reported from the field on the first real push: it failed with BadDeviceToken and the server deleted the registration. the build was production-signed and the row said sandbox — a configuration mismatch, with the device and the app both perfectly fine. pruning there destroys the evidence. the phone re-registers on next launch, the row comes back looking healthy, and the next send fails identically; you can lose a day to that loop without ever seeing what is actually wrong. so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune — apple and google are saying the app is uninstalled. BadDeviceToken, DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check. everything else is transient and still counts toward three strikes. INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed MESSAGE — including a non-string in `data`, which is the mistake this codebase is most likely to make and already has a test for. deleting on it meant one bug in the payload builder would wipe every android device from the registry on a single send, presenting as "android push stopped working" with nothing left to explain why. misconfigured is also not counted as a strike. not deleting is not enough on its own — a persistent mismatch would reach three failures and delete the row anyway, which is the same bug with a slower fuse. the apns log now names the mismatch rather than the reason code, since the code alone sends people to look at the device: it prints the row's environment and topic and says to check the SHIPPED artefact rather than the intermediate build, which is exactly where this one hid.
542 lines
28 KiB
TypeScript
542 lines
28 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 (`/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 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[];
|
|
/**
|
|
* 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.
|
|
api: ['/user', '/dock', '/api-keys'],
|
|
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: '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: '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.
|
|
//
|
|
// 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'],
|
|
},
|
|
// `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.
|
|
api: ['/chat', '/agent-status'],
|
|
// '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'],
|
|
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'],
|
|
// `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'],
|
|
},
|
|
{
|
|
key: 'items',
|
|
label: 'Permission 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 assertPermissionTotality refuses to boot on a permission claiming a prefix nothing
|
|
// serves. Put both back together or neither.
|
|
//
|
|
// `/scrape` is untouched and is what this permission 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 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.
|
|
];
|
|
|
|
// ── 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 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)));
|
|
}
|