Files
platform/src/server.tsx
T
pastilhasandClaude Opus 5 e44c0c7665 the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two
panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history,
and nothing regressed, because none of it had been running.

What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches
a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the
browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so
the registry stops describing one thing in the other's words, and it drops the /browser frontend route
it could no longer serve.

Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering
panels through appType placeholders — a plugin supplies its own route and panels through
Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover
from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no
longer had: dead at runtime, invisible to the compiler.

The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where
it lives now. It was written for whoever did this extraction, and this is that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:05:11 +00:00

569 lines
29 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 { bootstrapCaldav } from './servers/caldav-bootstrap';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { listPluginRootClaims } from './servers/plugins/public-paths';
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, secureRuntimeSecrets } 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 { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
import { startNudger } from './servers/api/chat/nudger';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
import officerWeb from './apps/officer-web/index.gen.html';
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
import { sendProvisionableUsers } from './servers/plugins/user-provisioning';
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' | 'chat-notify' | 'task-runner' | 'pipeline' | '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 registration = msg as SidecarRegistration;
const id = registerSidecar(ws, registration);
sidecarConnections.set(ws, id);
ws.send(JSON.stringify({ type: 'registered', id }));
// The account list, for a plugin whose MANIFEST declares `userProvisioning`.
//
// Read from the manifest and never from this message: `name` and `handles` are self-declared and
// believed, so a capability asserted here would be a plugin granting itself one. The manifest was
// reviewed when the owner installed it.
//
// After the ack, not inside it — registration must not wait on a database query. A slow or
// unreachable Postgres would otherwise stall every sidecar's startup, and none of them need the
// list to start.
void sendProvisionableUsers(ws, registration.name);
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,
'chat-notify': chatNotifyWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
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),
});
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
// module whose only job is a side effect is one an import sweep quietly deletes.
registerChatPushNotifications();
// Nudger's tick. Here rather than on first use, because a schedule armed before a restart has to survive
// one — the row is in Postgres and nothing else would ever look at it again.
startNudger();
// 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());
// Then close the ones the platform writes ITSELF, which is a repair rather than a refusal — see
// `secureRuntimeSecrets`. It runs on every boot because `officer-anthropic-proxy` is a separate PM2
// process that can recreate its state file at the machine's umask hours after this one started, and
// because the file it protects is the owner's Claude Max subscription in string form.
const secured = await secureRuntimeSecrets();
if (secured.repaired.length) {
console.warn(
`[boot] closed ${secured.repaired.length} world-readable platform secret(s): ${secured.repaired.join(', ')}\n` +
' If this names sidecar/claude-state.json, the proxy secret was readable by every account on\n' +
' this machine and should be ROTATED, not just closed — the mode is fixed, the string is not.',
);
}
for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`);
// 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' | 'chat-notify' | 'task-runner' | 'pipeline',
) {
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);
},
// 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/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
// 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,
/*
Top-level paths a plugin claims (`/identity/*`, …), handed to hono BEFORE the SPA fallback.
They need naming here for the same reason `/dav` did: Bun matches this table first, and only the
paths listed reach hono — anything else falls through to `/*`, which answers 200 with the React
shell. A missing entry therefore looks like a working endpoint returning nonsense rather than a 404,
which is among the least debuggable failures available.
Built from the plugin set because `refreshPluginMounts` has already run by here (line ~204, well
before `serve()`), so the claims are known. The cost is that adding or removing one needs a restart —
this table is fixed once `serve()` is called. Stated rather than worked around: installing a plugin
that claims a root path is the one install that is not complete until `pm2 restart officer`.
*/
...Object.fromEntries(
listPluginRootClaims().flatMap(({ segment }) => [
[`/${segment}`, (req: Request, server: unknown) => honoServer.fetch(req, server)],
[`/${segment}/*`, (req: Request, server: unknown) => honoServer.fetch(req, server)],
]),
),
...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
// The browser relay left for `plugins/browser` on 2026-08-21. It ran a SECOND listener from this process,
// which is why it was switched off here on 2026-08-13 — the platform serves one port. As a plugin it is a
// sidecar, which is a process allowed its own listeners, so the objection no longer applies.
//
// The port and everything that depended on it moved with it, including the reason it cannot be
// kernel-assigned: the extension stores the number by hand AND the number is an input to the token HMAC.
// See plugins/browser/sidecar/protocol.ts.
// 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.
// Calendar and contacts became CORE on 2026-08-19, having been an app-store entry since 2026-08-13.
// A fresh install gets radicale and the officer-caldav entry from setup; an EXISTING one gets neither
// from a `git pull`, so it would come up with /calendar mounted and nothing behind it. Best-effort,
// after serve(), never blocking: a held apt lock or a machine without passwordless sudo must not stop
// Officer from starting, and when it cannot install it says what to run instead.
void bootstrapCaldav();
// Pi check/install is handled by bootstrap.ts (imported above)