capabilities: a registry, and a server that refuses to boot without one
permissions are capabilities, not routes. a capability is a feature — the
unit the owner grants, the dock filters on and the acl enforces — declaring
the api prefixes, websocket providers and screens it expands to.
four kinds. core is every account and is not grantable because it is not
deniable. app is the grantable surface. admin is the platform administering
itself. execution is never grantable at any level: terminal, chat, tasks,
files, desktop and browser all run as the owner's os user in the owner's
home, so granting one is co-ownership of the machine rather than a feature.
the part that matters is assertCapabilityTotality. the websocket hole fixed
in 2873948 was not a wrong rule — it was a door added without telling the
rule, because bun's route table matches /api/terminal/ws before the /api/*
catch-all that reaches hono's middleware. so the server now refuses to start
unless every mounted prefix and every user-facing socket maps to exactly one
capability. hono.ts mounts from a table and exports it, so the check reads
the real surface instead of a copy that can drift from it.
verified: passes against the live surface, and refuses all four ways — an
ungated router, an ungated socket, a claim on a deleted router, a claim on a
deleted socket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+64
-42
@@ -146,48 +146,70 @@ const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
protectedRouter.use(userMiddleware);
|
||||
|
||||
protectedRouter.route('/server-settings', serverSettingsRouter);
|
||||
protectedRouter.route('/users', usersRouter);
|
||||
protectedRouter.route('/plans', plansRouter);
|
||||
protectedRouter.route('/skills', skillsRouter);
|
||||
protectedRouter.route('/tasks', tasksRouter);
|
||||
protectedRouter.route('/agents', agentsRouter);
|
||||
protectedRouter.route('/tools', toolsRouter);
|
||||
protectedRouter.route('/processes', processesRouter);
|
||||
protectedRouter.route('/rescan', rescanRouter);
|
||||
protectedRouter.route('/scrape', scrapeRouter);
|
||||
protectedRouter.route('/upload', uploadRouter);
|
||||
protectedRouter.route('/user', settingsRouter);
|
||||
protectedRouter.route('/dashboards', dashboardsRouter);
|
||||
protectedRouter.route('/task-logs', taskLogsRouter);
|
||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/music', musicRouter);
|
||||
protectedRouter.route('/slskd', slskdRouter);
|
||||
protectedRouter.route('/terminal', terminalRouter);
|
||||
protectedRouter.route('/memos', memosRouter);
|
||||
protectedRouter.route('/gitea', giteaRouter);
|
||||
protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI
|
||||
protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level)
|
||||
protectedRouter.route('/notify', notifyRouter);
|
||||
protectedRouter.route('/headscale', headscaleRouter);
|
||||
protectedRouter.route('/transmission', transmissionRouter);
|
||||
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
||||
protectedRouter.route('/jellyfin', jellyfinRouter);
|
||||
protectedRouter.route('/photos', photosRouter);
|
||||
protectedRouter.route('/wallet', walletRouter);
|
||||
protectedRouter.route('/vpn', vpnRouter);
|
||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||
protectedRouter.route('/activity', activityRouter);
|
||||
protectedRouter.route('/dock', dockRouter);
|
||||
protectedRouter.route('/integrations', integrationsRouter);
|
||||
protectedRouter.route('/queue', queueRouter);
|
||||
protectedRouter.route('/email', emailRouter);
|
||||
protectedRouter.route('/browser', browserRouter);
|
||||
protectedRouter.route('/bug-report', bugReportRouter);
|
||||
protectedRouter.route('/chat', chatRouter);
|
||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||
protectedRouter.route('/desktop', desktopRouter);
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// Order is irrelevant here: every prefix is distinct, so hono's registration-order matching has nothing to
|
||||
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
|
||||
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
||||
['/server-settings', serverSettingsRouter],
|
||||
['/users', usersRouter],
|
||||
['/plans', plansRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
['/tools', toolsRouter],
|
||||
['/processes', processesRouter],
|
||||
['/rescan', rescanRouter],
|
||||
['/scrape', scrapeRouter],
|
||||
['/upload', uploadRouter],
|
||||
['/user', settingsRouter],
|
||||
['/dashboards', dashboardsRouter],
|
||||
['/task-logs', taskLogsRouter],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/music', musicRouter],
|
||||
['/slskd', slskdRouter],
|
||||
['/terminal', terminalRouter],
|
||||
['/memos', memosRouter],
|
||||
['/gitea', giteaRouter],
|
||||
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
|
||||
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
|
||||
['/notify', notifyRouter],
|
||||
['/headscale', headscaleRouter],
|
||||
['/transmission', transmissionRouter],
|
||||
['/invoiceshelf', invoiceshelfRouter],
|
||||
['/jellyfin', jellyfinRouter],
|
||||
['/photos', photosRouter],
|
||||
['/wallet', walletRouter],
|
||||
['/vpn', vpnRouter],
|
||||
['/system-monitor', systemMonitorRouter],
|
||||
['/activity', activityRouter],
|
||||
['/dock', dockRouter],
|
||||
['/integrations', integrationsRouter],
|
||||
['/queue', queueRouter],
|
||||
['/email', emailRouter],
|
||||
['/browser', browserRouter],
|
||||
['/bug-report', bugReportRouter],
|
||||
['/chat', chatRouter],
|
||||
['/pipeline-jobs', pipelineJobsRouter],
|
||||
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||
['/desktop', desktopRouter],
|
||||
];
|
||||
|
||||
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
|
||||
|
||||
/** Every prefix served behind the account gate. Read by the capability 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.
|
||||
*/
|
||||
export const UNPROTECTED_API_PREFIXES: string[] = ['/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'];
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user