Files
platform/src/servers/permissions/totality.ts
T
pastilhasandClaude Opus 5 f6a1194905 vault, extracted into plugins/vault — the platform half
Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.

Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── The one new piece of platform: src/servers/account-lifecycle.ts ──

Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.

Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)

Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.

── Two things deleted rather than carried, both dark since 2026-08-13 ──

The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.

The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.

── Tests ──

Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.

bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.

Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:43:32 +00:00

162 lines
8.3 KiB
TypeScript

import { PERMISSIONS, permissionForApiPath, permissionForWsProvider } from './registry';
// The part that actually matters.
//
// On 2026-08-06 a Member token that was 403'd on `GET /api/tasks` opened `/api/tasks/pipeline/ws` and got
// a 101 in the same minute — a shell, the agent with --dangerously-skip-permissions, arbitrary script
// execution and the owner's screen, all reachable. The cause was not a bad rule. It was that WebSocket
// upgrades never reach Hono's middleware at all: Bun's route table in server.tsx matches
// `/api/terminal/ws` before the `/api/*` catch-all, so the authorisation gate was simply not on that code
// path. Nobody had written a rule that was wrong; a door had been added and the rule had not been told.
//
// It was fixed in 2873948 by adding the check to the socket door too. That fix is a patch, and patches of
// that shape do not survive the next door. What survives is refusing to boot: every mounted API prefix and
// every user-facing WebSocket provider must map to exactly one permission, or the server does not start.
// Add a router and forget the registry and you find out during `pm2 restart`, not during an incident.
//
// The exemptions below are the honest cost of that: a short list of surfaces that genuinely are not
// user-permission-gated, each of which has to say why. A list that grows silently is the failure mode, so
// keep it short and keep the reasons real.
/**
* Mounted under `honoServer` directly rather than `protectedRouter`, and gated by something other than a
* platform permission. Each entry is a claim that has to stay true.
*/
const EXEMPT_API_PREFIXES: Record<string, string> = {
// Unauthenticated by necessity — this is where a caller goes to BECOME authenticated.
'/auth': 'signin, bootstrap and token refresh; runs before any account exists to check',
// Public marketing surfaces, served to anonymous visitors.
'/landing-page-data': 'public landing page content, no account involved',
'/waitlist': 'public signup form, no account involved',
// Registration socket for sidecars. Process-to-process on loopback; there is no user on this path.
'/sidecar': 'sidecar registration, loopback process-to-process',
// The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so
// userMiddleware would 401 it and a permission lookup would have no account to resolve. What it does
// present is a per-panel token minted by the platform, which identifies exactly one agent panel and
// authorises exactly one action — deliver a prompt to a NAMED PEER ON THAT PANEL'S OWN DASHBOARD.
// It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is
// the whole justification: this is not a general API door with a second credential, it is one verb.
'/agent-handoff': 'agent-to-agent handoff; a per-panel bearer token authorising one verb, not a platform JWT',
// An iframe cannot send an Authorization header — it is a navigation, not a fetch — so the permission
// gate has nothing to read and would 401 the preview before it loaded. The route is NOT open: it
// verifies the owner's token from the URL once and issues a short-lived cookie, and it refuses anyone
// who is not the owner. That check lives in the router because it is the only door here, which is
// exactly the shape this list exists to make somebody justify.
'/dev-proxy': 'iframes cannot carry a bearer token; the router itself verifies the owner and sets a cookie',
};
/**
* WebSocket providers that do not go through `upgradeWs` and therefore never carry a platform account.
*/
const EXEMPT_WS_PROVIDERS: Record<string, string> = {
sidecar: 'sidecar registration, loopback process-to-process, no user',
// The HMR socket of a dev server being previewed. Upgraded by `upgradeDevProxyWs`, which requires the
// same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner
// without carrying an account through `upgradeWs`. Refused outright without that cookie.
'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues',
};
/**
* Is this path served above the account gate?
*
* Read by the permission backstop, so that signin — which by definition has no account to check — is not
* asked to prove a permission. Deliberately shares EXEMPT_API_PREFIXES with the boot check: an exemption
* granted at boot and an exemption honoured at request time must be the same list, or one of them is a
* hole.
*/
export function isExemptApiPath(path: string): boolean {
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
return Object.keys(EXEMPT_API_PREFIXES).some((prefix) => rest === prefix || rest.startsWith(`${prefix}/`));
}
export type PermissionSurface = {
/** Every prefix mounted on protectedRouter, as written in hono.ts. */
apiPrefixes: string[];
/** Every key of the `handlers` map in server.tsx. */
wsProviders: string[];
};
/**
* Refuses to return if the registry and the real surface disagree. Called from the boot path.
*
* Four ways to fail, and all four are real bugs rather than pedantry:
* - a mounted prefix no permission claims → reachable by a rule nobody wrote
* - a prefix two permissions claim → which grant applies is undefined
* - a declared prefix nothing mounts → the registry is describing a router that no longer exists
* - a socket provider no permission claims → exactly the 2026-08-06 hole, structurally
*/
export function assertPermissionTotality(surface: PermissionSurface): void {
const problems: string[] = [];
// 1. Every mount is claimed, and claimed once.
for (const prefix of surface.apiPrefixes) {
if (prefix in EXEMPT_API_PREFIXES) continue;
const claimants = PERMISSIONS.filter((c) => c.api.includes(prefix));
if (claimants.length === 0) {
problems.push(`/api${prefix} is mounted but no permission claims it — add it to the registry`);
} else if (claimants.length > 1) {
problems.push(`/api${prefix} is claimed by ${claimants.map((c) => c.key).join(', ')} — it must be exactly one`);
}
}
// 2. Every claim corresponds to something real. Catches a router that was deleted or renamed while the
// registry kept describing it — which would leave a grant that silently means nothing.
const mounted = new Set(surface.apiPrefixes);
for (const permission of PERMISSIONS) {
for (const prefix of permission.api) {
if (!mounted.has(prefix)) {
problems.push(`permission '${permission.key}' claims /api${prefix}, which nothing mounts`);
}
}
}
// 3. Every socket door is claimed. This is the one the incident was about.
for (const provider of surface.wsProviders) {
if (provider in EXEMPT_WS_PROVIDERS) continue;
if (!permissionForWsProvider(provider)) {
problems.push(`websocket provider '${provider}' is served but no permission claims it`);
}
}
// 4. And no permission claims a socket that does not exist.
const providers = new Set(surface.wsProviders);
for (const permission of PERMISSIONS) {
for (const provider of permission.ws ?? []) {
if (!providers.has(provider)) {
problems.push(`permission '${permission.key}' claims websocket '${provider}', which is not served`);
}
}
}
// 5. Keys are unique — a duplicate would make grants ambiguous in the database.
const seen = new Set<string>();
for (const permission of PERMISSIONS) {
if (seen.has(permission.key)) problems.push(`duplicate permission key '${permission.key}'`);
seen.add(permission.key);
}
if (problems.length > 0) {
throw new Error(
[
'Permission registry does not cover the served surface. The server will not start.',
'',
...problems.map((p) => ` • ${p}`),
'',
'Fix src/servers/permissions/registry.ts. If a surface genuinely is not permission-gated, add it',
'to the exemption list in totality.ts WITH a reason — an unexplained exemption is how the',
'websocket hole happened.',
].join('\n'),
);
}
}
/** Exposed for the settings UI, so an owner can see what a permission actually covers. */
export function describePermissionSurface(): {
exemptApi: typeof EXEMPT_API_PREFIXES;
exemptWs: typeof EXEMPT_WS_PROVIDERS;
} {
return { exemptApi: EXEMPT_API_PREFIXES, exemptWs: EXEMPT_WS_PROVIDERS };
}
export { permissionForApiPath };