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>
162 lines
8.3 KiB
TypeScript
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 };
|