/**
* Auth checks and request middleware for pear-git-v3.
*
* Two layers:
*
* 1. extractIdentity(req, sessions) — reads the bearer token from
* `Authorization: Bearer <token>` header OR `pgv3-session=<token>` cookie,
* looks it up in the SessionStore, returns the identity record or null.
* Does NOT throw — caller decides if missing identity is OK.
*
* 2. requireWriter(ctx) / requireAdmin(ctx) — handler-side checks. Throw a
* 401-style Error if the caller is not allowed to mutate.
* Pass automatically when ctx.transport === 'uds' (local user is
* trusted by virtue of having the UDS socket open).
* Pass when ctx.identity.ownerMatched (envelope root = daemon's
* hyperident identity root — the intrinsic owner).
* Otherwise look up the pubkey in IdentityRegistry and check
* globalPermissions for 'admin' / 'write'.
*
* IdentityRegistry (hyper-identity Autobase) is the source of truth for
* user → perms mapping. Replaced the legacy trust-map in F.6.
*/
const COOKIE_NAME = 'pgv3-session'
class AuthError extends Error {
constructor (message, status = 401) {
super(message)
this.name = 'AuthError'
this.status = status
this.code = 'EAUTH'
}
}
export { AuthError, COOKIE_NAME }
/**
* Is this identity revoked? (org #14)
*
* The registry keeps revocation in a SEPARATE `revoked:<identityId>` view
* index — the record returned by getIdentity does NOT carry the flag, so
* `identity.revoked` is always false and testing it silently misses every
* revocation. isRevoked() is the authoritative read.
*
* This check lived only in the SSH branch of git-acl.js and in resolveToken,
* so revoking an identity did not stop it pushing over an HTTP session or
* punch://, nor invoking writer/admin RPCs. One helper, called on every
* resolution path, is what keeps the next transport from re-opening the hole.
*
* Fails OPEN on a read error, matching resolveToken and the original SSH
* branch: a transient autobase read must not deny every caller at once. A
* genuine revocation is a durable view entry, not a transient failure.
*/
export async function isIdentityRevoked (registry, identityId) {
if (!registry || !identityId) return false
if (typeof registry.isRevoked !== 'function') return false
return registry.isRevoked(identityId).catch(() => false)
}
/**
* Extract the bearer token from a request.
* @param {http.IncomingMessage} req
* @returns {string|null}
*/
export function extractToken (req) {
if (!req || !req.headers) return null
const auth = req.headers.authorization
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7).trim() || null
}
const cookieHeader = req.headers.cookie
if (cookieHeader) {
for (const part of cookieHeader.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
const k = part.slice(0, idx).trim()
if (k === COOKIE_NAME) {
return decodeURIComponent(part.slice(idx + 1).trim()) || null
}
}
}
return null
}
/**
* Look up the identity for the request, given a SessionStore.
* Returns the session record (with sliding renewal applied) or null.
*
* @param {http.IncomingMessage} req
* @param {SessionStore} sessions
* @returns {{ publicKey: string, expiresAt: number } | null}
*/
export function extractIdentity (req, sessions) {
const token = extractToken(req)
if (!token) return null
return sessions.get(token)
}
/**
* Require the caller to be a "writer" — has 'admin' or 'write' in their
* User.globalPermissions, OR connected via UDS (local user is always
* trusted). Lookup is session.publicKey → registry.getUserByIdentity →
* user.globalPermissions.
*
* @param {object} ctx - handler ctx ({ transport, identity, ... })
* @param {IdentityRegistry} registry - hyper-identity IdentityRegistry
* @throws {AuthError} if not allowed
*/
export async function requireWriter (ctx, registry) {
if (ctx && ctx.transport === 'uds') return // local UDS = trusted
const identity = ctx && ctx.identity
if (!identity || !identity.publicKey) {
throw new AuthError('authentication required')
}
// Intrinsic owner: envelope's root matches the daemon's own service
// identity root (set by router.js at dispatch time). The key that runs
// the daemon IS the owner — no registry record required.
if (identity.ownerMatched) return
if (!registry) {
throw new AuthError('identity registry not available', 500)
}
// #14: a revoked identity keeps its User record and its globalPermissions,
// so the perms check below passes for it. Deny before consulting perms.
if (await isIdentityRevoked(registry, identity.publicKey)) {
throw new AuthError('identity revoked', 403)
}
const user = await registry.getUserByIdentity(identity.publicKey).catch(() => null)
const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
if (perms.includes('admin') || perms.includes('write')) return
throw new AuthError('writer permission required', 403)
}
/**
* Require an AUTHENTICATED, non-revoked identity — but NOT write permission.
*
* For SELF-SCOPED reads: handlers returning only the caller's own data, which
* leak nothing cross-user. A newly TOFU-registered user gets `['read']` by
* default (handlers/users.js:457), and requireWriter above passes only 'admin'
* or 'write' — so a read-only user was locked out of its own profile surface.
* That is what stopped a fresh identity from listing its own devices and
* copying its link invite (regression surfaced 2026-08-09; the writer call in
* devices.js predates it, but #12's default-deny removed the anonymous path
* that used to mask it).
*
* Strictly WEAKER than requireWriter, strictly STRONGER than anonymous:
* - anonymous is still rejected — you need an identity to ask for "my"
* anything — so #12's default-deny surface is NOT reopened.
* - the #14 revocation gate is KEPT. A revoked identity retains its User
* record and globalPermissions, so any perms-only check still passes for
* it; revocation must be denied first. That bypass was real once.
*
* Additive: requireWriter and requireAdmin are untouched.
*/
export async function requireIdentity (ctx, registry) {
if (ctx && ctx.transport === 'uds') return // local UDS = trusted
const identity = ctx && ctx.identity
if (!identity || !identity.publicKey) {
throw new AuthError('authentication required')
}
// Intrinsic owner: same short-circuit as requireWriter — the key running the
// daemon IS the owner, no registry record required.
if (identity.ownerMatched) return
if (!registry) {
throw new AuthError('identity registry not available', 500)
}
if (await isIdentityRevoked(registry, identity.publicKey)) {
throw new AuthError('identity revoked', 403)
}
}
/**
* Non-throwing probe — does ctx belong to a user who would pass
* requireAdmin? Used by SSR page renderers to decide whether to SHOW
* an admin-only form in the first place (vs. rendering it and letting
* the handler reject on submit). UDS always passes. HTTP callers
* check the identity's globalPermissions.
*
* Returns false (not throw) when unauthenticated, not an owner, or
* the registry isn't available. Never throws.
*/
export async function isAdmin (ctx, registry) {
if (ctx && ctx.transport === 'uds') return true
const identity = ctx && ctx.identity
if (!identity || !identity.publicKey) return false
// Intrinsic owner — see requireWriter.
if (identity.ownerMatched) return true
if (!registry || typeof registry.getUserByIdentity !== 'function') return false
try {
// #14 — keep the probe in step with requireAdmin, so a revoked admin is
// not shown a form that the handler will then refuse.
if (await isIdentityRevoked(registry, identity.publicKey)) return false
const user = await registry.getUserByIdentity(identity.publicKey)
const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
return perms.includes('admin')
} catch {
return false
}
}
/**
* Require the caller to be an "owner" — strict 'admin' in globalPermissions.
* UDS bypasses (the local user runs the daemon).
*/
export async function requireAdmin (ctx, registry) {
if (ctx && ctx.transport === 'uds') return
const identity = ctx && ctx.identity
if (!identity || !identity.publicKey) {
throw new AuthError('authentication required')
}
// Intrinsic owner: see requireWriter. The daemon's service identity is
// always the owner by construction.
if (identity.ownerMatched) return
if (!registry) {
throw new AuthError('identity registry not available', 500)
}
// #14 — see requireWriter.
if (await isIdentityRevoked(registry, identity.publicKey)) {
throw new AuthError('identity revoked', 403)
}
const user = await registry.getUserByIdentity(identity.publicKey).catch(() => null)
const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
if (perms.includes('admin')) return
throw new AuthError('owner permission required', 403)
}