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);