/**
* repo-access — the single authority for "may this caller read/write this repo?"
*
* WHY THIS EXISTS
*
* pear-git-v3 has a write-authorization model and effectively no read model.
* Every transport gates `git-receive-pack` and lets `git-upload-pack` through:
*
* services/sync.js:65 "git-upload-pack (read) stays open"
* handlers/repos.js:709-710 "reads (git-upload-pack) stay open"
* handlers/ssh-keys.js:98 op !== 'receive-pack' -> { allowed: true }
*
* and there are ~12 independent read surfaces (P2P sync topic, tracker room,
* overlay-by-hash, HTTP smart-git, SSH, web browser, search, /files/raw,
* releases, docs, get-repo RPC). None of them consults any visibility state,
* and there is no shared repo-resolution helper to hang a check on —
* `repo-name.js isValidRepoName()` is a validator, not a gate.
*
* So every surface defaults to open, and every FUTURE surface will too. This
* module is the choke point that fixes the default. Surfaces call it; a
* surface that forgets is a test failure, not a silent leak.
*
* RELATIONSHIP TO #138 (export-ok)
*
* Complementary, not alternatives:
* - #138 keeps a repo OUT of the P2P layer entirely (never onboarded: no
* tracker, no room, no bundles). Prevention, no per-request cost.
* - this module governs the request-shaped surfaces (HTTP/SSH/web/RPC),
* where there is a caller identity to judge.
* A bare repo on disk is servable over smart-HTTP whether or not it was ever
* onboarded, so #138 alone does not make a repo private.
*
* DEFAULT
*
* Read access DENIES unless the repo is marked public. The current default —
* open — is the bug. But flipping it live would un-serve every existing repo,
* so `enforce` starts false: the predicate computes and reports the decision
* without acting on it, letting a deployment observe what WOULD break before
* anything does. See the migration sequence on #138.
*
* NOT REPLICATED
*
* The marker is local on-disk state, deliberately. "Do I serve this?" is a
* per-peer property — consistent with #135, where byte removal is already a
* per-peer operator step. If it replicated, marking a repo private on one
* backend would un-serve it on all of them, which is wrong for a repo
* deliberately served by exactly one machine.
*/
import fs from '#node:fs'
import { join } from '#node:path'
/** Presence of this file in `<name>.git/` marks a repo as publicly readable. */
export const PUBLIC_MARKER = 'pear-git-export-ok'
/**
* Mark a repo public, idempotently. Called wherever a bare repo comes into
* existence — repo creation, a peer materializing a repo it joined, and the
* cross-peer pull path.
*
* WHY AUTO-MARK RATHER THAN DEFAULT-PRIVATE: without this, hosting a repo
* silently becomes a two-step people forget, and every mirror-materialized
* repo would go unserved the moment enforcement turns on — the fleet would
* degrade quietly instead of failing loudly.
*
* WHY THAT IS SAFE: it cannot leak a private repo, by construction. An
* unexported repo is never onboarded into the P2P layer (no tracker, no room
* joined, no bundles published — forge #138), so it never replicates, so a
* peer can only ever materialize a repo that was ALREADY public at the source.
* Marking what you just pulled or created matches what it already was.
*
* New repos are public by default, preserving today's behaviour — every repo
* is currently readable. `pgh repo visibility <name> private` opts out.
*
* @returns {boolean} true if a marker was written, false if already present or
* the write failed (never throws — a marker is not worth failing a clone).
*/
export function ensureExportMarker (reposDir, name, _fs = fs) {
if (!reposDir || !name) return false
try {
const p = join(reposDir, name + '.git', PUBLIC_MARKER)
if (_fs.existsSync(p)) return false
_fs.writeFileSync(p, '')
return true
} catch {
return false
}
}
export const DENY_RETIRED = 'retired'
export const DENY_UNMARKED = 'not-marked-public'
export const DENY_BAD_NAME = 'invalid-repo-name'
export const ALLOW_PUBLIC = 'public'
export const ALLOW_LOCAL = 'local-transport'
export const ALLOW_READER = 'authorized-reader'
// Transports that are already the operator's own machine. UDS is the daemon's
// local socket; treating it as readable keeps `pgh` working on the host for
// repos the operator deliberately keeps unpublished — the "host it privately"
// case. HTTP and swarm are never local.
const LOCAL_TRANSPORTS = new Set(['uds', 'local'])
/**
* @param {object} o
* o.reposDir dir containing `<name>.git` bares
* o.isRetired (name) => boolean — RetiredRepos.isRetired, same shape
* o.enforce boolean — false (default) computes but does not deny
* o.fs injectable for tests
* o.isValidName (name) => boolean — repo-name.js isValidRepoName
* o.isReader async (name, ctx) => boolean — identity-based read grant,
* consulted ONLY by the `.async` variant and ONLY for a repo
* that is merely unmarked. Injected rather than imported so
* this module stays dependency-free and unit-testable.
* @returns {(name: string, ctx?: object) => { read, write, reason, wouldDeny, enforced }}
* with an `.async(name, ctx) => Promise<same>` identity-aware variant.
*/
export function createRepoAccess (o = {}) {
const reposDir = o.reposDir
const _fs = o.fs || fs
const isRetired = typeof o.isRetired === 'function' ? o.isRetired : () => false
const isValidName = typeof o.isValidName === 'function' ? o.isValidName : () => true
const enforce = o.enforce === true
if (!reposDir) throw new Error('createRepoAccess requires { reposDir }')
function isMarkedPublic (name) {
try {
return _fs.existsSync(join(reposDir, name + '.git', PUBLIC_MARKER))
} catch {
return false
}
}
/**
* Decide access. Never throws — a surface must be able to call this on any
* input, including hostile input, and get a decision.
*/
function repoAccess (name, ctx = {}) {
const decide = (allowRead, reason) => {
// When not enforcing, report the decision but do not act on it. This is
// what makes the rollout observable: callers log `wouldDeny` and the
// fleet keeps serving until the operator flips `enforce`.
const read = enforce ? allowRead : true
return {
read,
write: false, // write authz is unchanged and still owned by the existing push gates
reason,
wouldDeny: !allowRead,
enforced: enforce
}
}
if (!name || !isValidName(name)) return decide(false, DENY_BAD_NAME)
// Retirement outranks everything — a tombstoned repo is never readable,
// regardless of markers. Predicates compose; retired ∧ public => denied.
if (isRetired(name)) return decide(false, DENY_RETIRED)
// The operator's own machine. Deliberately NOT identity-based: identity
// checks are async (tracker writer-set lookups) and every call site here
// is synchronous. Per-identity read grants are the next increment.
if (LOCAL_TRANSPORTS.has(ctx.transport)) return decide(true, ALLOW_LOCAL)
if (isMarkedPublic(name)) return decide(true, ALLOW_PUBLIC)
return decide(false, DENY_UNMARKED)
}
/**
* Identity-aware variant. Same decision, plus one extra chance: a repo that
* is merely UNMARKED can still be read by an authorized reader (repo writer
* set / org membership). That lookup is async — it touches the tracker — so
* it cannot live in the sync path, which is why both exist.
*
* Only DENY_UNMARKED is rescuable. Retirement and an invalid name are final:
* no identity should resurrect a tombstoned repo, and no identity makes a
* traversal string into a repo name.
*
* `isReader` failing is a DENY, not an allow — a tracker that is unreachable
* or mid-migration must not open a private repo. Fail closed, always.
*
* Use this wherever the caller has an identity (web, RPC). The sync form
* remains correct for transports that have none (anonymous HTTP, SSH is
* keyed but resolved elsewhere).
*/
repoAccess.async = async function repoAccessAsync (name, ctx = {}) {
const base = repoAccess(name, ctx)
if (!base.wouldDeny) return base
if (base.reason !== DENY_UNMARKED) return base
if (typeof o.isReader !== 'function') return base
let ok = false
try {
ok = await o.isReader(name, ctx) === true
} catch {
ok = false // fail closed
}
if (!ok) return base
return {
read: true,
write: false,
reason: ALLOW_READER,
wouldDeny: false,
enforced: base.enforced
}
}
return repoAccess
}