import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; /** * Persisted module-owner table (SECURE_MODULE_PUBLISH.md ยง5[D-C], ce-1ch). * * The introspection verify-bridge (ce-s7e) proves *who* a publish token belongs * to (a verified `sub` + group claims). This table gates *which modules* that * identity may publish: a module name is **first-publish-claims** โ€” the first * verified publisher of an unclaimed name records ownership; thereafter only the * owner (or an admin) may publish it. This defends the confused-deputy case: * Author-A holding a valid idp token cannot publish Author-B's module. * * Ownership is low-friction / npm-style: no pre-registration, admin can reassign. * Mirrors {@link ScopedTokenStore} (JSON file + I/O seam for unit testing). */ export interface ModuleOwnerEntry { /** Module name this ownership record governs. */ moduleName: string; /** Verified idp subject that owns the name (from introspection `sub`). */ ownerSub: string; /** ISO timestamp the name was first claimed. */ claimedAt: string; /** Group claim that granted the claim (admin or publisher group at claim time). */ sourceGroup: string; } /** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */ export interface ModuleOwnerPersistence { load(): ModuleOwnerEntry[]; save(entries: ModuleOwnerEntry[]): void; } /** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */ export function fileModuleOwnerPersistence(filePath: string): ModuleOwnerPersistence { return { load() { if (!existsSync(filePath)) return []; try { const parsed = JSON.parse(readFileSync(filePath, 'utf-8')); return Array.isArray(parsed) ? (parsed as ModuleOwnerEntry[]) : []; } catch { return []; } }, save(entries) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(entries, null, 2)); }, }; } export class ModuleOwnerStore { private entries: ModuleOwnerEntry[]; constructor( private readonly persistence: ModuleOwnerPersistence, private readonly now: () => string = () => new Date().toISOString(), ) { this.entries = persistence.load(); } list(): ModuleOwnerEntry[] { return [...this.entries]; } /** The ownership record for `moduleName`, or undefined when unclaimed. */ get(moduleName: string): ModuleOwnerEntry | undefined { return this.entries.find((e) => e.moduleName === moduleName); } /** * Record ownership of an **unclaimed** name for `ownerSub`. If the name is * already claimed this is a no-op and returns the *existing* entry โ€” claiming * never steals a name (use {@link reassign} for admin overwrite). Returns the * effective owner entry either way. */ claim(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry { const existing = this.get(moduleName); if (existing) return existing; const entry: ModuleOwnerEntry = { moduleName, ownerSub, claimedAt: this.now(), sourceGroup, }; this.entries = [...this.entries, entry]; this.persistence.save(this.entries); return entry; } /** * Overwrite (or create) the ownership record for `moduleName` โ€” admin * reassignment. Unlike {@link claim} this steals an already-owned name. */ reassign(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry { const entry: ModuleOwnerEntry = { moduleName, ownerSub, claimedAt: this.now(), sourceGroup, }; this.entries = [...this.entries.filter((e) => e.moduleName !== moduleName), entry]; this.persistence.save(this.entries); return entry; } }