/**
* Git access control (issue #37, item 2) — pear-git-v3 specific.
*
* Kept separate from the shared auth pattern in check.js (which hypernote
* / pear-git / hyper-social-web also use): serving git is unique to this
* forge, so its ACL lives here.
*
* There is NO separate per-repo ACL store. Push rights reuse the SAME
* IdentityRegistry user-perm model as check.js:requireWriter — a caller
* resolves to a User and needs 'admin' or 'write'. Identity-linking means
* any of a user's linked keys (HTTP session pubkey, signed-envelope root,
* or swarm noise key) maps to the one user.
*
* Policy: only PUSH (git-receive-pack) is gated. Reads (git-upload-pack)
* stay open — anonymous clone / fetch, CI, mirror replication, and public
* browsing must keep working.
*/
import { resolveToken } from './access-token.js'
import { isIdentityRevoked } from './check.js'
/**
* Whether a caller may run git-receive-pack (push).
*
* - transport 'uds' → trusted (local operator runs the daemon)
* - ownerMatched → the daemon's own identity (intrinsic owner)
* - else resolve to an IdentityRegistry user and require admin|write.
*
* The caller is resolved by ONE of (the classic + p2p front doors, all
* mapping to the same User — see spec/flows/classic-git-auth.md):
* - identityId/pubkey (HTTP session or signed-envelope root)
* - token (HTTPS PAT, `pgv3_…`, via resolveToken)
* - sshFingerprint (SSH push, via the `sshkey:<fp>` reverse index)
* - noiseKey (punch:// transport, via `noisekey:<hex>`)
* All may be absent; absent + no match → denied (safe default).
*
* Token scope is an extra cap: a `read`-scoped token can NEVER push, even if
* its user has write — the token bounds what that credential may do.
*
* @param {object} registry - IdentityRegistry (hyper-identity)
* @param {object} caller
* @param {string} [caller.transport] - 'uds' | 'http' | 'swarm' | 'ssh'
* @param {boolean} [caller.ownerMatched]
* @param {string} [caller.identityId] - hex pubkey / identity id
* @param {string} [caller.token] - raw `pgv3_…` access token
* @param {string} [caller.sshFingerprint] - OpenSSH SHA256 fingerprint
* @param {string} [caller.noiseKey] - hex swarm noise key
* @returns {Promise<boolean>}
*/
export async function canPushGit (registry, caller = {}) {
if (caller.transport === 'uds') return true
if (caller.ownerMatched) return true
if (!registry) return false
// Access token: resolve + enforce the token's scope cap before perms.
// A read-scoped token is denied push regardless of the user's perms.
if (caller.token) {
const resolved = await resolveToken(registry, caller.token)
if (!resolved || resolved.scope !== 'write') return false
return hasWrite(resolved.user)
}
const user = await resolveUser(registry, caller)
return hasWrite(user)
}
function hasWrite (user) {
const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
return perms.includes('admin') || perms.includes('write')
}
/**
* Resolve the caller's credential to an identityId, without judging it.
* Each front door yields the SAME kind of value, so revocation can be applied
* once, downstream — see resolveUser.
*/
async function resolveIdentityId (registry, caller) {
if (caller.identityId) return caller.identityId
if (caller.sshFingerprint && typeof registry.getIdentityBySSHKey === 'function') {
const ident = await registry.getIdentityBySSHKey(caller.sshFingerprint).catch(() => null)
if (ident && ident.identityId) return ident.identityId
}
if (caller.noiseKey && typeof registry.getIdentityByNoiseKey === 'function') {
const ident = await registry.getIdentityByNoiseKey(caller.noiseKey).catch(() => null)
if (ident && ident.identityId) return ident.identityId
}
return null
}
/**
* Resolve a caller to a User, refusing revoked identities. (org #14)
*
* Revocation used to be checked in the sshFingerprint branch ONLY. The
* identityId branch (HTTP sessions) and the noiseKey branch (punch://) both
* called getUserByIdentity directly, which walks the user-identity link and
* does NOT consult the revocation index — so a revoked identity kept pushing
* over HTTP and punch:// for as long as its User still carried write/admin.
*
* Restructured so the three front doors only RESOLVE, and the revocation gate
* sits on the single path they all funnel through. A revoked credential is a
* definite denial, not a reason to fall through to the next branch: trying the
* next door on a revoked one is how this class of bypass gets reintroduced.
*/
async function resolveUser (registry, caller) {
if (typeof registry.getUserByIdentity !== 'function') return null
const identityId = await resolveIdentityId(registry, caller)
if (!identityId) return null
if (await isIdentityRevoked(registry, identityId)) return null
return registry.getUserByIdentity(identityId).catch(() => null)
}
/**
* Resolve a caller to the User behind it, for READ authorization.
*
* Identity resolution ONLY — it does not decide access. Per-repo read policy
* lives in hyper-orgs (`checkAccess`), reached via repo-access's isReader. This
* answers "which user is asking?" so every read surface derives the caller the
* same way instead of each inventing its own extraction.
*
* Shares resolveUser's funnel deliberately, so the revocation gate above
* applies to reads too. Resolving identities separately on the read path would
* reintroduce exactly the bypass documented on resolveUser — revocation checked
* in one branch and missed in the others.
*
* Token scope differs from push: canPushGit requires scope === 'write', but a
* READ-scoped token is a valid way to read. Both scopes resolve here; the scope
* is REPORTED, not judged, so a caller needing the distinction can apply it.
*
* @param {object} registry - IdentityRegistry
* @param {object} caller - same shape as canPushGit's caller
* @returns {Promise<{ user: object, scope: string|null } | null>}
*/
export async function resolveGitReader (registry, caller = {}) {
if (!registry) return null
if (caller.token) {
const resolved = await resolveToken(registry, caller.token).catch(() => null)
if (!resolved || !resolved.user) return null
return { user: resolved.user, scope: resolved.scope || null }
}
const user = await resolveUser(registry, caller)
return user ? { user, scope: null } : null
}