import { createHash } from 'node:crypto'; /** * Package-scoped token auth (build-bus Phase 3, ISS-0140). * * Each token carries a SCOPE — either a single package name (the token may * publish only that module) or the admin scope `*` (publish anything + mint * scoped tokens). Tokens are stored SHA-256 hashed; cleartext never lives in * the auth set once the server starts. * * PUBLISH_TOKENS env (newline-separated), per line: * → admin scope `*` (publish any package, mint tokens) * → scoped to that one package * * A bare `` line is the admin/bootstrap token — this preserves the * pre-scoping behavior (any configured token publishes anything) AND is the * privilege that the `registry_publish` capability uses to mint per-repo * scoped tokens. Minted scoped tokens are added at runtime via {@link addHashed} * (loaded from the persisted store) and removed via {@link removeHashed}. */ /** Admin scope: publish any package + mint scoped tokens. */ export const ADMIN_SCOPE = '*'; export interface TokenSpec { token: string; /** Package name, or {@link ADMIN_SCOPE}. */ scope: string; } /** SHA-256 of a token (trimmed). The persisted scoped-token store hashes too. */ export function hashToken(token: string): string { return createHash('sha256').update(token.trim()).digest('hex'); } /** Strip an optional `Bearer ` prefix and surrounding whitespace from a header. */ export function bearerToken(header: string): string { return header.replace(/^Bearer\s+/i, '').trim(); } export class TokenAuth { /** hash → scope. */ private readonly scopeByHash = new Map(); constructor(specs: TokenSpec[]) { for (const s of specs) this.addRaw(s.token, s.scope); } /** * Parse the `PUBLISH_TOKENS` env format into the scoped auth set. Each line is * `` (admin scope) or ` ` (scoped to one package). */ static fromEnv(): TokenAuth { const raw = process.env.PUBLISH_TOKENS ?? ''; const specs: TokenSpec[] = []; for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; const [token, scope] = trimmed.split(/\s+/, 2); specs.push({ token, scope: scope || ADMIN_SCOPE }); } return new TokenAuth(specs); } /** Add a cleartext token (hashes it). Empty tokens are ignored. */ addRaw(rawToken: string, scope: string): void { const t = rawToken.trim(); if (!t) return; this.scopeByHash.set(hashToken(t), scope.trim() || ADMIN_SCOPE); } /** Add an already-hashed token (used when loading the persisted minted-token store). */ addHashed(hash: string, scope: string): void { this.scopeByHash.set(hash, scope); } /** Remove a token by its hash (token revocation). */ removeHashed(hash: string): void { this.scopeByHash.delete(hash); } /** If no tokens are configured, the server treats every write as unauthorized. */ hasTokens(): boolean { return this.scopeByHash.size > 0; } /** The scope of a token (from an Authorization header value), or null if unknown. */ scopeOf(header: string): string | null { const t = bearerToken(header); if (!t) return null; return this.scopeByHash.get(hashToken(t)) ?? null; } /** True when the token may publish/yank the given package (admin or exact scope). */ authorize(header: string, pkg: string): boolean { const scope = this.scopeOf(header); if (scope === null) return false; return scope === ADMIN_SCOPE || scope === pkg; } /** True when the token is an admin token (scope `*`) — required to mint scoped tokens. */ isAdmin(header: string): boolean { return this.scopeOf(header) === ADMIN_SCOPE; } }