// 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. // confined execution-shaped, but the KERNEL enforces the boundary per account. Grantable, and only // to an account that has a Linux user — see below. // execution NEVER grantable. Owner only, structurally. // admin owner only: the platform administering itself, and the owner's own money and network. // // ── `confined`, and why it is not just `app` ── // // Added 2026-08-11 with per-user Linux accounts (docs/per-user-linux-accounts.md). A confined capability // touches the filesystem or runs a process, so calling it an `app` would be a lie — but it is no longer // the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing // everything above it. // // The distinction earns its keep in one place: a grant on a confined capability means NOTHING unless the // account actually has that Linux user. `authorize.ts` drops confined grants for an account with no // `osUser`, so "granted but unconfined" resolves to no access rather than to the owner's home. That rule // lives there, once, instead of in each router that would otherwise have to remember it. // // Moving a capability from `execution` to `confined` is therefore a claim with a test attached: every path // it reaches must resolve its directory from the CALLER, not from HOME_DIR. // // `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' | 'confined' | '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[]; /** * Routes any authenticated account may call even holding NO grant on this capability, because they act * on the caller themselves. `METHOD /exact/path`, relative to the capability's prefix — exact, not a * prefix, so this cannot widen by accident. * * One entry exists and it should stay that way. `PUT /api/users` is self-profile update (useAuth.ts * calls it to change your own name and avatar) and has always lived on the same router as the owner-only * account administration beside it. Moving it to `/api/user` would be tidier and would break every * shipped mobile client, so the honest fix is to say out loud that this one route is not what the * capability around it is. */ selfService?: string[]; }; export const CAPABILITIES: Capability[] = [ // ── core ──────────────────────────────────────────────────────────────────────────────────────── { key: 'account', label: 'Account', description: 'Sign in, your own profile, password, preferences and API keys', kind: 'core', // `/api-keys` is core rather than app or admin because a key is not new authority — it is a second way // to present the authority the account already has, so denying it would only force the holder to keep // using a password in places a password should not go. What a key can then DO is decided by the same // capability checks as any other request from that user; nothing here widens them. api: ['/user', '/dock', '/api-keys'], 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: ['/'], }, // Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is // the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`, // `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it // meant a member with `files` granted got a completely blank Files screen and no request to // /api/file-browser at all — the panel never mounted. Same for Terminal, Chat and every other screen. // // It is entirely `personal` and always was: every row is keyed to the caller. There is nothing here to // withhold, and withholding it does not restrict an account, it breaks it — which is exactly the // definition of `core` at the top of this file. { key: 'dashboards', label: 'Screen layouts and dashboards', description: 'Where your own screen layouts and dashboards are saved', kind: 'core', api: ['/dashboards'], routes: ['/dashboards'], personal: ['/'], }, // ── execution: never grantable ────────────────────────────────────────────────────────────────── // Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their // own Linux account, in their own home, with the platform's environment cleared — so it is their shell, and // the kernel decides what it can reach. The sidecar also records whose each session is, so `list` and `kill` // scope to the caller instead of every shell on the box. // // What this is NOT is a jail. A member with a shell can `cd /` and read whatever the system leaves // world-readable, like any account on any machine. It isolates members from each other and from the owner's // files, which is the promise `confined` makes. { key: 'terminal', label: 'Terminal', description: 'A shell on this machine, as your own user', kind: 'confined', api: ['/terminal'], ws: ['terminal'], routes: ['/terminal'], }, // Confined so the owner can grant it and the route resolves — but the agent underneath still runs as the // OWNER, so `api/chat/chat.ts` refuses a non-owner outright and the chat socket is refused in server.tsx. // A deliberate, temporary gap: the permission exists, the functionality follows when a turn can be spawned // under `runAs` with the member's own HOME. Until then this grant buys a route and a refusal, and the // comments at both guards say so. { key: 'chat', label: 'Chat', description: 'The agent', kind: 'confined', api: ['/chat'], ws: ['chat'], routes: ['/chat'], }, // Confined rather than execution since 2026-08-11. Every path under `/file-browser` resolves its root // through `resolveHomeDir(userId)` in a middleware that refuses the request outright when the account has // no Linux user — so a member sees their own home and `resolveUserPath`'s containment check stops them // walking out of it. `/upload` was already per-caller: it writes only under // `DATA_PATH//attachments`, never into a home. { key: 'files', label: 'Files', description: 'Your own home directory on this machine, and the code editor over it', kind: 'confined', 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: 'app-store', label: 'App store', description: 'Install, enable and remove the sidecars this server runs', // Admin, not app. Installing a sidecar starts a process on the machine and provisioning one starts // containers — that is process control, not a feature a member can be granted a read of. The router // gates on the owner in its own right as well; this entry is what makes the boot check pass and what // keeps the surface visible in one enumeration. kind: 'admin', api: ['/app-store'], routes: ['/app-store'], }, { 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'], // Changing your own name, username and avatar. Owner-only account administration is every other route // on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock. selfService: ['PUT /'], }, { 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; `execution` and `admin` are owner-only. * * `confined` is offered here, but a grant on one is inert for an account without a Linux user — that is * enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner * could not pre-grant a role before provisioning the people in it, which is the normal order of operations. */ export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); /** 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; } /** * Is this an exact self-service route — one any authenticated account may call without holding the * capability at all? Matched exactly on method AND path, never as a prefix. */ export function isSelfServiceRoute(capability: Capability, method: string, path: string): boolean { if (!capability.selfService?.length) return false; const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; const upper = method.toUpperCase(); return capability.api.some((prefix) => { if (!isPrefixOf(prefix, rest)) return false; const sub = rest.slice(prefix.length) || '/'; return capability.selfService!.includes(`${upper} ${sub}`); }); } 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))); }