The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.
Three things stayed, each on purpose.
cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.
The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.
api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.
Two bugs found on the way, neither visible from reading.
The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.
[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.
registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.
music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.
Not yet verified on the live server — that is next.
568 lines
27 KiB
TypeScript
568 lines
27 KiB
TypeScript
// 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. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole
|
|
// surface is personal and a read grant is really "your own, fully". So "may a member write here" is a
|
|
// property of the endpoint, not a policy knob someone has to remember to set.
|
|
//
|
|
// Music was this note's example until 2026-08-15, when it left with `plugins/music/`. A plugin declares
|
|
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
|
|
// so they are one mechanism under two names.
|
|
|
|
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: `/devices` on the `notify`
|
|
* capability permits `POST /api/notify/devices/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[];
|
|
};
|
|
|
|
/**
|
|
* The PLATFORM's own capabilities. A plugin's are added on top at runtime — see `setPluginPermissions`.
|
|
*
|
|
* Kept separate from the live `CAPABILITIES` below so two invariants hold by construction rather than by
|
|
* anyone remembering them: a plugin can never become `core` (granted to everyone, undeniable), and a
|
|
* plugin is never in the fresh-install baseline.
|
|
*/
|
|
const CORE_REGISTRY: 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'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
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: 'photos',
|
|
label: 'Photos',
|
|
description: 'Browse the photo library',
|
|
kind: 'app',
|
|
// api: ['/photos'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/photos'],
|
|
},
|
|
{
|
|
key: 'jellyfin',
|
|
label: 'Video',
|
|
description: 'Browse and play the Jellyfin library',
|
|
kind: 'app',
|
|
// api: ['/jellyfin'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/jellyfin'],
|
|
},
|
|
{
|
|
key: 'memos',
|
|
label: 'Memos',
|
|
description: 'Notes',
|
|
kind: 'app',
|
|
// api: ['/memos'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
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'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
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'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/email'],
|
|
},
|
|
{
|
|
key: 'notify',
|
|
label: 'Notifications',
|
|
description: 'Push notifications to your devices',
|
|
kind: 'app',
|
|
// api: ['/notify'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
// 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'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/transmission'],
|
|
},
|
|
{
|
|
key: 'soulseek',
|
|
label: 'Soulseek',
|
|
description: 'Search and download from the Soulseek network',
|
|
kind: 'app',
|
|
// api: ['/slskd'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/soulseek'],
|
|
},
|
|
{
|
|
key: 'invoices',
|
|
label: 'Invoices',
|
|
description: 'InvoiceShelf books',
|
|
kind: 'app',
|
|
// api: ['/invoiceshelf'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/invoices'],
|
|
},
|
|
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
|
|
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
|
|
// OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently
|
|
// independent of the platform, since the thing that gets you to the platform cannot itself need it.
|
|
//
|
|
// Device enrolment did not go away, it moved out. A phone claims an invite from the Companion at
|
|
// `${invite.base}/api/v1/enroll/claim`, which is a different component entirely and does not involve
|
|
// Officer. Confirmed against the mobile monorepo and the companion repo before removal.
|
|
//
|
|
// What this does cost: `headscale` is `admin`, so with `vpn` gone no member-grantable headscale surface
|
|
// remains. Reintroduce one here if members ever need to enrol their own devices through Officer.
|
|
// 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, and true since 2026-08-12: a member's turn spawns their own `claude` as their own Linux account
|
|
// through `sudo setpriv`, with their own `~/.claude` credential, their own transcripts and sessions that
|
|
// record whose they are. The two owner-only refusals that held this open — `api/chat/chat.ts` and the socket
|
|
// in `server.tsx` — were removed together once each of those was in place.
|
|
{
|
|
key: 'chat',
|
|
label: 'Chat',
|
|
description: 'The agent',
|
|
kind: 'confined',
|
|
// `/agent-status` is on this grant but deliberately NOT on `chatRouter`, because that router refuses
|
|
// non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports
|
|
// two booleans about the caller's own home and nothing about anyone else, which is what lets a member be
|
|
// told "run `claude` once to sign in" instead of being shown a tile that 403s.
|
|
api: ['/chat', '/agent-status'],
|
|
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/<email>/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', '/queue'],
|
|
ws: ['task-runner', 'pipeline'],
|
|
routes: ['/jobs'],
|
|
},
|
|
{
|
|
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'], // plugin — switched off 2026-08-13
|
|
// ws: ['desktop'],
|
|
api: [],
|
|
routes: ['/desktop'],
|
|
},
|
|
// `/browser` — the Chrome-extension relay — is unmounted as of 2026-08-13 and dropped from this claim,
|
|
// because check 2 of assertCapabilityTotality refuses to boot on a capability claiming a prefix nothing
|
|
// serves. Put both back together or neither.
|
|
//
|
|
// `/scrape` is untouched and is what this capability still covers. It shares nothing with the relay: it
|
|
// launches its own headless chromium through playwright and never speaks to the extension.
|
|
{
|
|
key: 'browser',
|
|
label: 'Browser',
|
|
description: 'Drives a real browser on the host',
|
|
kind: 'execution',
|
|
api: ['/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: 'plugins',
|
|
label: 'Plugins',
|
|
description: 'Install, enable and remove the plugins this server runs',
|
|
// Admin for the same reason as the app store above: installing a plugin mounts routes and starts a
|
|
// process, which is process control rather than a feature to grant a read of.
|
|
//
|
|
// Note this capability guards the MANAGEMENT surface, not the plugins themselves. A plugin declares
|
|
// its own permissions in its manifest, and those are what gate its routes — so a member can hold
|
|
// `offscale` at read without being able to install or remove anything.
|
|
kind: 'admin',
|
|
api: ['/plugins'],
|
|
routes: ['/plugins'],
|
|
},
|
|
{
|
|
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 /'],
|
|
},
|
|
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
|
|
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
|
|
// platform no longer knows this capability exists, which is the entire point.
|
|
{
|
|
key: 'wallet',
|
|
label: 'Wallet',
|
|
description: "The server owner's bitcoin",
|
|
kind: 'admin',
|
|
// api: ['/wallet'], // plugin — switched off 2026-08-13
|
|
api: [],
|
|
routes: ['/wallet'],
|
|
},
|
|
];
|
|
|
|
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// `let`, not `const`, because installing a plugin adds capabilities and uninstalling removes them. ESM
|
|
// exports are live bindings, so an importer holding `CAPABILITY_BY_KEY` sees the reassignment — every
|
|
// consumer reads these inside a function, never at module scope, which is what makes that safe.
|
|
//
|
|
// Nothing mutates the arrays in place. `setPluginPermissions` replaces them, for the same reason
|
|
// `buildHonoApp` replaces the app rather than adding routes to it: a half-updated lookup is worse than a
|
|
// stale one, and replacement has no half.
|
|
|
|
/** Core plus every installed plugin's. The list `capabilityForApiPath` and the totality check read. */
|
|
export let CAPABILITIES: Capability[] = CORE_REGISTRY;
|
|
|
|
export let 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 let GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined');
|
|
|
|
/**
|
|
* What every role starts with on a fresh install: the three confined capabilities, at `write`.
|
|
*
|
|
* These are the baseline the platform is FOR — a terminal, a file browser and chat. An account that can sign
|
|
* in and reach none of them is not a restricted account, it is a useless one, and making the owner grant them
|
|
* by hand before anyone can do anything is a step with no decision in it.
|
|
*
|
|
* Seeded as real rows rather than implied by absence, which keeps the table's one rule intact: a missing row
|
|
* means no access, always, with no exceptions to remember. So revoking one of these works exactly like
|
|
* revoking anything else — the row goes, and nothing puts it back.
|
|
*
|
|
* `app` capabilities are deliberately NOT here. Those reach data the owner may not intend to share, and each
|
|
* needs a sidecar installed before it means anything anyway.
|
|
*/
|
|
// From CORE_REGISTRY, never from CAPABILITIES: a plugin must not be able to put itself in the baseline
|
|
// every new role starts with.
|
|
export const DEFAULT_ROLE_CAPABILITIES: string[] = CORE_REGISTRY.filter((c) => c.kind === 'confined').map((c) => c.key);
|
|
|
|
/**
|
|
* Available to every signed-in account without a grant.
|
|
*
|
|
* From CORE_REGISTRY, so a plugin declaring `kind: 'core'` — which its manifest cannot express, but which
|
|
* a future bug could smuggle in — still could not grant itself to everyone undeniably.
|
|
*/
|
|
export const CORE_CAPABILITIES = CORE_REGISTRY.filter((c) => c.kind === 'core');
|
|
|
|
/**
|
|
* Replace the plugin half of the registry. Called after every install, uninstall, enable and disable.
|
|
*
|
|
* Plugin capabilities are dropped and re-added wholesale rather than diffed: the source of truth is the
|
|
* set of installed plugins, and computing a delta against it would be a second answer to the same
|
|
* question. A key colliding with a core one is REFUSED here rather than silently overriding — a plugin
|
|
* that could redefine `chat` or `terminal` could widen it.
|
|
*/
|
|
export function setPluginPermissions(pluginCapabilities: Capability[]): { rejected: string[] } {
|
|
const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key));
|
|
const seen = new Set<string>();
|
|
const accepted: Capability[] = [];
|
|
const rejected: string[] = [];
|
|
|
|
for (const capability of pluginCapabilities) {
|
|
if (coreKeys.has(capability.key) || seen.has(capability.key)) {
|
|
rejected.push(capability.key);
|
|
continue;
|
|
}
|
|
seen.add(capability.key);
|
|
accepted.push(capability);
|
|
}
|
|
|
|
CAPABILITIES = [...CORE_REGISTRY, ...accepted];
|
|
CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c]));
|
|
GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined');
|
|
return { rejected };
|
|
}
|
|
|
|
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)));
|
|
}
|