import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { hashToken } from './auth'; /** * Persisted store of minted, package-scoped publish tokens (build-bus Phase 3, * ISS-0140). The `registry_publish` capability mints one token per registered * repo, scoped to the single package that repo publishes — so a compromised * runner can ship only its own module, never another. * * Only the token HASH is persisted (never cleartext) — the raw token is returned * exactly once at mint time, for the caller to set as the repo's Forgejo Actions * secret. At startup the server loads this store into its {@link TokenAuth} via * `addHashed`; mint/revoke update both the in-memory auth and this file, so a * minted token works without a server restart. */ export interface ScopedTokenEntry { /** SHA-256 of the raw token. */ hash: string; /** Package this token may publish. */ scope: string; /** Repo path on the source forge this token was minted for (the reconcile key). */ repo: string; /** ISO timestamp of mint. */ mintedAt: string; } export interface MintResult { /** The raw token — returned ONCE; never persisted in cleartext. */ token: string; entry: ScopedTokenEntry; /** Hashes of tokens this mint superseded for the same repo (revoke them in auth). */ revokedHashes: string[]; } /** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */ export interface ScopedTokenPersistence { load(): ScopedTokenEntry[]; save(entries: ScopedTokenEntry[]): void; } /** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */ export function fileScopedTokenPersistence(filePath: string): ScopedTokenPersistence { return { load() { if (!existsSync(filePath)) return []; try { const parsed = JSON.parse(readFileSync(filePath, 'utf-8')); return Array.isArray(parsed) ? (parsed as ScopedTokenEntry[]) : []; } catch { return []; } }, save(entries) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(entries, null, 2)); }, }; } function defaultGenToken(): string { return `cpt_${randomBytes(32).toString('base64url')}`; } export class ScopedTokenStore { private entries: ScopedTokenEntry[]; constructor( private readonly persistence: ScopedTokenPersistence, private readonly genToken: () => string = defaultGenToken, private readonly now: () => string = () => new Date().toISOString(), ) { this.entries = persistence.load(); } list(): ScopedTokenEntry[] { return [...this.entries]; } /** * Mint a fresh scoped token for `repo`, reconciling: any existing token for the * same repo is superseded (its hash returned in `revokedHashes`). Idempotent at * the repo level — re-running rotates the token cleanly. */ mint(repo: string, scope: string): MintResult { const revokedHashes = this.entries.filter((e) => e.repo === repo).map((e) => e.hash); const token = this.genToken(); const entry: ScopedTokenEntry = { hash: hashToken(token), scope, repo, mintedAt: this.now(), }; this.entries = [...this.entries.filter((e) => e.repo !== repo), entry]; this.persistence.save(this.entries); return { token, entry, revokedHashes }; } /** Revoke all tokens for `repo`. Returns the removed hashes. */ revoke(repo: string): string[] { const removed = this.entries.filter((e) => e.repo === repo).map((e) => e.hash); if (removed.length > 0) { this.entries = this.entries.filter((e) => e.repo !== repo); this.persistence.save(this.entries); } return removed; } }