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:
+11
-11
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user