Files
platform/src/server.tsx
T
pastilhasandClaude Opus 5 7d64b0a0d6 paste localhost:3000 into the preview and see the server's port
No proxy host, no certificate, no port opened. `/api/dev-proxy/5173/…` forwards to
`http://127.0.0.1:5173/…` over the platform's own origin, and the panel maps what
you type onto it.

── Why a proxy at all, when the ask was "just point at localhost" ──

Neither direct route works from a browser on https://<platform>.
`http://localhost:5173` resolves on the VIEWER's machine, not the server's, and
`http://<server>:5173` is mixed content inside an https page and is blocked before
it is even attempted. Both fail looking exactly like the dev server being down. The
bytes have to arrive over an origin the page already trusts, which is the platform.

A dev server can therefore stay bound to 127.0.0.1. The workflow this replaces —
one NPM proxy host per app — needs it on 0.0.0.0 plus a firewall rule for the
docker bridge, so this is both less setup and less exposure.

── What came back from `188b45c1^`, and what did not ──

The 46-line HTML/asset rewrite, which is the hard-won part: absolute paths in the
document, plus an injected script that intercepts fetch/XHR/WebSocket for the URLs
built at runtime. Its JWT injection is deleted — those apps called the PLATFORM's
API; a dev server proxied here talks to itself, and handing its process the owner's
bearer token would be giving credentials to whatever is on that port.

The other 314 lines are not back: spawning, port discovery, idle timers, log
collection, a proxyId registry. The port is the id, and the background-job button
already starts dev servers.

── HMR is bridged, because "see it" without "follow it" is half the feature ──

The injected script rewrites the dev server's socket under the prefix; the relay
dials `ws://127.0.0.1:<port>` and forwards frames both ways. Same shape as the vault
relay including the non-obvious part — state registered synchronously and frames
queued until the upstream opens, because Bun requires the upgrade to be synchronous
and an HMR client speaks immediately. An upstream close closes the client, which is
what makes a dev-server restart reconnect by itself.

── Access, which was not in the ask and is not optional ──

This route reaches any loopback port on the machine, on a host that answers from the
public internet. An iframe cannot carry a bearer token — it is a navigation — so the
panel puts it in the URL once, the proxy verifies it is the OWNER through
`resolveAuthToken`, and issues a short-lived cookie every sub-resource then carries.
Anonymous and non-owner requests get 401, verified. Owner-only rather than a
permission: reading an arbitrary loopback port is the `execution` power the registry
says can never be granted.

Both exemptions are registered with reasons, and the boot check earned its keep —
it refused to start until the socket provider was claimed too.

── Verified against a real loopback-only server ──

Confirmed unreachable from outside, then through the proxy: index served and
rewritten, `/_bun/` chunk resolved, JSON passed through untouched, 401 anonymous,
401 with a bad token, "nothing is listening" on a dead port. Websocket: refused
without the cookie, target parsed, frames relayed in both directions.

One bug found by testing rather than reading: the upstream path was derived by
slicing a hardcoded prefix, so every asset request returned the index page. It is
found from the `/<port>` segment now.

A test asserting `http://localhost:5173` is kept verbatim is now inverted, with the
reason written beside it — that was correct when the panel only framed real
hostnames.

tsgo clean, frontend builds, 890 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:59:20 +00:00

541 lines
27 KiB
TypeScript

import './servers/bootstrap';
import type { ServerWebSocket } from 'bun';
import { serve } from 'bun';
import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertPermissionTotality } from './servers/permissions/totality';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
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/permissions/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 { 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';
import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket';
// Build static file routes from public/
// A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file`
// handle opened now.
//
// `plugins/` is EXCLUDED, and that is not an optimisation. Everything under it is published and removed
// at runtime by the plugin installer, and this map is spread into the route table ahead of the
// `/plugins/*` wildcard — so an exact entry here WINS over the dynamic handler that knows how to say
// 404. A plugin whose icon existed at boot and was then uninstalled left a route pointing at a deleted
// file, which answered `500 ENOENT` on every dock render until the next restart, with the error in the
// log each time. Precisely what the wildcard's own comment says it exists to avoid.
//
// The add case was already known and is why the wildcard exists at all. This is its mirror: a snapshot
// cannot describe a directory that changes while the server runs, in either direction. So it does not
// try — `/plugins/*` owns that prefix alone.
const publicRoutes: Record<string, (req: Request) => Response> = {};
for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) {
if (file.startsWith('plugins/')) continue;
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' | '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,
'dev-proxy': devProxyWebsocket,
chat: chatWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
// desktop: desktopWebsocket,
// vault: vaultWebsocket,
sidecar: sidecarWebsocket,
};
// Both doors into the platform, checked against the permission 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.
assertPermissionTotality({
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());
// Mount whatever is installed, BEFORE serve().
//
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,
// because discovery reads the disk and the database and neither can be awaited at module scope. The
// install verbs each rebuild, so it looked correct in testing — right up until the first restart, which
// is exactly the kind of bug that ships.
//
// Before `serve()` rather than after, so there is no window in which an installed plugin's route 404s.
// Failure is logged and survived: a plugin that will not load must not stop the platform from starting,
// and `refreshPluginMounts` already isolates one bad plugin from the rest.
try {
// Generate first, then build, then mount. The generated module is what the bundler reads, so writing it
// after the build would produce a bundle describing the previous plugin set.
const { states } = await snapshotPlugins();
generatePluginsModule(states);
const built = await rebuildFrontend();
if (built.ok) console.log(`[plugins] frontend built in ${built.ms}ms (${built.outputs} outputs)`);
else console.error('[plugins] frontend build FAILED —', built.error);
// `skipFrontend`, because the build above already used the same snapshot. Rebuilding here would cost a
// second of start-up to produce a byte-identical bundle.
const { mounted, broken, rejected } = await refreshPluginMounts({ skipFrontend: true });
if (mounted.length) console.log(`[plugins] mounted ${mounted.join(', ')}`);
if (broken.length) console.error(`[plugins] unreadable: ${broken.join(', ')}`);
if (rejected.length) console.error(`[plugins] permission keys refused: ${rejected.join(', ')}`);
} catch (err) {
console.error('[plugins] none mounted —', err instanceof Error ? err.message : err);
}
// ── Serving the SPA ──
//
// In PRODUCTION the bundle comes from `build/`, rebuilt whenever a plugin is installed. That is what makes
// a plugin's frontend appear without restarting the process: the HTML import below is bundled once when
// the module graph loads and can never change afterwards.
//
// In DEVELOPMENT the HTML import is kept, because it is what gives HMR — and `bun --watch` restarts on
// every source change anyway, so nothing is gained by building. The two paths differ only in where the
// bytes come from; the generated `Plugins.gen.tsx` is the same file in both.
const serveBuilt = async (req: Request): Promise<Response> => {
const path = new URL(req.url).pathname;
// An asset if it exists, the shell otherwise. A client-side route like `/offscale/servers` is not a file
// and must return index.html, which is what makes deep links work at all.
// A request that LOOKS like an asset and is not one must 404, never fall through to the shell. Serving
// HTML with a JS content-type produces "Unexpected token '<'" and a blank page — a failure mode that
// reads as a broken build rather than a missing file.
//
// SINGLE-SEGMENT only, and that is load-bearing. The bundler emits everything to the root of `build/`
// — `chunk-<hash>.js`, `chunk-<hash>.css`, the shell — so a real build asset never has a second slash.
// Multi-segment assets (`public/**`, `/plugins/<app>/icon.png`) are served by Bun's `routes` table
// before this handler runs and never arrive here at all.
//
// Without the segment test, `/files/Tests/logo.png` — a client route naming a DIRECTORY that happens to
// end in `.png` — 404s instead of reaching the SPA. Rare, but it fails silently and looks like a broken
// deep link rather than a rule.
const isSingleSegment = path.indexOf('/', 1) === -1;
if (isSingleSegment && /\.(js|css|map|png|svg|ico|webmanifest|woff2?)$/.test(path)) {
const file = Bun.file(join(BUILD_DIR, path.slice(1)));
if (!(await file.exists())) return new Response('Not found', { status: 404 });
return new Response(file, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
}
const asset = Bun.file(join(BUILD_DIR, path === '/' ? SHELL_FILE : path.slice(1)));
if (path !== '/' && (await asset.exists())) {
// Content-hashed, so the name changes whenever the bytes do and this can be cached hard. The shell
// below is the opposite case and must not be.
return new Response(asset, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
}
const shell = Bun.file(join(BUILD_DIR, SHELL_FILE));
if (!(await shell.exists())) {
// The build failed or never ran. Say so rather than 404ing, because a blank page here looks like a
// broken deploy and this is recoverable by restarting or reinstalling the plugin that broke it.
return new Response('The interface has not been built. Check the server log for a build failure.', {
status: 503,
headers: { 'content-type': 'text/plain' },
});
}
// NEVER cache the shell.
//
// It names the hashed chunks, so a cached shell is a browser pinned to a build that no longer exists on
// disk — every asset it asks for 404s, or worse it renders an old app against a current API. Served with
// no cache headers at all until now, which leaves it to the browser's heuristics: a plugin could be
// installed, the bundle rebuilt, and the tab keep running the previous one with no way to tell.
return new Response(shell, {
headers: {
'content-type': 'text/html',
'cache-control': 'no-cache, no-store, must-revalidate',
},
});
};
const isProduction = process.env.NODE_ENV === 'production';
const spaRoutes = isProduction ? { '/': serveBuilt, '/*': serveBuilt } : { '/': officerWeb, '/*': officerWeb };
// Said out loud, because "is it serving the build I just made, or the one bundled at import?" is otherwise
// answerable only by hiding the shell and watching for a 503 — which is how it was answered once.
//
// It matters because the two behave differently in exactly the case that matters: the HTML import is
// bundled when the module graph loads and can never change, so installing a plugin would rebuild `build/`
// and serve something else entirely.
console.log(
isProduction
? `[web] serving the built SPA from ${BUILD_DIR} — a plugin install rebuilds it`
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
);
async function upgradeWs(
req: Request,
server: any,
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | '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 mobile app
// holding one gets the same doors as a browser session, and a socket that only understood JWTs would
// have made "signed in" and "can open this" 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 permission 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` permissions, 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 permission 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 permission 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);
// },
// A dev server's HMR socket, framed by the Preview panel. Must be in the route table rather than
// behind the `/api/*` catch-all: Bun matches this table first, and an upgrade that reaches Hono is
// an ordinary GET by then.
'/api/dev-proxy/*': (req: Request, server: any) => {
if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(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/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,
...spaRoutes,
'/api': honoServer.fetch,
// A CLOSURE, deliberately, and not the bound `honoServer.fetch`.
//
// Installing a plugin swaps the whole Hono app (`rebuildHonoApp` — Hono cannot add routes to a live
// app, and cannot remove one at all). The bound method would capture whichever app existed when
// `serve()` ran, so every rebuild after boot would be invisible and an install would silently do
// nothing. Reading `honoServer` per request is what makes the reassignment the swap.
'/api/*': (req: Request, server: unknown) => honoServer.fetch(req, server),
},
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. Every trace of cliamp left on 2026-08-15 — the relay, the two socket routes and
// the provider names — and now lives in `plugins/music/cliamp/`, parked rather than working. Bringing it
// back means a plugin owning a websocket, which is a platform gap, not a music one.
// Pi check/install is handled by bootstrap.ts (imported above)