Files
platform/src/servers/permissions/registry.ts
T
pastilhasandClaude Opus 5 09518e1e1f the three files fb1cf0f6 was supposed to carry
fb1cf0f6 deleted the todo router, the board and the screen but did NOT remove the
imports of them. so a checkout of that commit has hono.ts importing
`./api/todos/router`, which is not there — and the server cannot start.

fofos hit it within a minute of pulling. it was down until this commit.

WHY IT PASSED EVERY CHECK HERE: the files were modified in my working tree and
never staged, so this machine typechecked, tested and ran against the fix while the
commit shipped without it. every green light I reported was true of the tree in
front of me and false of the thing I pushed.

HOW IT HAPPENED, precisely, because the shape is worth knowing: the `git add` naming
these three also named a path that no longer existed. `git add` ABORTS ON AN
UNMATCHED PATHSPEC — it does not stage the rest and warn. It staged nothing. the
`git status` I printed straight afterwards showed them as ` M`, unstaged, and I read
the output as confirmation rather than as the warning it was.

the lesson is not "look harder at git status". it is that a commit which deletes a
module and a commit which stops importing it must be the same commit, and the only
thing that proves it is a check against the COMMIT rather than the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:19:00 +01:00

660 lines
35 KiB
TypeScript

// The permission registry: the single enumeration of what this platform can do, and the unit the owner
// grants to a role.
//
// ── Why permissions 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 (`/tts`, `/ocr` multipart audio and images —
// `/stt` and `/transcribe` were two more of these until 2026-08-24, when speech-to-text left for the
// `transcribe` plugin), 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 permission.
//
// The deeper reason is that a route list is not what the owner is deciding. The owner decides "this person
// gets Gitea". A permission 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 permission
// 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 permission 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 permission 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 permission, 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 PermissionKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
export type Permission = {
/** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */
key: string;
label: string;
description: string;
kind: PermissionKind;
/**
* 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 permission's own: `/devices` on the `notify`
* permission permits `POST /api/notify/devices/123`.
*/
personal?: string[];
/**
* Sub-paths, relative to each `api` prefix, that ONLY the owner may reach — at any level, by any
* method, however the permission is granted.
*
* For the administrative corner of an otherwise shareable surface. Jellyfin is the case: the plugin is
* exactly what a Member should have, and `/_config` — which stores a server's admin token and drives
* account provisioning — is exactly what they should not. Without this the only options were to grant
* the whole plugin or none of it.
*
* ── Why this is not a kind, and not `ownerOnly` on a permission ──
*
* `plugins/manifest.ts` refuses an `ownerOnly` PERMISSION, and that refusal still stands: a permission
* is a thing the owner delegates, and one nobody can delegate is a contradiction. This is narrower and
* different in kind — the permission stays fully grantable, and a few paths inside it are marked as
* administering the plugin rather than using it.
*
* ── Why it is declared here rather than enforced by each plugin ──
*
* A plugin could check this itself, and the first one would. The tenth would forget a route and nothing
* would notice — no boot failure, no warning, no test, exactly the shape `assertPermissionTotality`
* exists to prevent. Declared here it is enforced once, for every plugin, on the door they all pass
* through, and a route added later by someone who never read this is covered by default.
*
* Beaten by nothing. Checked before `selfService`, so a path that is somehow both is owner-only —
* fail closed, since the two together are a contradiction and the safe reading has to win.
*/
ownerOnly?: 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 permission's `api` prefix, like `personal`.
*/
readOnlyWrites?: string[];
/**
* Routes any authenticated account may call even holding NO grant on this permission, because they act
* on the caller themselves. `METHOD /exact/path`, relative to the permission'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
* permission around it is.
*/
selfService?: string[];
};
/**
* The PLATFORM's own permissions. A plugin's are added on top at runtime — see `setPluginPermissions`.
*
* Kept separate from the live `PERMISSIONS` 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: Permission[] = [
// ── 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
// permission checks as any other request from that user; nothing here widens them.
// `/theme-favorites` sits here rather than on `/themes`, which is unauthenticated so the sign-in screen
// can paint. A starred theme is the same shape as a dock preference: the caller's own taste, granting
// nothing, and not a sensible thing to deny an account that can already choose a theme.
api: ['/user', '/dock', '/api-keys', '/theme-favorites'],
routes: ['/settings/profile'],
},
{
key: 'themes',
label: 'Themes',
description: 'Read the themes installed on this machine',
// `core`, and not deniable. Themes are how the interface looks; an account that could not read the
// list would render unstyled, which is not a permission decision anyone means to make. It is read-only
// — installing a theme is a directory on disk, not a request.
kind: 'core',
api: ['/themes'],
routes: [],
},
{
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: 'memos',
label: 'Memos',
description: 'Notes',
kind: 'app',
// api: ['/memos'], // plugin — switched off 2026-08-13
api: [],
routes: ['/memos'],
},
{
key: 'notify',
label: 'Notifications',
description: 'Push notifications to your devices',
/*
CORE, not `app`, since 2026-08-24.
It was grantable and nothing granted it, so every Member's app got a 403 the moment it tried to
register its push token — silently, at launch, with no notification ever arriving and nothing
anywhere saying why. Invisible for days because the owner bypasses the check.
Making it grantable was the mistake rather than the missing grant. These are the caller's OWN
devices, and the send route resolves its target from the authenticated header rather than the body
(`sidecar/notify/resolve-user.ts`), so an account can only ever reach its own phone. There is no
version of "you may not subscribe to your own notifications" that is a coherent thing to configure.
*/
kind: 'core',
api: ['/notify'],
// Device registration is the caller's own — a phone subscribing to its own push channel.
//
// The `_officer/` segment is NOT decoration: these are the sidecar's paths, and `personal` is matched
// against what remains after the permission's own prefix is stripped. Written as `/devices` it matched
// nothing, so a read-level member could list their phones but not register or delete one — the exact
// opposite of the line above. Invisible in testing because the owner bypasses every check.
personal: ['/_officer/devices', '/_officer/subscriptions'],
/*
Broadcast is the one part of a core permission that is not the caller's own.
Everything else here acts on the account asking — its devices, its notifications — which is what
made `core` right. Sending to EVERY account is administration, so it needs the owner even though
the permission around it is not deniable.
*/
ownerOnly: ['/broadcast', '/user'],
},
// `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 cost: the tailnet's own permission was `admin`, so with `vpn` gone no member-grantable
// tailnet surface remained. That permission has since left with the offscale plugin, which declares its
// own — so a member-facing enrolment surface would be the PLUGIN's to add, not this file's.
// 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: ['/'],
},
// Core because there is nothing to grant a level of: a commander is the caller's own session, in the
// caller's own directory, in a table scoped to the caller's own user_id at every query. Withholding
// this from a role would not restrict what they can reach, it would just hide their own conversations
// from a second list of the same conversations — the same reasoning `dashboards` above rests on.
{
key: 'command-hq',
label: 'Command HQ',
description: 'Long-running commanders you started, organised into categories',
kind: 'core',
api: ['/command-hq'],
routes: ['/command-hq'],
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.
// `/claude-auth` is the `/login` flow — it spawns `claude auth login` AS THE CALLER and writes the
// credential into the caller's own home. It rides this permission rather than earning its own for the
// reason that makes the pairing exact: an account that may use the agent must be able to authenticate
// the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest
// for free — `authorize.ts` drops a confined grant for an account with no `osUser`, which is precisely
// the account that cannot hold credentials of its own.
//
// NOT under `/auth`, which `totality.ts` exempts from the gate entirely because it is where a caller
// goes to BECOME authenticated. Mounted there, an unauthenticated request could start a login as the
// owner.
api: ['/chat', '/agent-status', '/claude-auth'],
// 'chat-notify' is a session-independent socket (the bell feature): it carries no sessionId and no
// transcript, just a ping that a chat this account can already reach has finished a turn or needs
// input. Riding this permission rather than a new one, because it is meaningless without `chat`
// already granted.
ws: ['chat', 'chat-notify'],
// `/bots` is a chat with a context, not a second feature: a bot IS a conversation whose working
// directory is `bots/`, armed through the background job any chat can arm. It earns no permission of
// its own because it adds no surface — the same routes, the same socket, the same confinement.
routes: ['/chat', '/bots'],
},
// 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'],
// `POST /file-browser/download` zips a multi-selection and streams it back. It writes nothing — it is
// a POST only because a list of paths does not fit a query string. Without this a read-level member
// could download a whole folder (GET /download) but not two files, which is not a rule anyone chose.
//
// `isPrefixOf` is segment-aware, so this does NOT also permit `/download-video` — which really is a
// write, fetching a video onto the disk.
readOnlyWrites: ['/download'],
},
{
key: 'tasks',
label: 'Tasks and jobs',
description: 'Running permissions, pipelines and background jobs',
kind: 'execution',
api: ['/tasks', '/jobs', '/pipeline-jobs', '/queue'],
ws: ['task-runner', 'pipeline'],
routes: ['/jobs'],
},
/*
SendOff is `app`, not `confined`, and the distinction is the point.
It writes into a home directory, which normally means `confined` — but it writes exactly one shape of
path, `Documents/SendOff/<sender>/`, chosen by the server from the caller's token. There is no path in
the request to contain, nothing to traverse out of, and no way to read anything back. So the Linux-user
requirement that `confined` exists to enforce buys nothing here, and requiring it would mean an account
with no OS user could not be sent a file — which is most of the point of the feature.
WHO may send to whom is not decided here. It is a row per pair held by the recipient, because the
person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all.
*/
/*
`confined`, and for the same reason chat is.
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
whoever called it, which is the whole distinction.
*/
{
key: 'sub-agents',
label: 'Sub-agents',
description: 'Start another agent from a prompt, subject to the account budget',
kind: 'confined',
api: ['/sub-agents'],
},
{
key: 'sendoff',
label: 'SendOff',
description: 'Send files to your own machines, and to accounts that have allowed you',
kind: 'app',
api: ['/sendoff'],
routes: ['/sendoff'],
},
{
key: 'items',
label: 'Permission authoring',
description: 'Skills, tools, agents and processes on disk',
kind: 'execution',
api: ['/skills', '/tools', '/agents', '/processes', '/rescan'],
},
// `desktop` left with the plugin on 2026-08-21. It is the first permission to make the round trip from
// `kind: 'execution'` to a plugin's own, and the property survived the move: a plugin permission has no
// kind, so "runs as the owner and cannot be shared" is expressed as `ownerOnly: ['/']` in the plugin's
// manifest instead. Same answer, stated where the paths are.
// The relay left for `plugins/browser` on 2026-08-21, taking `/browser` and its frontend route with it.
// What stays is `/scrape`, and the two were never the same thing wearing one word: scrape launches a
// headless chromium through playwright as the OWNER and knows nobody, which is why this is still
// `execution`. The plugin attaches to whoever is holding the keyboard, scoped per pairing, and is
// grantable for exactly that reason.
{
key: 'browser',
label: 'Scraping',
description: 'Fetches and renders a page in a headless browser, as the server owner',
kind: 'execution',
api: ['/scrape'],
},
// ── 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 permission 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'],
// The API kept its name; only the screen was renamed. `/plugins` stays listed because the redirect
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
routes: ['/marketplace', '/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 permission exists, which is the entire point.
{
key: 'roles',
label: 'Roles',
description: 'Create, rename and delete the roles this server grants',
// Admin, and for the same reason `user-admin` beside it is: a role is not a member's own data, it is
// policy that governs every account holding it, so there is no level of "read" that makes creating or
// deleting one a feature to grant. `registry.test.ts` asserts admin can never be handed out — this
// entry is what makes that assertion mean something for roles specifically.
//
// Separate from `user-admin` rather than folded into it: that permission's `/users` is the account
// list and the grants a role holds (`/users/permissions`); this one is the roles TABLE itself — the
// name, the description, whether it is system-named. Two routers, two questions, so a boot-time check
// that a mounted prefix has exactly one owner stays meaningful for both.
kind: 'admin',
api: ['/roles'],
},
];
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
//
// `let`, not `const`, because installing a plugin adds permissions and uninstalling removes them. ESM
// exports are live bindings, so an importer holding `PERMISSION_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 `permissionForApiPath` and the totality check read. */
export let PERMISSIONS: Permission[] = CORE_REGISTRY;
export let PERMISSION_BY_KEY = new Map(PERMISSIONS.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_PERMISSIONS = PERMISSIONS.filter((c) => c.kind === 'app' || c.kind === 'confined');
/**
* What every role starts with on a fresh install: the three confined permissions, 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` permissions 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 PERMISSIONS: a plugin must not be able to put itself in the baseline
// every new role starts with.
export const DEFAULT_ROLE_PERMISSIONS: 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_PERMISSIONS = CORE_REGISTRY.filter((c) => c.kind === 'core');
/**
* Replace the plugin half of the registry. Called after every install, uninstall, enable and disable.
*
* Plugin permissions 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.
*/
/**
* Which of these keys the PLATFORM already claims.
*
* Exported so install can refuse before it starts, rather than leaving `setPluginPermissions` to reject
* the key afterwards — that rejection is real but it happens at mount time and only reaches stderr, so
* the install reports success, the dock tile appears, and the route 404s. That exact sequence caught
* photos, soulseek and jellyfin on the same machine within a day: each had been a CORE permission until
* the extraction commit, so any server whose platform predates it still claims the key.
*/
export function corePermissionCollisions(keys: string[]): string[] {
const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key));
return keys.filter((k) => coreKeys.has(k));
}
export function setPluginPermissions(pluginPermissions: Permission[]): { rejected: string[] } {
const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key));
const seen = new Set<string>();
const accepted: Permission[] = [];
const rejected: string[] = [];
for (const permission of pluginPermissions) {
if (coreKeys.has(permission.key) || seen.has(permission.key)) {
rejected.push(permission.key);
continue;
}
seen.add(permission.key);
accepted.push(permission);
}
PERMISSIONS = [...CORE_REGISTRY, ...accepted];
PERMISSION_BY_KEY = new Map(PERMISSIONS.map((c) => [c.key, c]));
GRANTABLE_PERMISSIONS = PERMISSIONS.filter((c) => c.kind === 'app' || c.kind === 'confined');
return { rejected };
}
export type PermissionLevel = 'read' | 'write';
const isPrefixOf = (prefix: string, path: string): boolean =>
prefix === '/' || path === prefix || path.startsWith(`${prefix}/`);
/**
* Which permission owns this path? `path` is the full request path (`/api/gitea/...`).
*
* Longest prefix wins, so a permission may claim `/dav` while another claims `/dav/something` without the
* order of the array mattering. Returns null for a path no permission claims — which the totality check
* below is there to make impossible for anything mounted on protectedRouter.
*/
export function permissionForApiPath(path: string): Permission | null {
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
let best: Permission | null = null;
let bestLength = -1;
for (const permission of PERMISSIONS) {
for (const prefix of permission.api) {
if (isPrefixOf(prefix, rest) && prefix.length > bestLength) {
best = permission;
bestLength = prefix.length;
}
}
}
return best;
}
export function permissionForWsProvider(provider: string): Permission | null {
return PERMISSIONS.find((c) => c.ws?.includes(provider)) ?? null;
}
/**
* Is this path inside one of the permission's owner-only sub-paths?
*
* Prefix-matched, like `personal` — `/_config` covers `/_config/3/users`. A settings surface is a tree,
* and enumerating its leaves is how one gets missed.
*/
export function isOwnerOnlyRoute(permission: Permission, path: string): boolean {
if (!permission.ownerOnly?.length) return false;
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
return permission.api.some((prefix) => {
if (!isPrefixOf(prefix, rest)) return false;
const sub = rest.slice(prefix.length) || '/';
return permission.ownerOnly!.some((entry) => isPrefixOf(entry, sub));
});
}
/**
* Is this an exact self-service route — one any authenticated account may call without holding the
* permission at all? Matched exactly on method AND path, never as a prefix.
*/
export function isSelfServiceRoute(permission: Permission, method: string, path: string): boolean {
if (!permission.selfService?.length) return false;
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
const upper = method.toUpperCase();
return permission.api.some((prefix) => {
if (!isPrefixOf(prefix, rest)) return false;
const sub = rest.slice(prefix.length) || '/';
return permission.selfService!.includes(`${upper} ${sub}`);
});
}
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* May a caller holding `level` on `permission` make this request?
*
* `write` is unconditional within the permission. `read` permits safe methods, anything the permission
* declares as a `readOnlyWrites` read-in-POST-clothing, and mutations confined to `personal` sub-paths.
*/
export function isRequestAllowedAtLevel(
permission: Permission,
level: PermissionLevel,
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 permission's own prefixes matched, so `personal` entries are written relative
// to the permission rather than repeated per prefix.
const withinPermission = permission.api
.filter((prefix) => isPrefixOf(prefix, rest))
.map((prefix) => rest.slice(prefix.length) || '/');
const allowed = [...(permission.personal ?? []), ...(permission.readOnlyWrites ?? [])];
return withinPermission.some((sub) => allowed.some((entry) => isPrefixOf(entry, sub)));
}