capabilities: both doors now read the same declaration

replaces the account backstop. it was two hand-written lists — NON_OWNER_PATHS
confining every non-owner to /api/auth + /api/music, and NON_OWNER_WS_PROVIDERS
doing the same for sockets. they were not wrong, they were unscalable in one
specific way: an allow-list answers "which paths" but never "why", so onboarding
anyone who needed anything other than music meant editing an array in a
middleware file and hoping the socket half got edited too.

now both doors resolve against the registry, so they cannot disagree about what
a role holds. terminal, chat, task-runner, pipeline and desktop are refused by
being `execution` capabilities rather than by being absent from a list somebody
maintains.

fail-closed everywhere: an unknown capability key, a missing row, a database
error or a deleted user all deny. the grant cache is keyed on role and has an
explicit invalidation contract — unlike the one super-admin.ts refuses to have,
this one has exactly one writer and it lives beside the reader.

seeded Member → music at WRITE, which is precisely what the old path-based
backstop allowed. granting `read` would have been a silent downgrade that broke
playlists for the three live member accounts overnight.

verified against the live database and real accounts: 27 http/socket cases, the
read/write split (personal sub-paths writable at read, /music/scan not), cache
invalidation after a revoke, and the borrowed test account's role restored.
20 new unit tests; full suite 362 pass 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:55:12 +00:00
co-authored by Claude Opus 5
parent c57fefa75d
commit 8e9d53b2d2
5 changed files with 344 additions and 35 deletions
+10 -7
View File
@@ -4,8 +4,7 @@ import { serve } from 'bun';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertCapabilityTotality } from './servers/capabilities/totality';
import { verify } from './servers/jwt';
import { isSuperAdmin } from './servers/super-admin';
import { isWsProviderAllowedForNonOwner } from './servers/_middlewares';
import { isWsProviderAllowed } from './servers/capabilities/authorize';
import { isTokenBlacklisted } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
@@ -166,11 +165,15 @@ async function upgradeWs(
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
}
// The account backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves who
// is calling and never asks what they may reach. That is why a Member with a valid token could open
// a terminal here in the same minute it was 403'd on GET /api/tasks. Same rule as
// originScopeMiddleware, deliberately declared in that same file so the two cannot drift apart.
if (!(await isSuperAdmin(user)) && !isWsProviderAllowedForNonOwner(provider)) {
// The capability backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves
// who is calling and never asks what they may reach. That is why a Member with a valid token could
// open a terminal here in the same minute it was 403'd on GET /api/tasks.
//
// This resolves against the same registry as the HTTP door rather than a parallel list, which is the
// whole point: the two doors cannot disagree about what a role holds, because there is only one
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
// by being `execution` capabilities, not by being absent from an array someone has to maintain.
if (!(await isWsProviderAllowed(user.id, provider))) {
return new Response('Forbidden', { status: 403 });
}