/**
* Repo browser + git smart HTTP handlers.
*
* Delegates to hyper-sync's browser methods (listFiles, readFile,
* getCommits, getDiff, etc.) which shell out to git commands.
*/
import { spawn } from '#node:child_process'
import fs from '#node:fs'
import { join } from '#node:path'
import b4a from 'b4a'
import { requireWriter, requireAdmin, extractIdentity } from '../auth/check.js'
import { canPushGit, resolveGitReader } from '../auth/git-acl.js'
import { PUBLIC_MARKER, ensureExportMarker } from '../repo-access.js'
import { resolveToken } from '../auth/access-token.js'
import { recordPush } from '../services/push-index.js'
import { encodeB64, formatUrl } from '../services/follow-payload.js'
import { OP_CREATED, OP_RETIRED } from '../services/retired-repos.js'
import { repoNameError } from '../repo-name.js'
import { assertValidRepoName } from '../repo-name.js'
/**
* Extract a personal access token from an HTTP Basic header. Git's credential
* helper sends `Authorization: Basic base64("<user>:<token>")` — the token is
* normally the password, but some clients put it in the username field, so we
* accept either. Returns the candidate string (validated downstream by
* resolveToken) or null when no Basic header is present.
*/
export function parseBasicToken (req) {
const auth = req && req.headers && req.headers.authorization
if (!auth || !auth.startsWith('Basic ')) return null
let decoded
try {
decoded = b4a.toString(b4a.from(auth.slice(6).trim(), 'base64'), 'utf8')
} catch {
return null
}
const idx = decoded.indexOf(':')
const user = idx === -1 ? decoded : decoded.slice(0, idx)
const pass = idx === -1 ? '' : decoded.slice(idx + 1)
if (pass && pass.indexOf('pgv3_') === 0) return pass
if (user && user.indexOf('pgv3_') === 0) return user
return pass || user || null
}
/**
* Resolve the pusher's canonical userId from the SAME credentials allowPush
* accepts (session, then PAT). Used to attribute a successful push in the
* push-index for the public profile "repos pushed" list. Returns null when
* unattributable — attribution is best-effort and never gates the push.
*
* The two credential paths resolve to DIFFERENT shapes, and conflating them
* silently broke PAT attribution: getUserByIdentity returns a User (has
* .userId), but resolveToken returns { user, scope, identityId } — reading
* .userId off that wrapper is always undefined, so every token-authenticated
* push went unrecorded (push-index empty, profile "Repos pushed (0)") even
* though the push itself succeeded. Exported as a factory so the mapping is
* unit-testable without standing up an HTTP server.
*/
export function makeResolvePusher (svc) {
return async function resolvePusher (req) {
const session = svc.sessions ? extractIdentity(req, svc.sessions) : null
if (session && session.publicKey) {
const user = await svc.identityRegistry.getUserByIdentity(session.publicKey).catch(() => null)
if (user && user.userId) return user.userId
}
const token = parseBasicToken(req)
if (token) {
const resolved = await resolveToken(svc.identityRegistry, token).catch(() => null)
if (resolved && resolved.user && resolved.user.userId) return resolved.user.userId
}
return null
}
}
export function repoHandlers (svc) {
const sync = () => svc._sync
const registry = () => svc.identityRegistry
function ensureSync () {
const s = sync()
if (!s) throw new Error('sync service not running')
return s
}
/**
* Name check for the RETIREMENT path, which is cleanup — not creation.
*
* assertValidRepoName exists to stop malformed names being CREATED. Applying
* it unchanged to retire meant a malformed repo could never be REMOVED: the
* fleet carries real repos called `--help` (rejected: starts with "-") and
* `undefined` (rejected: stringified sentinel), and both were untombstonable
* — the guard against making them also protected them.
*
* So: a malformed name is accepted ONLY if that repo actually exists here.
* That cleans up real junk without letting anyone append tombstones for
* names that were never repos, which would just pollute the event log.
*
* Path separators, traversal and control characters are refused regardless —
* the name reaches the filesystem, and "it already exists" is not a reason to
* relax containment.
*/
const assertRetirableName = (name) => {
if (typeof name !== 'string' || !name) {
throw new Error('retire requires { name }')
}
if (name.includes('/') || name.includes('\\') || name.includes('..') ||
// eslint-disable-next-line no-control-regex
/[\u0000-\u001f\u007f]/.test(name)) {
throw new Error('unsafe repo name: ' + JSON.stringify(name))
}
if (!repoNameError(name)) return name // well-formed — nothing to argue about
const reposDir = svc._reposDir
const exists = !!reposDir && fs.existsSync(join(reposDir, name + '.git'))
if (!exists) {
throw new Error('invalid repo name: ' + JSON.stringify(name) +
' — malformed names may only be retired when the repo actually exists here')
}
return name
}
return [
// list-repos and repo-info already registered by syncHandlers — browser adds these:
{
// get-repo: name → { punchUrl, info }. The pgh CLI's `clone`
// command relies on this RPC; before 2026-05-18 the call was
// unimplemented and clone.js fell through to a plain `git clone`
// with no daemon coordination. The daemon is the authority on its
// own punch:// URL — serverKey + repo name → URL.
name: 'get-repo',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('get-repo requires { name }')
const s = ensureSync()
const info = s.info(params.name)
if (!info) throw new Error('repo not found: ' + params.name)
// READ GATE. This RPC hands out the punch:// URL and the on-disk path,
// which is enough to clone — so it is a read surface, not metadata.
// UDS callers are the local operator and pass through repo-access's
// local-transport allowance.
if (svc.repoAccess) {
let d = null
try {
d = await svc.repoAccess.async(params.name, {
transport: ctx && ctx.transport ? ctx.transport : 'http',
identity: ctx && ctx.identity ? ctx.identity : null
})
} catch { d = null }
if (d && d.wouldDeny) {
if (!d.enforced) {
// eslint-disable-next-line no-console
console.log('[read-gate] OBSERVE would-deny get-repo:' + params.name + ' (' + d.reason + ')')
} else {
// Same wording as the not-found throw above — do not confirm that
// a private repo exists.
throw new Error('repo not found: ' + params.name)
}
}
}
const serverKeyHex = svc.identity && svc.identity.publicKeyHex
return {
name: params.name,
punchUrl: serverKeyHex ? ('punch://' + serverKeyHex + '/' + params.name) : null,
info
}
}
},
{
// repo-follow-payload: emits the follow-payload + URL for a
// local repo, so a non-mirror peer can read-only replicate it
// via `pgh repo follow`. Auth model mirrors dump-keys — UDS +
// swarm allowed, HTTP blocked. The payload itself ships no
// secrets (only public keys and bootstrap roots) but we still
// gate on transport so the web layer doesn't accidentally leak
// them without explicit caller intent.
name: 'repo-follow-payload',
fn: async (ctx, params = {}) => {
if (ctx.transport === 'http') {
throw Object.assign(new Error('repo-follow-payload not available over HTTP'), {
code: 'EAUTH', status: 403
})
}
if (!params.name) throw new Error('repo-follow-payload requires { name }')
const s = ensureSync()
const info = s.info(params.name)
if (!info) throw new Error('repo not found: ' + params.name)
if (!svc.identity || !svc.identity.publicKeyHex) {
throw new Error('daemon identity not initialized')
}
const sourceServerKey = svc.identity.publicKeyHex
// Tracker key: open (or get cached) the per-repo HyperIssues to
// emit its bootstrap key. Same path repo-info / issues UIs take —
// safe to call on demand.
let issueBootstrapKey = null
if (svc.issueRegistry) {
try {
const tracker = await svc.issueRegistry.getForRepo(params.name)
issueBootstrapKey = tracker.keyHex
} catch (err) {
// ETRACKER_NOT_READY on mirrors before bootstrap delivery is
// not fatal — the payload still works without it, the
// follower discovers the key via RepoEventLog entries.
if (err && err.code !== 'ETRACKER_NOT_READY') throw err
}
}
const repoEventLogKey = svc.bootstrapKeys && svc.bootstrapKeys.repoEventLog
? svc.bootstrapKeys.repoEventLog
: null
const payloadFields = {
kind: 'repo',
sourceServerKey,
name: params.name,
issueBootstrapKey,
repoEventLogKey
}
return {
payload: encodeB64(payloadFields),
url: formatUrl(payloadFields),
sourceServerKey,
name: params.name,
issueBootstrapKey,
repoEventLogKey
}
}
},
{
name: 'repo-files',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-files requires { name }')
const s = ensureSync()
return s.listFiles(params.name, params.ref, params.path)
}
},
{
name: 'repo-file',
fn: async (ctx, params = {}) => {
if (!params.name || !params.path) throw new Error('repo-file requires { name, path }')
const s = ensureSync()
const content = await s.readFile(params.name, params.path, params.ref)
const binary = await s.isBinary(params.name, params.path, params.ref)
return { path: params.path, content: binary ? null : content, binary }
}
},
{
name: 'repo-commits',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-commits requires { name }')
const s = ensureSync()
return s.getCommits(params.name, params.ref, params.limit)
}
},
{
name: 'repo-commit',
fn: async (ctx, params = {}) => {
if (!params.name || !params.hash) throw new Error('repo-commit requires { name, hash }')
const s = ensureSync()
const commit = await s.getCommit(params.name, params.hash)
if (!commit) throw new Error('commit not found')
const diff = await s.getCommitDiff(params.name, params.hash)
return { ...commit, diff }
}
},
{
name: 'repo-branches',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-branches requires { name }')
const s = ensureSync()
return s.getBranches(params.name)
}
},
{
name: 'repo-tags',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-tags requires { name }')
const s = ensureSync()
return s.getTags(params.name)
}
},
{
name: 'repo-readme',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-readme requires { name }')
const s = ensureSync()
return s.getReadme(params.name, params.ref)
}
},
{
name: 'repo-diff',
fn: async (ctx, params = {}) => {
if (!params.name || !params.from) throw new Error('repo-diff requires { name, from }')
const s = ensureSync()
return s.getDiff(params.name, params.from, params.to)
}
},
{
name: 'repo-search',
fn: async (ctx, params = {}) => {
if (!params.name || !params.query) throw new Error('repo-search requires { name, query }')
const s = ensureSync()
return s.searchFiles(params.name, params.query, params.ref)
}
},
{
// Autocomplete: case-insensitive substring match on repo name +
// description. Returns up to `limit` results (default 8), sorted
// with name matches ranked higher than description matches.
name: 'search-all-repos',
fn: async (ctx, params = {}) => {
const q = String(params.query || '').toLowerCase().trim()
const limit = Math.min(parseInt(params.limit, 10) || 8, 50)
if (q.length < 2) return { repos: [], query: q, total: 0 }
const s = sync()
if (!s) return { repos: [], query: q, total: 0 }
// Rank on the CHEAP fields first. This fires on a 150ms debounce from
// the nav search box, and the full enrichRepos() it used to call walks
// git over every repo — the same full-fleet pass as the /repos landing
// page, on a keystroke path. Name and branch count are already in
// status(); description is a small file read (free when cached).
const { describeRepos, enrichRepos } = await import('../services/repo-activity.js')
const candidates = await describeRepos(s.status(), s.repos)
const scored = []
for (const r of candidates) {
const name = String(r.name || '').toLowerCase()
const desc = String(r.description || '').toLowerCase()
const nameHit = name.includes(q)
const descHit = desc.includes(q)
if (!nameHit && !descHit) continue
scored.push({ base: r, relevance: nameHit ? 2 : 1 })
}
scored.sort((a, b) => {
if (b.relevance !== a.relevance) return b.relevance - a.relevance
return a.base.name.localeCompare(b.base.name)
})
// Cap BEFORE enriching: `commits` is the only field that costs a git
// walk, and only the rows actually returned need it. Ranking is
// unaffected — it never depended on commit count.
const capped = scored.slice(0, limit)
if (capped.length === 0) return { repos: [], query: q, total: 0 }
const enriched = await enrichRepos({ repos: capped.map((m) => m.base) }, s.repos)
// enrichRepos preserves input order, so index i lines up with capped[i].
const repos = enriched.map((r, i) => ({
name: r.name,
description: r.description || null,
commits: r.commits || 0,
branches: r.branches || 0,
relevance: capped[i].relevance
}))
return { repos, query: q, total: repos.length }
}
},
{
// Code search via `git grep` per repo. Limits: maxRepos (default 10),
// limit = total matches cap (default 100). Returns matches grouped
// by repo for the autocomplete dropdown.
name: 'search-code-all',
fn: async (ctx, params = {}) => {
const q = String(params.query || '').trim()
const limit = Math.min(parseInt(params.limit, 10) || 100, 500)
const maxRepos = Math.min(parseInt(params.maxRepos, 10) || 10, 50)
if (q.length < 2) return { results: [], query: q, totalMatches: 0, reposSearched: 0 }
const s = sync()
if (!s) return { results: [], query: q, totalMatches: 0, reposSearched: 0 }
const repos = [...s.repos.entries()].slice(0, maxRepos)
const results = []
let totalMatches = 0
for (const [name, info] of repos) {
if (totalMatches >= limit) break
const out = await execGit(info.path, [
'grep', '-n', '-I', '-i', '--heading', '--break', '-e', q, 'HEAD'
]).catch(() => '')
if (!out || !out.trim()) continue
const matches = parseGrepOutput(out, limit - totalMatches)
if (matches.length === 0) continue
results.push({ repo: name, matches })
totalMatches += matches.length
}
return {
results,
query: q,
totalMatches,
reposSearched: repos.length
}
}
},
// ── Retire / un-retire (tombstones, issue #135) ────────────
//
// ⚠ RETIRE DOES NOT DELETE ANYTHING, AND MUST NEVER LEARN HOW.
// It appends an op:2 tombstone to the replicated RepoEventLog. Every peer
// folds that into a retired-set and stops listing / serving / pulling /
// re-creating the repo. Removing the bare repo's BYTES is a manual
// operator step on each peer — `rm -rf ~/git-repos/<name>.git` by hand.
// pear-git-v3 has no capability to delete a repo and must not gain one.
//
// The tombstone is what makes that manual delete STICK: without it a peer
// that still announces the repo re-clones it onto the peer that deleted it.
//
// ⚠ ROLLOUT: a peer on older code IGNORES op:2 (it does not crash) and
// keeps announcing. Every peer must run 0.1.178+ BEFORE removing bytes.
{
// repo-backfill-visibility — mark every existing repo public, here.
//
// Same job as scripts/backfill-export-ok.js, reachable on an INSTALLED
// daemon. The script only works where the source tree exists, and
// `pgh.stage.ignore` lists `scripts/`, so it never ships to a peer
// installed via `pgh app install` — i.e. it is missing on exactly the
// machines that must run it before enforcement (#138 R1). An operator
// needs a daemon-side verb, not a file they do not have.
//
// Local + per-peer by design (#135), so this must be run on each peer.
// Idempotent.
name: 'repo-backfill-visibility',
fn: async (ctx, params = {}) => {
await requireAdmin(ctx, registry())
const reposDir = svc._reposDir
if (!reposDir) throw new Error('repos dir not configured')
let entries = []
try { entries = fs.readdirSync(reposDir) } catch (err) {
throw new Error('cannot read repos dir: ' + err.message)
}
const dryRun = params.dryRun === true
let marked = 0
let already = 0
const names = []
for (const d of entries) {
if (!d.endsWith('.git')) continue
const repoDir = join(reposDir, d)
try { if (!fs.statSync(repoDir).isDirectory()) continue } catch { continue }
const name = d.replace(/\.git$/, '')
if (fs.existsSync(join(repoDir, PUBLIC_MARKER))) { already++; continue }
if (dryRun) { marked++; names.push(name); continue }
if (ensureExportMarker(reposDir, name)) { marked++; names.push(name) }
}
return {
reposDir,
dryRun,
bareRepos: entries.filter((d) => d.endsWith('.git')).length,
marked,
alreadyMarked: already,
names,
note: 'The marker is LOCAL — run this on EVERY peer before enabling PEAR_GIT_READ_GATE=enforce.'
}
}
},
{
// repo-visibility — read or set whether this peer SERVES a repo.
//
// Backed by the pear-git-export-ok marker (forge #138), the same state
// the read gate consults. LOCAL and not replicated: "do I serve this?"
// is a per-peer property, so this must be run on each peer — consistent
// with #135 making byte removal a per-peer operator step.
//
// ⚠ NOT SYMMETRIC. public→private is BEST-EFFORT, not a retraction: it
// stops future serving here, but cannot pull back bundles already in the
// overlay (#135 — additive, no GC) nor un-tell peers a discoveryKey they
// already hold. Reported in the response so an operator does not act on
// a false belief, the same hazard class as #135's "retire is not delete".
name: 'repo-visibility',
fn: async (ctx, params = {}) => {
const reposDir = svc._reposDir
if (!reposDir) throw new Error('repos dir not configured')
assertValidRepoName(params.name)
const repoDir = join(reposDir, params.name + '.git')
if (!fs.existsSync(repoDir)) throw new Error('repo not found: ' + params.name)
const marker = join(repoDir, PUBLIC_MARKER)
const current = () => (fs.existsSync(marker) ? 'public' : 'private')
// Query is read-only — no admin needed to ask about a repo you can
// already see. Changing it is an admin action.
if (params.visibility === undefined || params.visibility === null) {
return { name: params.name, visibility: current() }
}
await requireAdmin(ctx, registry())
if (params.visibility !== 'public' && params.visibility !== 'private') {
throw new Error("visibility must be 'public' or 'private'")
}
const before = current()
if (params.visibility === 'public') {
if (before !== 'public') fs.writeFileSync(marker, '')
return { name: params.name, visibility: 'public', changed: before !== 'public' }
}
if (before === 'public') fs.unlinkSync(marker)
return {
name: params.name,
visibility: 'private',
changed: before !== 'private',
warning: 'Best-effort. This peer stops serving ' + params.name +
', but bundles already published remain in the overlay (no true deletion, #135) ' +
'and peers already holding the discoveryKey keep it. Run this on every peer.'
}
}
},
{
name: 'retire-repo',
fn: async (ctx, params = {}) => {
await requireAdmin(ctx, registry())
assertRetirableName(params.name)
const log = svc._repoEventLog
if (!log) throw new Error('repo-event-log not available')
// Single-writer by construction: mirrors open the log by key and are
// read-only replicas, so only the primary can author a tombstone.
if (!log.writable) {
const err = new Error('retire-repo must run on the primary — this daemon holds a read-only replica of the repo-event-log')
err.code = 'ENOT_PRIMARY'
throw err
}
await log.append({ op: OP_RETIRED, name: params.name, head: '' })
if (svc._retiredRepos) svc._retiredRepos.applyEntry({ op: OP_RETIRED, name: params.name }, log.length - 1)
return {
retired: params.name,
note: 'Tombstone appended. Bytes were NOT removed. Verify every peer shows it retired, then remove ~/git-repos/' + params.name + '.git by hand on each peer.'
}
}
},
{
// Reversal: a later op:0 wins by sequence number, so a mistaken retire
// is undone by re-announcing the repo as created.
name: 'unretire-repo',
fn: async (ctx, params = {}) => {
await requireAdmin(ctx, registry())
assertRetirableName(params.name)
const log = svc._repoEventLog
if (!log) throw new Error('repo-event-log not available')
if (!log.writable) {
const err = new Error('unretire-repo must run on the primary')
err.code = 'ENOT_PRIMARY'
throw err
}
await log.append({ op: OP_CREATED, name: params.name, head: '' })
if (svc._retiredRepos) svc._retiredRepos.applyEntry({ op: OP_CREATED, name: params.name }, log.length - 1)
return { unretired: params.name }
}
},
{
// The propagation check. This is the gate an operator must pass on
// EVERY peer before removing any bytes.
name: 'list-retired-repos',
fn: async () => {
const set = svc._retiredRepos
return { retired: set ? set.names() : [], folded: !!set }
}
},
// ── Per-repo-autobase invites / join / enroll (issue #35) ──
//
// A repo tracker is already its own autobase, so joining one repo
// replicates ONLY that repo. Mirrors the org invite handlers
// (create-org-invite / join-org / enroll-writer / list-org-keys)
// — peer mode = these handlers; mirror mode = list-repo-keys.
{
// Owner: mint a blind-pairing invite for one repo (P2P). A joiner
// pairs over the swarm and, for contributor+, is addWritered DURING
// pairing — no HTTP, no separate enroll step.
name: 'create-repo-invite',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
if (!params.name) throw new Error('create-repo-invite requires { name }')
if (!svc.issueRegistry) throw new Error('issue registry not available')
if (!svc.swarm || !svc.swarm.swarm) throw new Error('swarm not available for pairing')
const { invitation, descriptor } = await svc.issueRegistry.createRepoInvite(params.name, {
role: params.role || 'reader',
swarm: svc.swarm.swarm
})
// Persist so the pairing member is re-registered on daemon restart.
if (svc._pendingPairings && descriptor) svc._pendingPairings.add('repos', params.name, descriptor)
// Announce this repo room on the shared swarm so a joiner can
// rendezvous for it by its discoveryKey (Keet activateRoom).
const tracker = await svc.issueRegistry.getForRepo(params.name)
if (tracker && typeof svc.activateRoom === 'function') {
svc.activateRoom(tracker.discoveryKey)
}
return { invitation }
}
},
{
// Joiner: pair into ONLY that repo over the swarm; become a writer
// (contributor+) DURING pairing. One step, P2P — no enroll-repo-writer.
name: 'join-repo',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
if (!params.invitation) throw new Error('join-repo requires { invitation }')
if (!svc.issueRegistry) throw new Error('issue registry not available')
if (!svc.swarm || !svc.swarm.swarm) throw new Error('swarm not available for pairing')
const joined = await svc.issueRegistry.joinRepo(params.invitation, { swarm: svc.swarm.swarm })
// Join THIS repo room's topic on the shared swarm to rendezvous
// with the owner for it — keeps peer-mode scoped to just this repo.
const tracker = await svc.issueRegistry.getForRepo(joined.name)
if (tracker && typeof svc.activateRoom === 'function') {
svc.activateRoom(tracker.discoveryKey)
}
// gip-overlay: materialize this repo's bare repo from the autobase as
// refs replicate in (objects fetched via the overlay bundle chain).
if (svc._refReconcilers) svc._refReconcilers.ensureFor(joined.name).catch(() => {})
return joined
}
},
{
// Mirror mode: every repo's tracker bootstrap key, for a sister
// daemon to mount each → full replica. Admin-gated (full-forge
// replication capability); peers never call this.
name: 'list-repo-keys',
fn: async (ctx, params = {}) => {
await requireAdmin(ctx, registry())
if (!svc.issueRegistry) throw new Error('issue registry not available')
return svc.issueRegistry.listRepoKeys()
}
},
{
// List the push-authorized writers of a repo (gip authority = the
// per-repo autobase writer set). Writer-gated visibility.
name: 'repo-list-writers',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
if (!params.name) throw new Error('repo-list-writers requires { name }')
if (!svc.issueRegistry) throw new Error('issue registry not available')
const tracker = await svc.issueRegistry.getForRepo(params.name)
return { name: params.name, writers: await tracker.listWriters() }
}
},
{
// Revoke a writer's push authority on a repo. Admin-gated. After
// removal the node's future ref/bundle ops are no longer applied; its
// past applied ops stand; read access is unaffected (reads are open).
name: 'repo-revoke-writer',
fn: async (ctx, params = {}) => {
await requireAdmin(ctx, registry())
if (!params.name) throw new Error('repo-revoke-writer requires { name }')
if (!params.userId) throw new Error('repo-revoke-writer requires { userId }')
if (!svc.issueRegistry) throw new Error('issue registry not available')
const tracker = await svc.issueRegistry.getForRepo(params.name)
await tracker.removeWriter(params.userId)
return { name: params.name, userId: params.userId, revoked: true }
}
},
{
// READ the per-repo autobase's ref-VIEW — the P2P source of truth for a
// repo's refs, as distinct from the bare repo on disk.
//
// Nothing exposed this before, and its absence caused a real mistake on
// 2026-08-08: `pgh repo branches` was cited as evidence about the view when
// it actually reads DISK (syncServer.getBranches), and a ref deletion was
// then believed to have propagated when for 3 of 5 repos it silently had
// not. Ref replication cannot be reasoned about without seeing BOTH sides,
// so this returns both and marks where they disagree.
name: 'repo-refs',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-refs requires { name }')
if (!svc.issueRegistry) throw new Error('issue registry not available')
const tracker = await svc.issueRegistry.getForRepo(params.name)
const view = new Map()
for (const r of await tracker.listRefs()) {
if (r && r.ref) view.set(r.ref, r.hash || null)
}
const disk = new Map()
if (svc._reposDir) {
const { listDiskRefs } = await import('../services/ref-publish-reconciler.js')
for (const [ref, oid] of await listDiskRefs(join(svc._reposDir, params.name + '.git'))) {
disk.set(ref, oid)
}
}
// Classification lives in services/ref-diff.js so the rule that decides
// "is this a resurrection hazard" is pure and directly testable.
const { classifyRefs, refDisagreements } = await import('../services/ref-diff.js')
const refs = classifyRefs(view, disk)
return {
name: params.name,
// Whether THIS daemon can publish ref changes for this repo at all.
// gipPublishRef returns SILENTLY when false, so a delete issued here
// would be a no-op that still answers ok:true — the 2026-08-08 trap.
writable: tracker.writable,
trackerKey: tracker.keyHex,
refs,
// Pre-filtered for the common operator question: what disagrees?
disagreements: refDisagreements(refs)
}
}
},
{
// Surface THIS daemon's own tracker local key for a repo — the key an
// owner needs to addWriter this daemon onto the shared per-repo autobase.
// Read-only (it's just a public key). This is the missing primitive that
// lets an admin learn a peer's writer key WITHOUT the peer tearing down an
// existing (e.g. org-grouped, read-only) mount: the admin pulls this key,
// then calls repo-add-writer. `writable` tells the caller whether this
// daemon is already a writer on the shared autobase.
name: 'repo-local-key',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-local-key requires { name }')
if (!svc.issueRegistry) throw new Error('issue registry not available')
const tracker = await svc.issueRegistry.getForRepo(params.name)
return {
name: params.name,
localKey: tracker.localKeyHex,
trackerKey: tracker.keyHex,
writable: tracker.writable,
identity: svc.identity ? svc.identity.publicKeyHex : null
}
}
},
{
// Grant a peer push authority on a repo by adding its tracker LOCAL key
// to the per-repo autobase writer set (gip authority). Admin-gated; the
// symmetric counterpart to repo-revoke-writer.
//
// This is the path to make an ALREADY-mounted tracker writable on a peer
// (e.g. one mounted read-only via the org-resource path): the peer never
// re-joins (which would no-op — issue-registry joinRepo short-circuits if
// the repo is already open). Instead the OWNER addWriters the peer's local
// key here; once the add-writer op replicates, the peer's own local key is
// in the writer set and its autobase becomes `writable`, so its ref/bundle
// ops linearize into the shared view (symmetric P2P writes).
//
// role 'owner' = indexer (offline-tolerant co-equal writer),
// 'writer' = non-indexer writer (default), 'mirror' = read-only metadata.
name: 'repo-add-writer',
fn: async (ctx, params = {}) => {
await requireAdmin(ctx, registry())
if (!params.name) throw new Error('repo-add-writer requires { name }')
if (!params.userId) throw new Error('repo-add-writer requires { userId }')
if (!params.writerKey) {
throw new Error('repo-add-writer requires { writerKey } (peer tracker local key hex)')
}
const role = params.role || 'writer'
if (!['owner', 'writer', 'mirror'].includes(role)) {
throw new Error('role must be owner, writer, or mirror')
}
if (!svc.issueRegistry) throw new Error('issue registry not available')
const addedBy = (ctx && ctx.identity && ctx.identity.publicKey) ||
(ctx && ctx.transport === 'uds' ? 'uds' : null)
const tracker = await svc.issueRegistry.getForRepo(params.name)
await tracker.addWriter(params.userId, { writerKey: params.writerKey, role, addedBy })
return { name: params.name, userId: params.userId, role, added: true }
}
},
{
// AUTO-ADMISSION (forceResync companion, #3). A peer that re-joined a
// tracker under a fresh corestore (`repo-resync`) holds a NEW, read-only
// local key. Instead of an admin manually running repo-add-writer, the peer
// REQUESTS admission of its own key over the swarm; the owner auto-approves
// IFF the requester ALREADY holds write authority for the repo
// (`requireWriter` — the same gate as a git push). This grants NO new trust:
// it re-admits an already-authorized member's rotated key. Role is FORCED to
// non-indexer 'writer' — indexer/owner promotion stays a deliberate admin
// act (no auto-escalation via a self-request). The swarm call is
// envelope-signed, so ctx.identity is the verified requester.
name: 'repo-request-writer',
fn: async (ctx, params = {}) => {
if (!params.name) throw new Error('repo-request-writer requires { name }')
if (!params.writerKey) {
throw new Error('repo-request-writer requires { writerKey } (your tracker local key hex)')
}
// Gate: requester must already have write authority (throws otherwise).
await requireWriter(ctx, registry())
if (!svc.issueRegistry) throw new Error('issue registry not available')
const requester = (ctx && ctx.identity && ctx.identity.publicKey) ||
(ctx && ctx.transport === 'uds' ? 'uds' : null)
if (!requester) throw new Error('repo-request-writer requires a verified identity')
const userId = params.userId || requester
const tracker = await svc.issueRegistry.getForRepo(params.name)
// Self-request NEVER grants indexer/owner — plain writer only.
await tracker.addWriter(userId, { writerKey: params.writerKey, role: 'writer', addedBy: requester })
return { name: params.name, userId, role: 'writer', admitted: true }
}
}
]
}
/**
* Pick a best-effort HEAD SHA from a `for-each-ref` snapshot map.
* Prefers refs/heads/main, then refs/heads/master, then any refs/heads/*.
* Returns '' if the repo has no branches yet (e.g. bare/empty).
*/
function pickHead (refs) {
if (!refs || refs.size === 0) return ''
if (refs.has('refs/heads/main')) return refs.get('refs/heads/main')
if (refs.has('refs/heads/master')) return refs.get('refs/heads/master')
for (const [ref, sha] of refs) {
if (ref.startsWith('refs/heads/')) return sha
}
return ''
}
function execGit (repoPath, args) {
return new Promise((resolve) => {
const proc = spawn('git', ['-C', repoPath, ...args], {
stdio: ['ignore', 'pipe', 'ignore']
})
const chunks = []
proc.stdout.on('data', (c) => chunks.push(c))
proc.on('error', () => resolve(''))
proc.on('close', () => resolve(Buffer.concat(chunks).toString('utf8')))
})
}
/**
* Parse `git grep -n --heading --break` output into {file, line, content}
* records. The output format is:
* HEAD:path/to/file.js
* 12:matching line content
* 15:another match
* <blank>
* HEAD:other/file.js
* ...
*/
function parseGrepOutput (output, limit) {
const matches = []
let currentFile = null
for (const line of output.split('\n')) {
if (matches.length >= limit) break
if (!line.trim() || line === '--') continue
const fileMatch = line.match(/^HEAD:(.+)$/)
if (fileMatch) {
currentFile = fileMatch[1]
continue
}
const lineMatch = line.match(/^(\d+)([:-])(.*)$/)
if (lineMatch && currentFile) {
const [, lineNum, sep, content] = lineMatch
matches.push({
file: currentFile,
line: parseInt(lineNum, 10),
content: content.trim(),
isMatch: sep === ':'
})
}
}
return matches
}
/**
* Git smart HTTP routes — register directly on the HTTP server.
* Handles:
* GET /:repo.git/info/refs?service=git-upload-pack
* POST /:repo.git/git-upload-pack
* POST /:repo.git/git-receive-pack
*/
export function registerGitRoutes (svc) {
const sync = () => svc._sync
if (!svc.http || !svc.http.server) return
const server = svc.http.server
// Push gate (issue #37, item 2; classic-git-auth Phase 1): git-receive-pack
// requires write access; reads (git-upload-pack) stay open. Three credential
// paths, all resolving through the SAME IdentityRegistry user-perm model:
// 1. session bearer (browser / signed-envelope) — Bearer header / cookie
// 2. HTTP Basic (stock `git push https://…`) — password is a PAT
// 3. none → 401 WWW-Authenticate so git prompts for a token
// Returns true if allowed; otherwise writes the response + returns false.
async function allowPush (req, res) {
// 1. Existing session path (browser dashboard).
const session = svc.sessions ? extractIdentity(req, svc.sessions) : null
if (session && session.publicKey) {
const ok = await canPushGit(svc.identityRegistry, {
transport: 'http',
identityId: session.publicKey
})
if (ok) return true
}
// 2. HTTP Basic → personal access token.
const token = parseBasicToken(req)
if (token) {
const ok = await canPushGit(svc.identityRegistry, { transport: 'http', token })
if (ok) return true
// Credentials WERE presented but aren't authorized — 403, don't 401-loop.
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('push denied: token invalid, expired, or lacks write access\n')
return false
}
// 3. No usable credentials — challenge so stock git prompts for a token.
res.writeHead(401, {
'Content-Type': 'text/plain; charset=utf-8',
'WWW-Authenticate': 'Basic realm="pear-git", charset="UTF-8"'
})
res.end('authentication required: push with a personal access token as the password\n')
return false
}
// Resolve the pusher's canonical userId — see makeResolvePusher above.
const resolvePusher = makeResolvePusher(svc)
/**
* Read gate for the classic-git transports. Consults the shared choke point
* (svc.repoAccess, lib/repo-access.js) rather than deciding locally, so that
* one visibility change moves every surface at once.
*
* In OBSERVE mode (the default) this logs what it WOULD deny and allows the
* request — enforcing before a fleet-wide marker backfill would un-serve
* every existing repo. Returns true if the request may proceed.
*/
async function allowRead (repoName, res, where, req) {
const access = svc.repoAccess
if (!access) return true // gate not wired (tests / partial boot) — fail open, as today
// Identity extraction mirrors allowPush's credential paths — session
// bearer/cookie first, then HTTP Basic where the password is a PAT — but
// resolves them for READ: resolveGitReader accepts a read-scoped token,
// which canPushGit rightly refuses. Anonymous stays anonymous; a public
// repo needs no identity at all.
let userId = null
if (req) {
try {
const session = svc.sessions ? extractIdentity(req, svc.sessions) : null
const caller = session && session.publicKey
? { transport: 'http', identityId: session.publicKey }
: (() => { const t = parseBasicToken(req); return t ? { transport: 'http', token: t } : null })()
if (caller) {
const resolved = await resolveGitReader(svc.identityRegistry, caller)
if (resolved && resolved.user) userId = resolved.user.userId
}
} catch {
userId = null // unresolvable credential reads as anonymous, never as authorized
}
}
// transport 'http': deliberately NOT treated as local. The daemon's own
// tooling reaches it over UDS; anything arriving on the HTTP server is a
// remote client even when it dials 127.0.0.1.
const ctx = { transport: 'http', userId }
// .async only consults the identity hook for a repo that is not public, so
// public clones still cost no registry or tracker lookup.
const decision = await access.async(repoName, ctx)
if (!decision.wouldDeny) return true
if (!decision.enforced) {
// eslint-disable-next-line no-console
console.log('[read-gate] OBSERVE would-deny ' + where + ' ' + repoName + ' (' + decision.reason + ')')
return true
}
// eslint-disable-next-line no-console
console.log('[read-gate] deny ' + where + ' ' + repoName + ' (' + decision.reason + ')')
// 404, not 403: a private repo should not confirm its own existence to an
// unauthorized caller. Matches the "Repository not found" body above so the
// two are indistinguishable.
res.writeHead(404)
res.end('Repository not found')
return false
}
server.on('request', async (req, res) => {
const s = sync()
if (!s) return // not handling this request
// Match /:repo.git/info/refs
const infoMatch = req.url.match(/^\/([^/]+)\.git\/info\/refs/)
if (infoMatch && req.method === 'GET') {
const repoName = infoMatch[1]
const url = new URL(req.url, 'http://localhost')
const service = url.searchParams.get('service')
if (!s.repos.has(repoName)) {
res.writeHead(404)
res.end('Repository not found')
return
}
// Gate the push advertisement so an unauthorized client is rejected
// at discovery, before it sends a pack.
if (service === 'git-receive-pack' && !(await allowPush(req, res))) return
// Read gate. Historically reads advertised freely on every transport —
// that default is what makes "private repo" unimplementable. Denying at
// info/refs stops `git clone` before any pack is generated.
if (service !== 'git-receive-pack' && !(await allowRead(repoName, res, 'info/refs', req))) return
const repoPath = s._repoPath(repoName)
s.handleInfoRefs(repoPath, service).then(body => {
res.writeHead(200, {
'Content-Type': 'application/x-' + service + '-advertisement',
'Cache-Control': 'no-cache'
})
res.end(body)
}).catch(err => {
res.writeHead(500)
res.end(err.message)
})
return
}
// Match /:repo.git/git-upload-pack or git-receive-pack
const packMatch = req.url.match(/^\/([^/]+)\.git\/(git-upload-pack|git-receive-pack)$/)
if (packMatch && req.method === 'POST') {
const repoName = packMatch[1]
const service = packMatch[2]
if (!s.repos.has(repoName)) {
res.writeHead(404)
res.end('Repository not found')
return
}
// Push gate: git-receive-pack requires write access.
if (service === 'git-receive-pack' && !(await allowPush(req, res))) return
// Read gate — defence in depth. A client that skipped info/refs (or was
// told to advertise by a cached response) still cannot fetch a pack.
if (service === 'git-upload-pack' && !(await allowRead(repoName, res, 'upload-pack', req))) return
// Attribute the push to its author for the public profile activity list.
// Resolved now (headers intact); recorded on successful close below.
let pusher = null
if (service === 'git-receive-pack') pusher = await resolvePusher(req).catch(() => null)
const repoPath = s._repoPath(repoName)
// For a PUSH (git-receive-pack), snapshot refs BEFORE spawning so
// we can diff afterwards and fire auto-builds for any ref whose SHA
// changed. Fire-and-forget — build runs in the background, HTTP
// response returns as soon as git-receive-pack finishes.
let preRefs = null
if (service === 'git-receive-pack' && svc._releases && svc._sync) {
try {
const { snapshotRefs } = await import('../ci-orchestrator.js')
preRefs = await snapshotRefs(repoPath)
} catch {}
}
const proc = s.spawnGitService(repoPath, service)
res.writeHead(200, {
'Content-Type': 'application/x-' + service + '-result',
'Cache-Control': 'no-cache'
})
req.pipe(proc.stdin)
proc.stdout.pipe(res)
proc.stderr.on('data', () => {}) // swallow stderr
proc.on('close', () => {
res.end()
// Attribute the push (best-effort) once git-receive-pack has finished.
if (service === 'git-receive-pack' && pusher) recordPush(pusher, repoName)
// Auto-build hook: diff refs + kick off background builds
if (service === 'git-receive-pack' && preRefs) {
import('../ci-orchestrator.js').then(async ({ snapshotRefs, autoBuildFromPush }) => {
try {
const postRefs = await snapshotRefs(repoPath)
await autoBuildFromPush({
vdriveSeeder: svc._vdriveSeeder,
sync: s,
releaseManager: svc._releases,
pendingBuilds: svc._pendingBuilds,
repo: repoName,
before: preRefs,
after: postRefs
})
// Drive the gip producer for each ref this push changed, so an
// HTTP push reaches the P2P layer DIRECTLY — not only via the
// post-receive hook's loopback to ref-update-observed. Closes the
// asymmetry where this in-process observer drove the legacy
// repoEventLog but not gip. Idempotent with a hook fire for the
// same push (the autobase FF guard no-ops the duplicate);
// fire-and-forget; self-guards on tracker.writable.
if (svc._gipPublishRef) {
const ZERO = '0000000000000000000000000000000000000000'
for (const [ref, newSha] of postRefs) {
if (preRefs.get(ref) === newSha) continue
svc._gipPublishRef({ name: repoName, ref, oldSha: preRefs.get(ref) || null, newSha })
.catch((e) => console.error('[post-receive] gip publish failed for', ref, '-', e.message))
}
for (const [ref, oldSha] of preRefs) {
if (postRefs.has(ref)) continue
svc._gipPublishRef({ name: repoName, ref, oldSha, newSha: ZERO })
.catch((e) => console.error('[post-receive] gip delete failed for', ref, '-', e.message))
}
}
// Append an 'updated' event to the repo-event log so
// mirror daemons get a live notification of the push. Best-
// effort — failures here must not affect the git push.
if (svc._repoEventLog && svc._repoEventLog.writable) {
try {
await svc._repoEventLog.append({
op: 1, // updated
name: repoName,
head: pickHead(postRefs)
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('[post-receive] repo-event-log append failed:', err.message)
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[post-receive] auto-build failed:', err.message)
}
}).catch(() => {})
}
})
return
}
})
}