Files
platform/src/server.tsx
T
pastilhasandClaude Opus 5 085afe7604 wait for Postgres instead of failing startup work once
The failure here was not a crash — it was the opposite, and that is why it would
never have been noticed.

server.tsx fired initQueue() and cleanupOnStartup() as bare promises with a
.catch() that logged. postgres-js connects lazily, so nothing fails at import;
the first query does. If Postgres is a few seconds behind — exactly what a reboot
looks like, with pm2's resurrect racing Docker starting the container — both log
one line during boot and do nothing else.

cleanupOnStartup is the one that matters. It marks jobs interrupted by the
previous shutdown and promotes the queued backlog, so failing it once leaves
those jobs marked running forever: nothing retries, nothing complains again, and
the only thing that would have corrected them has already run.

officerdb now exports waitForDatabase(timeoutMs = 60s): polls `select 1`, logs
once while waiting, resolves true or false rather than throwing. Bounded on
purpose — an unbounded wait holds a process open with no way to tell starting
from hung, and the caller decides what giving up means.

Deliberately NOT awaited before serve(). The listener is already up by that point
and holding it closed would turn a database thirty seconds late into a reverse
proxy answering connection-refused instead of a page. Requests needing the
database fail honestly in the meantime.

The rest of the estate was already fine, which is worth recording so nobody
"fixes" it again: postgres() opens no socket at construction, officer-agent's
resolveOwner is an unbounded 5s retry loop written after this exact failure cost
a session, and opencode, pty, headscale and the anthropic proxy touch no database
at boot at all.

officer-setup's Services section also waits for pg_isready before starting pm2.
Not because starting early breaks anything, but because Verify would then report
a failure that is really a race — and a red line that is usually noise is a red
line people stop reading.

Verified waitForDatabase against a dead port: logged once, returned false after
the timeout, did not throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:38:36 +00:00

416 lines
19 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 { assertInstallLayout } from './servers/data-path';
import { PORT } from './servers/officer-url.mjs';
import { assertSecretsClosed } from './servers/os-user';
import { resolveHomeDir } from './servers/user-home';
import { resolveAuthToken } from './servers/auth-token';
import { isWsProviderAllowed } from './servers/capabilities/authorize';
import { isTokenBlacklisted, getUserById } 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'; // switched off — see below
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';
// 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),
});
// That the working directory is the repo, because every path derives from its parent. First of the three,
// since a wrong answer here makes the other two check the wrong files.
assertInstallLayout();
// And, when members have real Linux accounts, that they cannot read the credentials that would make those
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
// JWT_SECRET is worse than no isolation, because the model looks intact.
await assertSecretsClosed(process.cwd());
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 {
// Same resolver as the two HTTP doors, so a key that works against /api works here too — a music app
// holding one needs cliamp and cliamp-audio, and a socket that only understood JWTs would have made
// "signed in" and "can play audio" two different questions. `jti` is absent on a key, so the
// blacklist below simply does not apply to one; its revocation is a column, checked in the lookup.
const user = await resolveAuthToken(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;
// ── Whose shell is this ──
//
// The terminal bridge forwards this query string to the pty sidecar untouched, and the sidecar starts a
// shell from what it finds there. So `osUser` and `home` are resolved HERE, from the authenticated
// account, and any values the browser sent are deleted first. Trusting the client for either would let a
// member ask for the owner's uid in a query parameter.
//
// Absent for the owner: no `osUser` means the sidecar runs the shell as itself, which is the behaviour
// this has always had.
url.searchParams.delete('osUser');
url.searchParams.delete('home');
// The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same
// commit — they were always one guard in two places. A member's turn now runs as their own Linux account
// with their own credential and their own transcripts; the capability check above is what gates it.
if (provider === 'terminal') {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) return new Response('Forbidden', { status: 403 });
if (!resolved.isOwner) {
const dbUser = await getUserById(user.id);
// A confined capability is only granted to an account with an OS user, so this should not happen —
// and if it ever does, refusing beats opening the owner's shell.
if (!dbUser?.osUser) return new Response('Forbidden', { status: 403 });
url.searchParams.set('osUser', dbUser.osUser);
url.searchParams.set('home', resolved.home);
}
}
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: 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);
},
// Icons and assets belonging to installed sidecars, copied to public/plugins/<id>/ by the installer.
//
// Served by this dynamic route rather than by `publicRoutes` above, which is a snapshot taken by
// globbing ./public at BOOT. A plugin installed while the server is running would not be in that map,
// so its icon would 404 until the next restart — and "install it, then restart the server to see the
// icon" is not an install.
'/plugins/*': async (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
// 404 rather than letting a missing file surface as a 500. An icon that has not been published
// yet is an ordinary state — the plugin is not installed — and a 500 would put a red line in the
// log for every dock render on a fresh machine.
if (!(await file.exists())) return new Response('Not found', { status: 404 });
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,
// Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own
// entry for the same reason /dav does: only the paths listed here reach hono, and anything else
// falls through to the SPA — which answers 200 with the React shell, so a missing line here looks
// like a working endpoint returning nonsense rather than a 404.
// '/vaultwarden/*': honoServer.fetch,
// The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes
// are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing.
// `/api/*` already reaches hono below, where a Bitwarden client header diverts it.
// '/identity/*': honoServer.fetch,
// '/notifications/*': honoServer.fetch,
// '/icons/*': honoServer.fetch,
// '/events/*': 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}`);
// ── The browser relay is not started. Deliberate, 2026-08-13. ──
//
// The Chrome extension, its CDP bridge (api/browser/) and the /browser screen all remain on disk: this
// is going to be extracted into a plugin, and deleting it would mean writing it again. What is switched
// off is the second listener and the /api/browser mount (see hono.ts) — the platform serves one port.
//
// It used to read BROWSER_RELAY_PORT, default 18792. The name is recorded here because there is nothing
// left to grep for, and whoever does the extraction needs to know what it was called.
//
// ── Read this before turning it back on ──
//
// The port is not only configuration. relay-auth.ts derives each extension's token as
// HMAC(JWT_SECRET, `officer-browser-relay-v1:${port}:${userId}:${salt}`), so the port is an INPUT TO A
// CREDENTIAL. Bringing the relay back on a different number silently invalidates every paired browser,
// and the extension reports it as "Relay not reachable" — which SETUP.md attributes to a wrong address,
// port or token. Re-pairing means re-copying from Settings → Browser Relay.
//
// Whatever it comes back as, it cannot be a kernel-assigned port:0 the way the other sidecars are. The
// extension is configured by hand and stores the value, so a port that changes each restart breaks the
// pairing every restart. It has to be predictable — PORT + 2 is the shape the Anthropic proxy already
// uses for the same reason.
//
// 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';
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
import { waitForDatabase } from 'officerdb';
// ── Startup work that needs the database, and waits for it ──
//
// Both of these query Postgres, and both used to be fired as bare promises with a `.catch()` that
// logged. That is fine when the database is up and silently wrong when it is not — which is precisely
// what a reboot looks like, with pm2's resurrect racing Docker starting the Postgres container.
//
// `cleanupOnStartup` is the one that matters: it marks jobs interrupted by the previous shutdown and
// promotes the queued backlog. Fail it once and those jobs stay marked running forever, because the
// only thing that would have corrected them has already run. Nothing retries and nothing complains
// again — the log line scrolls past during boot and the jobs are simply stuck.
//
// Deliberately NOT awaited before serve(): the HTTP listener is already up by here, and holding it
// closed for a minute would turn a database that is thirty seconds late into a reverse proxy serving
// connection-refused instead of a page. Requests that need the database fail honestly in the meantime.
void (async () => {
if (!(await waitForDatabase())) {
console.error('[startup] skipping queue init and pipeline cleanup — the database never answered');
return;
}
await initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
await 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)