import { mountPrefix, publicPathProblem, rootPathProblem, type DiscoveredPlugin } from './manifest'; // Which plugin paths are served ABOVE the account gate. // // The declaration side of this — `unauthenticatedPaths` in a manifest, `publicPathProblem` validating it — // existed from the day the field was designed. Nothing read it: no plugin had needed one yet (kiosk // declares none), so the field validated, documented itself, and did nothing. Vault is the first real // user, and this is the half that was missing. // // ── What a plugin is trusted with, and what it is not ── // // NOT trusted: which paths. A declared path is resolved against the plugin's OWN mount and re-checked // here at every mount, so a plugin cannot name `/auth`, `/users`, or another plugin's routes however it // edits its manifest. `publicPathProblem` already refuses `..`, `//`, mid-path wildcards and the whole // plugin; this adds the containment check that makes the promise structural rather than syntactic. // // Trusted: what those paths DO. Authorising them is the plugin's job — it asked for them to be public, so // whatever credential they check is the plugin's to define. The platform's job is to be exact about which // ones, and to say so out loud at boot. /** A resolved entry: the absolute path pattern, and who asked for it. */ export type PublicPath = { pattern: string; appName: string }; let publicPaths: PublicPath[] = []; /** * Resolve a plugin's declared paths against its own mount. * * Returns the absolute patterns and the reasons any were refused. Refusal is per-path rather than * per-plugin: one bad entry must not quietly take the good ones with it, and the caller logs what it lost. */ export function resolvePluginPublicPaths(plugin: DiscoveredPlugin): { paths: string[]; refused: string[] } { const declared = plugin.manifest.unauthenticatedPaths ?? []; const base = `/api${mountPrefix(plugin)}`; const paths: string[] = []; const refused: string[] = []; for (const path of declared) { const problem = publicPathProblem(path); if (problem) { refused.push(`${plugin.appName}: ${problem}`); continue; } const absolute = `${base}${path}`; // Containment, re-checked rather than assumed. `publicPathProblem` already makes escaping hard; this // makes it impossible, and it is cheap. A path that does not sit under the plugin's own mount is the // one failure that would matter, so it is checked where the mount is actually known. if (absolute !== base && !absolute.startsWith(`${base}/`)) { refused.push(`${plugin.appName}: "${path}" resolves outside the plugin's own mount`); continue; } paths.push(absolute); } return { paths, refused }; } /** * Replace the whole set. Called from `refreshPluginMounts` alongside `setPluginPermissions`, and for the * same reason: install, uninstall, enable and disable are all one recomputation rather than a diff. */ export function setPluginPublicPaths(entries: PublicPath[]): void { publicPaths = entries; } /** Everything currently public, for logging and for the plugins API to show. */ export function listPluginPublicPaths(): PublicPath[] { return publicPaths; } /** * Is this request path served above the account gate? * * A trailing `/*` matches the prefix and everything under it; anything else is exact. Deliberately not a * regex: the patterns come from a manifest, and the set of shapes `publicPathProblem` allows is small * enough to match literally — which is one fewer place for a pattern to mean more than it looks like. */ export function isPluginPublicPath(path: string): boolean { for (const { pattern } of publicPaths) { if (pattern.endsWith('/*')) { const prefix = pattern.slice(0, -2); if (path === prefix || path.startsWith(`${prefix}/`)) return true; } else if (path === pattern) { return true; } } return false; } // ── Top-level claims: root paths, and the header that hands `/api/*` to a plugin ── // // Everything above is about a plugin's own mount. This is about the platform's namespace, which a plugin // may reach into only for a protocol it does not control and cannot move — see `unauthenticatedRootPaths` // in manifest.ts for the full argument. Kept in the same file because it is the same question ("what does // a plugin serve without a session") and splitting it would let the two drift. export type RootClaim = { segment: string; appName: string }; let rootClaims: RootClaim[] = []; let protocolHeaders: { header: string; appName: string }[] = []; /** * Resolve a plugin's root claims, refusing collisions against a set already taken. * * `taken` is threaded in rather than read from module state so the caller can resolve every plugin * against one accumulating set — first come, first served, deterministic in install order, and the loser * is told by name rather than silently overwritten. */ export function resolvePluginRootPaths( plugin: DiscoveredPlugin, taken: Set, ): { segments: string[]; refused: string[] } { const segments: string[] = []; const refused: string[] = []; for (const path of plugin.manifest.unauthenticatedRootPaths ?? []) { const problem = rootPathProblem(path); if (problem) { refused.push(`${plugin.appName}: ${problem}`); continue; } const segment = path.slice(1); if (taken.has(segment)) { refused.push(`${plugin.appName}: "/${segment}" is already claimed by another plugin`); continue; } taken.add(segment); segments.push(segment); } return { segments, refused }; } export function setPluginRootClaims(claims: RootClaim[]): void { rootClaims = claims; } export function listPluginRootClaims(): RootClaim[] { return rootClaims; } /** Does this top-level path belong to a plugin? Segment match, so `/identityx` never counts. */ export function pluginForRootPath(path: string): string | null { for (const { segment, appName } of rootClaims) { if (path === `/${segment}` || path.startsWith(`/${segment}/`)) return appName; } return null; } export function setPluginProtocolHeaders(entries: { header: string; appName: string }[]): void { protocolHeaders = entries; } export function listPluginProtocolHeaders(): { header: string; appName: string }[] { return protocolHeaders; } /** * Which plugin, if any, claims this `/api/*` request by header. * * ROUTING, not authentication: a forged header reaches the plugin's own public router, which demands * whatever credential it demands. The worst outcome for a caller who sets it is losing access to * Officer's API for that request. */ export function pluginForProtocolHeaders(headers: Headers): string | null { for (const { header, appName } of protocolHeaders) { if (headers.has(header)) return appName; } return null; }