step 1/4: the permission engine is called permissions, not capabilities

The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
This commit is contained in:
2026-08-15 16:03:22 +00:00
parent b0fcd8b81b
commit 2e8ec845c8
59 changed files with 454 additions and 442 deletions
+11 -11
View File
@@ -48,7 +48,7 @@ import { integrationsRouter, googleCallbackHandler } from './api/integrations/in
import { queueRouter } from './api/queue/queue';
// import { emailRouter } from './api/email/router';
// The browser relay is switched off — see server.tsx. Restoring this mount means restoring the
// registry's claim on '/browser' in the same commit, or assertCapabilityTotality refuses to boot.
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
// import { browserRouter } from './api/browser/router';
// import { desktopRouter } from './api/desktop/rest';
import { bugReportRouter } from './api/bug-report/bug-report';
@@ -56,7 +56,7 @@ import { agentStatusRouter } from './api/agent-status/router';
import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, capabilityGateMiddleware } from './_middlewares';
import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares';
export { Hono };
export { createRouter };
@@ -89,7 +89,7 @@ export type MountedPlugin = { prefix: string; router: ReturnType<typeof createRo
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
// not a loosening: the check it replaced defaulted to off, so this is what every real install already
// did. The perimeter is the tailnet and the lock is a valid token on every protected route, plus the
// capability gate below.
// permission gate below.
const corsMiddleware = cors({
origin: (origin) => origin ?? '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
@@ -110,8 +110,8 @@ const isDavPath = (path: string) =>
// The mount table, as DATA rather than forty statements.
//
// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted
// prefix maps to exactly one capability, and that check is only worth anything if it reads the real mount
// The reason is the permission registry: assertPermissionTotality refuses to boot unless every mounted
// prefix maps to exactly one permission, and that check is only worth anything if it reads the real mount
// list. A hand-copied second list would drift, and the drift would be invisible until someone tried a
// prefix nobody had gated — which is precisely how the websocket hole happened.
//
@@ -129,7 +129,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/scrape', scrapeRouter],
['/upload', uploadRouter],
['/user', settingsRouter],
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
@@ -161,12 +161,12 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
];
/** Every prefix served behind the account gate. Read by the capability totality check at boot. */
/** Every prefix served behind the account gate. Read by the permission totality check at boot. */
export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix);
/**
* Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in
* capabilities/totality.ts, which has to justify each one.
* Mounted above the account gate, and so exempt from permission checks — see EXEMPT_API_PREFIXES in
* permissions/totality.ts, which has to justify each one.
*/
export const UNPROTECTED_API_PREFIXES: string[] = [
'/auth',
@@ -191,7 +191,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
app.use(capabilityGateMiddleware);
app.use(permissionGateMiddleware);
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
app.route('/api/auth', authRouter);
@@ -226,7 +226,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
// so userMiddleware would 401 it and a permission lookup would have no account to resolve. The token
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
app.route('/api/agent-handoff', agentHandoffRouter);