From 705d2b32359121677fa37db51ca4085cf66c6d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 00:48:08 +0000 Subject: [PATCH] capabilities: a registry, and a server that refuses to boot without one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permissions are capabilities, not routes. a capability is a feature — the unit the owner grants, the dock filters on and the acl enforces — declaring the api prefixes, websocket providers and screens it expands to. four kinds. core is every account and is not grantable because it is not deniable. app is the grantable surface. admin is the platform administering itself. execution is never grantable at any level: terminal, chat, tasks, files, desktop and browser all run as the owner's os user in the owner's home, so granting one is co-ownership of the machine rather than a feature. the part that matters is assertCapabilityTotality. the websocket hole fixed in 2873948 was not a wrong rule — it was a door added without telling the rule, because bun's route table matches /api/terminal/ws before the /api/* catch-all that reaches hono's middleware. so the server now refuses to start unless every mounted prefix and every user-facing socket maps to exactly one capability. hono.ts mounts from a table and exports it, so the check reads the real surface instead of a copy that can drift from it. verified: passes against the live surface, and refuses all four ways — an ungated router, an ungated socket, a claim on a deleted router, a claim on a deleted socket. Co-Authored-By: Claude Opus 5 (1M context) --- src/server.tsx | 13 +- src/servers/capabilities/registry.ts | 386 +++++++++++++++++++++++++++ src/servers/capabilities/totality.ts | 135 ++++++++++ src/servers/hono.ts | 106 +++++--- 4 files changed, 597 insertions(+), 43 deletions(-) create mode 100644 src/servers/capabilities/registry.ts create mode 100644 src/servers/capabilities/totality.ts diff --git a/src/server.tsx b/src/server.tsx index eb1f3dd6..13dabf7c 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,7 +1,8 @@ import './servers/bootstrap'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; -import { honoServer } from './servers/hono'; +import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; +import { assertCapabilityTotality } from './servers/capabilities/totality'; import { verify } from './servers/jwt'; import { isSuperAdmin } from './servers/super-admin'; import { isWsProviderAllowedForNonOwner } from './servers/_middlewares'; @@ -139,6 +140,16 @@ const handlers: Record = { sidecar: sidecarWebsocket, }; +// Both doors into the platform, checked against the capability registry before either opens. +// +// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated +// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half +// from the map directly above, so neither list can be a stale copy of the thing it describes. +assertCapabilityTotality({ + apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES], + wsProviders: Object.keys(handlers), +}); + async function upgradeWs( req: Request, server: any, diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts new file mode 100644 index 00000000..d984370e --- /dev/null +++ b/src/servers/capabilities/registry.ts @@ -0,0 +1,386 @@ +// The capability registry: the single enumeration of what this platform can do, and the unit the owner +// grants to a role. +// +// ── Why capabilities and not routes ── +// +// The obvious model is "list the routes a role may call". It does not survive contact with this codebase. +// A sweep of all 100 mutating platform routes on 2026-08-06 found reads permanently stuck on POST for two +// reasons that are not going away: bodies GET cannot carry (`/stt` multipart audio, `/tts`, `/ocr`, +// `/transcribe`), and credentials that must not sit in a query string where access logs, shell history and +// Referer headers capture them (`/tts/voices` apiKey, the four `/test` endpoints, `/local-providers/probe`). +// Five genuinely free conversions were done in e54d71d; the rest are staying. So the METHOD alone cannot +// carry the read/write distinction — hence `readOnlyWrites` below, declared per capability. +// +// The deeper reason is that a route list is not what the owner is deciding. The owner decides "this person +// gets Gitea". A capability is that decision; the prefixes, sockets and screens it expands to are an +// implementation detail that belongs next to the decision rather than in the granting UI. +// +// ── The four kinds, and why `execution` can never be granted ── +// +// core every authenticated account, always. Not grantable because not deniable — signing in +// without them means a broken app, not a restricted one. +// app the grantable surface. This is what the owner hands out per role. +// execution NEVER grantable. Owner only, structurally. +// admin owner only: the platform administering itself, and the owner's own money and network. +// +// `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home +// directory: the terminal is a real shell, chat spawns `claude` with --dangerously-skip-permissions, tasks +// run arbitrary scripts, the file browser and code editor read and write the owner's disk, the desktop is +// the owner's physical screen. Granting any of them is not a feature flag, it is co-ownership of the +// machine. There is no level of "read" that makes a shell safe, which is why these have no level at all. +// Revisit only if per-user home confinement is ever solved — and that is a project, not a checkbox. +// +// ── Read by default ── +// +// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in +// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing +// else. Music is the worked example: `/favorites`, `/now-playing` and `/playlists` are already per-caller +// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write +// here" is a property of the endpoint, not a policy knob someone has to remember to set. + +export type CapabilityKind = 'core' | 'app' | 'execution' | 'admin'; + +export type Capability = { + /** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */ + key: string; + label: string; + description: string; + kind: CapabilityKind; + /** + * Path prefixes under `/api`, written exactly as they are mounted on protectedRouter in hono.ts — + * leading slash, no `/api`. The totality check pairs these against the real mount table, so a prefix + * here that nothing mounts is as much an error as a mount nothing claims. + */ + api: string[]; + /** WebSocket providers, named as in server.tsx's `handlers` map. */ + ws?: string[]; + /** Frontend route prefixes. Filters the dock and the app registry; never a security boundary. */ + routes?: string[]; + /** + * Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only + * the caller's own data. Matched as a prefix after the capability's own: `/favorites` on the `music` + * capability permits `POST /api/music/favorites/123`. + */ + personal?: string[]; + /** + * Reads that must stay POST — see the note at the top. A read grant permits these paths at any method. + * Written relative to the capability's `api` prefix, like `personal`. + */ + readOnlyWrites?: string[]; +}; + +export const CAPABILITIES: Capability[] = [ + // ── core ──────────────────────────────────────────────────────────────────────────────────────── + { + key: 'account', + label: 'Account', + description: 'Sign in, your own profile, password and preferences', + kind: 'core', + api: ['/user', '/dock'], + routes: ['/settings/profile'], + }, + { + key: 'bug-report', + label: 'Report a problem', + description: 'Send the server owner a bug report', + kind: 'core', + api: ['/bug-report'], + }, + + // ── app: the grantable surface ────────────────────────────────────────────────────────────────── + { + key: 'gitea', + label: 'Gitea', + description: 'Repositories, issues, pull requests and notifications from your own Gitea account', + kind: 'app', + api: ['/gitea'], + routes: ['/gitea'], + // Every Gitea call is already scoped to the caller's own personal access token — the sidecar resolves + // the token from the caller's row and the instance from the owner's, so a member holding `read` still + // acts only as themselves upstream. Gitea's own permissions are the second gate and the real one: + // a token cannot reach a repository its account cannot reach, whatever this platform thinks. + // + // Which is why the whole capability is `personal` rather than a list of sub-paths. Nothing under + // /api/gitea can affect another Officer user, so withholding write here would only stop someone + // commenting on their own issues — security theatre with a real cost and no benefit. + personal: ['/'], + }, + { + key: 'music', + label: 'Music', + description: 'The music library, playback, and your own favourites and playlists', + kind: 'app', + api: ['/music'], + ws: ['cliamp', 'cliamp-audio'], + routes: ['/music'], + // Already per-caller in the sidecar contract (X-Officer-User), which is what makes them safe to write + // at read level. The library itself — scanning, tags, file moves — is not, and is not listed. + personal: ['/favorites', '/now-playing', '/playlists', '/queue'], + }, + { + key: 'photos', + label: 'Photos', + description: 'Browse the photo library', + kind: 'app', + api: ['/photos'], + routes: ['/photos'], + }, + { + key: 'jellyfin', + label: 'Video', + description: 'Browse and play the Jellyfin library', + kind: 'app', + api: ['/jellyfin'], + routes: ['/jellyfin'], + }, + { + key: 'memos', + label: 'Memos', + description: 'Notes', + kind: 'app', + api: ['/memos'], + routes: ['/memos'], + }, + { + key: 'calendar', + label: 'Calendar and contacts', + description: 'Calendars, contacts, and the app passwords that sync them to a phone', + kind: 'app', + api: ['/caldav', '/dav'], + routes: ['/calendar', '/contacts'], + // App passwords are minted for and revoked by their own owner; `/dav` holds nothing shared. + personal: ['/'], + }, + { + key: 'email', + label: 'Email', + description: 'Mail accounts and messages', + kind: 'app', + api: ['/email'], + routes: ['/email'], + }, + { + key: 'notify', + label: 'Notifications', + description: 'Push notifications to your devices', + kind: 'app', + api: ['/notify'], + // Device registration is the caller's own — a phone subscribing to its own push channel. + personal: ['/devices', '/subscriptions'], + }, + { + key: 'transmission', + label: 'Transmission', + description: 'Torrent downloads', + kind: 'app', + api: ['/transmission'], + routes: ['/transmission'], + }, + { + key: 'soulseek', + label: 'Soulseek', + description: 'Search and download from the Soulseek network', + kind: 'app', + api: ['/slskd'], + routes: ['/soulseek'], + }, + { + key: 'invoices', + label: 'Invoices', + description: 'InvoiceShelf books', + kind: 'app', + api: ['/invoiceshelf'], + routes: ['/invoices'], + }, + { + key: 'vpn', + label: 'VPN', + description: 'Enrol your own devices on the tailnet', + kind: 'app', + api: ['/vpn'], + // Minting a pre-auth key for your own device is the entire point of the capability, and the key is + // bound to the caller. Administering the tailnet is `headscale`, which is admin-only. + personal: ['/'], + }, + { + key: 'dashboards', + label: 'Dashboards', + description: 'Your own dashboards and saved layouts', + kind: 'app', + api: ['/dashboards'], + routes: ['/dashboards'], + personal: ['/'], + }, + { + key: 'plans', + label: 'Plans', + description: 'Plan documents', + kind: 'app', + api: ['/plans'], + routes: ['/plans'], + }, + + // ── execution: never grantable ────────────────────────────────────────────────────────────────── + { + key: 'terminal', + label: 'Terminal', + description: 'A real shell as the server owner', + kind: 'execution', + api: ['/terminal'], + ws: ['terminal'], + routes: ['/terminal'], + }, + { + key: 'chat', + label: 'Chat', + description: 'The agent, running unsandboxed as the server owner', + kind: 'execution', + api: ['/chat'], + ws: ['chat'], + routes: ['/chat'], + }, + { + key: 'files', + label: 'Files', + description: "The server owner's filesystem, and the code editor over it", + kind: 'execution', + api: ['/file-browser', '/upload'], + routes: ['/files', '/code-editor'], + }, + { + key: 'tasks', + label: 'Tasks and jobs', + description: 'Running capabilities, pipelines and background jobs', + kind: 'execution', + api: ['/tasks', '/jobs', '/pipeline-jobs', '/task-logs', '/queue'], + ws: ['task-runner', 'pipeline'], + routes: ['/jobs', '/task-logs'], + }, + { + key: 'items', + label: 'Capability authoring', + description: 'Skills, tools, agents and processes on disk', + kind: 'execution', + api: ['/skills', '/tools', '/agents', '/processes', '/rescan'], + }, + { + key: 'desktop', + label: 'Desktop', + description: "The server owner's physical screen", + kind: 'execution', + api: ['/desktop'], + ws: ['desktop'], + routes: ['/desktop'], + }, + { + key: 'browser', + label: 'Browser', + description: 'Drives a real browser on the host', + kind: 'execution', + api: ['/browser', '/scrape'], + routes: ['/browser'], + }, + + // ── admin: the platform administering itself ──────────────────────────────────────────────────── + { + key: 'server-admin', + label: 'Server settings', + description: 'Server configuration, integrations and the activity log', + kind: 'admin', + api: ['/server-settings', '/integrations', '/activity', '/system-monitor'], + routes: ['/settings/server', '/settings/system', '/activity', '/system-monitor'], + }, + { + key: 'user-admin', + label: 'User management', + description: 'Accounts, roles and what each role may reach', + kind: 'admin', + api: ['/users'], + routes: ['/settings/user-management'], + }, + { + key: 'headscale', + label: 'Headscale', + description: 'The tailnet: machines, routes and ACLs', + kind: 'admin', + api: ['/headscale'], + routes: ['/headscale'], + }, + { + key: 'wallet', + label: 'Wallet', + description: "The server owner's bitcoin", + kind: 'admin', + api: ['/wallet'], + routes: ['/wallet'], + }, +]; + +// ── Derived lookups ─────────────────────────────────────────────────────────────────────────────── + +export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); + +/** The keys an owner may actually hand to a role. `core` is automatic, the other two are owner-only. */ +export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app'); + +/** Available to every signed-in account without a grant. */ +export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core'); + +export type CapabilityLevel = 'read' | 'write'; + +const isPrefixOf = (prefix: string, path: string): boolean => + prefix === '/' || path === prefix || path.startsWith(`${prefix}/`); + +/** + * Which capability owns this path? `path` is the full request path (`/api/gitea/...`). + * + * Longest prefix wins, so a capability may claim `/dav` while another claims `/dav/something` without the + * order of the array mattering. Returns null for a path no capability claims — which the totality check + * below is there to make impossible for anything mounted on protectedRouter. + */ +export function capabilityForApiPath(path: string): Capability | null { + const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; + let best: Capability | null = null; + let bestLength = -1; + for (const capability of CAPABILITIES) { + for (const prefix of capability.api) { + if (isPrefixOf(prefix, rest) && prefix.length > bestLength) { + best = capability; + bestLength = prefix.length; + } + } + } + return best; +} + +export function capabilityForWsProvider(provider: string): Capability | null { + return CAPABILITIES.find((c) => c.ws?.includes(provider)) ?? null; +} + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * May a caller holding `level` on `capability` make this request? + * + * `write` is unconditional within the capability. `read` permits safe methods, anything the capability + * declares as a `readOnlyWrites` read-in-POST-clothing, and mutations confined to `personal` sub-paths. + */ +export function isRequestAllowedAtLevel( + capability: Capability, + level: CapabilityLevel, + method: string, + path: string, +): boolean { + if (level === 'write') return true; + if (SAFE_METHODS.has(method.toUpperCase())) return true; + + const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; + // Strip whichever of the capability's own prefixes matched, so `personal` entries are written relative + // to the capability rather than repeated per prefix. + const withinCapability = capability.api + .filter((prefix) => isPrefixOf(prefix, rest)) + .map((prefix) => rest.slice(prefix.length) || '/'); + + const allowed = [...(capability.personal ?? []), ...(capability.readOnlyWrites ?? [])]; + return withinCapability.some((sub) => allowed.some((entry) => isPrefixOf(entry, sub))); +} diff --git a/src/servers/capabilities/totality.ts b/src/servers/capabilities/totality.ts new file mode 100644 index 00000000..8ca37f9b --- /dev/null +++ b/src/servers/capabilities/totality.ts @@ -0,0 +1,135 @@ +import { CAPABILITIES, capabilityForApiPath, capabilityForWsProvider } from './registry'; + +// The part that actually matters. +// +// On 2026-08-06 a Member token that was 403'd on `GET /api/tasks` opened `/api/tasks/pipeline/ws` and got +// a 101 in the same minute — a shell, the agent with --dangerously-skip-permissions, arbitrary script +// execution and the owner's screen, all reachable. The cause was not a bad rule. It was that WebSocket +// upgrades never reach Hono's middleware at all: Bun's route table in server.tsx matches +// `/api/terminal/ws` before the `/api/*` catch-all, so the authorisation gate was simply not on that code +// path. Nobody had written a rule that was wrong; a door had been added and the rule had not been told. +// +// It was fixed in 2873948 by adding the check to the socket door too. That fix is a patch, and patches of +// that shape do not survive the next door. What survives is refusing to boot: every mounted API prefix and +// every user-facing WebSocket provider must map to exactly one capability, or the server does not start. +// Add a router and forget the registry and you find out during `pm2 restart`, not during an incident. +// +// The exemptions below are the honest cost of that: a short list of surfaces that genuinely are not +// user-capability-gated, each of which has to say why. A list that grows silently is the failure mode, so +// keep it short and keep the reasons real. + +/** + * Mounted under `honoServer` directly rather than `protectedRouter`, and gated by something other than a + * platform capability. Each entry is a claim that has to stay true. + */ +const EXEMPT_API_PREFIXES: Record = { + // Unauthenticated by necessity — this is where a caller goes to BECOME authenticated. + '/auth': 'signin, bootstrap and token refresh; runs before any account exists to check', + // Public marketing surfaces, served to anonymous visitors. + '/landing-page-data': 'public landing page content, no account involved', + '/waitlist': 'public signup form, no account involved', + // The Bitwarden clients carry their own bearer token, not a platform JWT, so userMiddleware would 401 + // them and a capability lookup has no account to resolve. Gated by origin scoping and Vaultwarden itself. + '/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer', + // Registration socket for sidecars. Process-to-process on loopback; there is no user on this path. + '/sidecar': 'sidecar registration, loopback process-to-process', +}; + +/** + * WebSocket providers that do not go through `upgradeWs` and therefore never carry a platform account. + */ +const EXEMPT_WS_PROVIDERS: Record = { + sidecar: 'sidecar registration, loopback process-to-process, no user', + vault: 'Vaultwarden notifications hub; upgraded by upgradeVaultWs with a Bitwarden token', +}; + +export type CapabilitySurface = { + /** Every prefix mounted on protectedRouter, as written in hono.ts. */ + apiPrefixes: string[]; + /** Every key of the `handlers` map in server.tsx. */ + wsProviders: string[]; +}; + +/** + * Refuses to return if the registry and the real surface disagree. Called from the boot path. + * + * Four ways to fail, and all four are real bugs rather than pedantry: + * - a mounted prefix no capability claims → reachable by a rule nobody wrote + * - a prefix two capabilities claim → which grant applies is undefined + * - a declared prefix nothing mounts → the registry is describing a router that no longer exists + * - a socket provider no capability claims → exactly the 2026-08-06 hole, structurally + */ +export function assertCapabilityTotality(surface: CapabilitySurface): void { + const problems: string[] = []; + + // 1. Every mount is claimed, and claimed once. + for (const prefix of surface.apiPrefixes) { + if (prefix in EXEMPT_API_PREFIXES) continue; + const claimants = CAPABILITIES.filter((c) => c.api.includes(prefix)); + if (claimants.length === 0) { + problems.push(`/api${prefix} is mounted but no capability claims it — add it to the registry`); + } else if (claimants.length > 1) { + problems.push(`/api${prefix} is claimed by ${claimants.map((c) => c.key).join(', ')} — it must be exactly one`); + } + } + + // 2. Every claim corresponds to something real. Catches a router that was deleted or renamed while the + // registry kept describing it — which would leave a grant that silently means nothing. + const mounted = new Set(surface.apiPrefixes); + for (const capability of CAPABILITIES) { + for (const prefix of capability.api) { + if (!mounted.has(prefix)) { + problems.push(`capability '${capability.key}' claims /api${prefix}, which nothing mounts`); + } + } + } + + // 3. Every socket door is claimed. This is the one the incident was about. + for (const provider of surface.wsProviders) { + if (provider in EXEMPT_WS_PROVIDERS) continue; + if (!capabilityForWsProvider(provider)) { + problems.push(`websocket provider '${provider}' is served but no capability claims it`); + } + } + + // 4. And no capability claims a socket that does not exist. + const providers = new Set(surface.wsProviders); + for (const capability of CAPABILITIES) { + for (const provider of capability.ws ?? []) { + if (!providers.has(provider)) { + problems.push(`capability '${capability.key}' claims websocket '${provider}', which is not served`); + } + } + } + + // 5. Keys are unique — a duplicate would make grants ambiguous in the database. + const seen = new Set(); + for (const capability of CAPABILITIES) { + if (seen.has(capability.key)) problems.push(`duplicate capability key '${capability.key}'`); + seen.add(capability.key); + } + + if (problems.length > 0) { + throw new Error( + [ + 'Capability registry does not cover the served surface. The server will not start.', + '', + ...problems.map((p) => ` • ${p}`), + '', + 'Fix src/servers/capabilities/registry.ts. If a surface genuinely is not capability-gated, add it', + 'to the exemption list in totality.ts WITH a reason — an unexplained exemption is how the', + 'websocket hole happened.', + ].join('\n'), + ); + } +} + +/** Exposed for the settings UI, so an owner can see what a capability actually covers. */ +export function describeCapabilitySurface(): { + exemptApi: typeof EXEMPT_API_PREFIXES; + exemptWs: typeof EXEMPT_WS_PROVIDERS; +} { + return { exemptApi: EXEMPT_API_PREFIXES, exemptWs: EXEMPT_WS_PROVIDERS }; +} + +export { capabilityForApiPath }; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index a2f64233..6bc3f8f0 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -146,48 +146,70 @@ const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); protectedRouter.use(userMiddleware); -protectedRouter.route('/server-settings', serverSettingsRouter); -protectedRouter.route('/users', usersRouter); -protectedRouter.route('/plans', plansRouter); -protectedRouter.route('/skills', skillsRouter); -protectedRouter.route('/tasks', tasksRouter); -protectedRouter.route('/agents', agentsRouter); -protectedRouter.route('/tools', toolsRouter); -protectedRouter.route('/processes', processesRouter); -protectedRouter.route('/rescan', rescanRouter); -protectedRouter.route('/scrape', scrapeRouter); -protectedRouter.route('/upload', uploadRouter); -protectedRouter.route('/user', settingsRouter); -protectedRouter.route('/dashboards', dashboardsRouter); -protectedRouter.route('/task-logs', taskLogsRouter); -protectedRouter.route('/file-browser', fileBrowserRouter); -protectedRouter.route('/music', musicRouter); -protectedRouter.route('/slskd', slskdRouter); -protectedRouter.route('/terminal', terminalRouter); -protectedRouter.route('/memos', memosRouter); -protectedRouter.route('/gitea', giteaRouter); -protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI -protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level) -protectedRouter.route('/notify', notifyRouter); -protectedRouter.route('/headscale', headscaleRouter); -protectedRouter.route('/transmission', transmissionRouter); -protectedRouter.route('/invoiceshelf', invoiceshelfRouter); -protectedRouter.route('/jellyfin', jellyfinRouter); -protectedRouter.route('/photos', photosRouter); -protectedRouter.route('/wallet', walletRouter); -protectedRouter.route('/vpn', vpnRouter); -protectedRouter.route('/system-monitor', systemMonitorRouter); -protectedRouter.route('/activity', activityRouter); -protectedRouter.route('/dock', dockRouter); -protectedRouter.route('/integrations', integrationsRouter); -protectedRouter.route('/queue', queueRouter); -protectedRouter.route('/email', emailRouter); -protectedRouter.route('/browser', browserRouter); -protectedRouter.route('/bug-report', bugReportRouter); -protectedRouter.route('/chat', chatRouter); -protectedRouter.route('/pipeline-jobs', pipelineJobsRouter); -protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI -protectedRouter.route('/desktop', desktopRouter); +// The mount table, as DATA rather than forty statements. +// +// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted +// prefix maps to exactly one capability, and that check is only worth anything if it reads the real mount +// list. A hand-copied second list would drift, and the drift would be invisible until someone tried a +// prefix nobody had gated — which is precisely how the websocket hole happened. +// +// Order is irrelevant here: every prefix is distinct, so hono's registration-order matching has nothing to +// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router. +const PROTECTED_MOUNTS: [prefix: string, router: ReturnType][] = [ + ['/server-settings', serverSettingsRouter], + ['/users', usersRouter], + ['/plans', plansRouter], + ['/skills', skillsRouter], + ['/tasks', tasksRouter], + ['/agents', agentsRouter], + ['/tools', toolsRouter], + ['/processes', processesRouter], + ['/rescan', rescanRouter], + ['/scrape', scrapeRouter], + ['/upload', uploadRouter], + ['/user', settingsRouter], + ['/dashboards', dashboardsRouter], + ['/task-logs', taskLogsRouter], + ['/file-browser', fileBrowserRouter], + ['/music', musicRouter], + ['/slskd', slskdRouter], + ['/terminal', terminalRouter], + ['/memos', memosRouter], + ['/gitea', giteaRouter], + ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI + ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) + ['/notify', notifyRouter], + ['/headscale', headscaleRouter], + ['/transmission', transmissionRouter], + ['/invoiceshelf', invoiceshelfRouter], + ['/jellyfin', jellyfinRouter], + ['/photos', photosRouter], + ['/wallet', walletRouter], + ['/vpn', vpnRouter], + ['/system-monitor', systemMonitorRouter], + ['/activity', activityRouter], + ['/dock', dockRouter], + ['/integrations', integrationsRouter], + ['/queue', queueRouter], + ['/email', emailRouter], + ['/browser', browserRouter], + ['/bug-report', bugReportRouter], + ['/chat', chatRouter], + ['/pipeline-jobs', pipelineJobsRouter], + ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI + ['/desktop', desktopRouter], +]; + +for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router); + +/** Every prefix served behind the account gate. Read by the capability totality check at boot. */ +export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix); + +/** + * Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in + * capabilities/totality.ts, which has to justify each one. + */ +export const UNPROTECTED_API_PREFIXES: string[] = ['/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar']; honoServer.route('/api', protectedRouter);