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>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import './servers/bootstrap';
|
|
import type { ServerWebSocket } from 'bun';
|
|
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 { isWsProviderAllowed } from './servers/capabilities/authorize';
|
|
import { isTokenBlacklisted } from 'officerdb';
|
|
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
|
import { chatWebsocket } from './servers/api/chat/websocket';
|
|
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
|
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
|
import { cliampWebsocket, cliampAudioWebsocket } from './servers/api/cliamp/relay';
|
|
import { desktopWebsocket } from './servers/api/desktop/websocket';
|
|
import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket';
|
|
import officerWeb from './apps/officer-web/index.gen.html';
|
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
|
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
|
|
import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report
|
|
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
|
import { toShellUsername } from './servers/data-path';
|
|
|
|
const { PORT = '5000' } = process.env;
|
|
|
|
// Build static file routes from public/
|
|
const publicRoutes: Record<string, (req: Request) => Response> = {};
|
|
for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) {
|
|
const bunFile = Bun.file(`./public/${file}`);
|
|
publicRoutes[`/${file}`] = () => new Response(bunFile);
|
|
}
|
|
|
|
type WSData = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
provider:
|
|
| 'terminal'
|
|
| 'chat'
|
|
| 'task-runner'
|
|
| 'pipeline'
|
|
| 'cliamp'
|
|
| 'cliamp-audio'
|
|
| 'desktop'
|
|
| 'vault'
|
|
| 'sidecar';
|
|
sessionId?: string;
|
|
cwd?: string;
|
|
command?: string;
|
|
cols?: number;
|
|
rows?: number;
|
|
search?: string; // raw query string, for providers that relay it to a sidecar
|
|
};
|
|
|
|
// Sidecar registration WebSocket handler
|
|
const sidecarConnections = new Map<ServerWebSocket<WSData>, string>(); // ws → sidecar ID
|
|
|
|
const sidecarWebsocket = {
|
|
open(_ws: ServerWebSocket<WSData>) {
|
|
// Wait for registration message
|
|
},
|
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
|
try {
|
|
const data = typeof raw === 'string' ? raw : raw.toString();
|
|
const msg = JSON.parse(data);
|
|
|
|
if (msg.type === 'register') {
|
|
const id = registerSidecar(ws, msg as SidecarRegistration);
|
|
sidecarConnections.set(ws, id);
|
|
ws.send(JSON.stringify({ type: 'registered', id }));
|
|
return;
|
|
}
|
|
|
|
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
|
|
if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) {
|
|
handleSidecarQueueCommand(ws, msg);
|
|
return;
|
|
}
|
|
|
|
const id = sidecarConnections.get(ws);
|
|
if (id) {
|
|
handleSidecarMessage(id, msg);
|
|
}
|
|
} catch {
|
|
// skip malformed messages
|
|
}
|
|
},
|
|
close(ws: ServerWebSocket<WSData>) {
|
|
const id = sidecarConnections.get(ws);
|
|
if (id) {
|
|
unregisterSidecar(id);
|
|
sidecarConnections.delete(ws);
|
|
}
|
|
},
|
|
drain() {},
|
|
};
|
|
|
|
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
|
|
async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Record<string, unknown>) {
|
|
const id = msg.id as string;
|
|
try {
|
|
switch (msg.type) {
|
|
case 'queue:enqueue': {
|
|
const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams);
|
|
ws.send(JSON.stringify({ type: 'queue:enqueued', id, job }));
|
|
break;
|
|
}
|
|
case 'queue:cancel': {
|
|
const job = await queueCancel(msg.jobId as string);
|
|
ws.send(JSON.stringify({ type: 'queue:cancelled', id, job }));
|
|
break;
|
|
}
|
|
case 'queue:list': {
|
|
const jobs = await queueList();
|
|
ws.send(JSON.stringify({ type: 'queue:list', id, jobs }));
|
|
break;
|
|
}
|
|
case 'queue:get': {
|
|
const job = await queueGet(msg.jobId as string);
|
|
ws.send(JSON.stringify({ type: 'queue:get', id, job }));
|
|
break;
|
|
}
|
|
default:
|
|
ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` }));
|
|
}
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) }));
|
|
}
|
|
}
|
|
|
|
const handlers: Record<string, any> = {
|
|
terminal: terminalWebsocket,
|
|
chat: chatWebsocket,
|
|
'task-runner': taskRunnerWebsocket,
|
|
pipeline: pipelineWebsocket,
|
|
cliamp: cliampWebsocket,
|
|
'cliamp-audio': cliampAudioWebsocket,
|
|
desktop: desktopWebsocket,
|
|
vault: vaultWebsocket,
|
|
sidecar: sidecarWebsocket,
|
|
};
|
|
|
|
// Both doors into the platform, checked against the capability registry before either opens.
|
|
//
|
|
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
|
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
|
// from the map directly above, so neither list can be a stale copy of the thing it describes.
|
|
assertCapabilityTotality({
|
|
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
|
wsProviders: Object.keys(handlers),
|
|
});
|
|
|
|
async function upgradeWs(
|
|
req: Request,
|
|
server: any,
|
|
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop',
|
|
) {
|
|
const token = new URL(req.url).searchParams.get('token');
|
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
|
|
|
try {
|
|
const user = await verify(token);
|
|
if (!user) return new Response('Unauthorized', { status: 401 });
|
|
|
|
if (user.jti) {
|
|
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
const cwd = url.searchParams.get('cwd') ?? undefined;
|
|
const command = url.searchParams.get('command') ?? undefined;
|
|
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
|
|
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
|
const ok = server.upgrade(req, {
|
|
data: {
|
|
userId: user.id,
|
|
email: user.email,
|
|
username: toShellUsername(user.username ?? '', user.email),
|
|
provider,
|
|
sessionId,
|
|
cwd,
|
|
command,
|
|
cols,
|
|
rows,
|
|
search: url.search,
|
|
},
|
|
});
|
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
|
} catch {
|
|
return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
}
|
|
|
|
const server = serve({
|
|
port: Number(PORT),
|
|
idleTimeout: 60,
|
|
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
|
|
routes: {
|
|
...publicRoutes,
|
|
'/novnc/*': (req) => {
|
|
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
|
return new Response(file);
|
|
},
|
|
'/vendor/*': (req) => {
|
|
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
|
return new Response(file);
|
|
},
|
|
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
|
|
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
|
|
'/api/vault/notifications/*': (req, server) => {
|
|
if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server);
|
|
return honoServer.fetch(req, server);
|
|
},
|
|
'/api/sidecar/register': (req: Request, server: any) => {
|
|
const ok = server.upgrade(req, {
|
|
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
|
});
|
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
|
},
|
|
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
|
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
|
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
|
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
|
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
|
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
|
|
'/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
|
|
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
|
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
|
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
|
|
// and the phone gets an HTML page where it expected a redirect.
|
|
'/.well-known/caldav': honoServer.fetch,
|
|
'/.well-known/carddav': honoServer.fetch,
|
|
'/dav': honoServer.fetch,
|
|
'/dav/*': honoServer.fetch,
|
|
'/': officerWeb,
|
|
'/*': officerWeb,
|
|
'/api': honoServer.fetch,
|
|
'/api/*': honoServer.fetch,
|
|
},
|
|
|
|
websocket: {
|
|
open(ws) {
|
|
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
|
handlers[provider]!.open(ws as any);
|
|
},
|
|
message(ws, raw) {
|
|
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
|
handlers[provider]!.message(ws as any, raw);
|
|
},
|
|
close(ws) {
|
|
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
|
handlers[provider]!.close(ws as any);
|
|
},
|
|
drain() {},
|
|
},
|
|
|
|
development: process.env.NODE_ENV !== 'production' && {
|
|
hmr: true,
|
|
},
|
|
});
|
|
|
|
console.log(`🚀 Server running at ${server.url}`);
|
|
|
|
const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792');
|
|
try {
|
|
await startBrowserRelay(BROWSER_RELAY_PORT);
|
|
console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
|
|
} catch (err) {
|
|
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
|
}
|
|
|
|
// Initialize queue engine in API server process
|
|
import {
|
|
initQueue,
|
|
enqueueJob as queueEnqueue,
|
|
cancelJob as queueCancel,
|
|
listAllJobs as queueList,
|
|
readJob as queueGet,
|
|
} from './servers/queue/init';
|
|
initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
|
|
|
|
// Mark any orphaned pipeline jobs from previous server run
|
|
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
|
|
cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err));
|
|
|
|
// PulseAudio and the `virtual_out` sink used to be set up here, at every boot of a process that has no
|
|
// audio responsibilities. They belong to the music sidecar, which owns both cliamp halves now
|
|
// (sidecar/music/pulse-audio.ts).
|
|
|
|
// Pi check/install is handled by bootstrap.ts (imported above)
|