a closed, named list of events — `files:uploaded`, `archive:extracted`,
`files:placed` — each carrying absolute paths and an owner. NOT an event bus in the
open sense: `emit(anyString, anything)` becomes an API nobody can change, because
the moment a plugin listens for a string that string is a contract with no list
saying so. a union means adding one is a deliberate edit and removing one fails to
compile.
TWO GUARANTEES, AND THEY ARE THE WHOLE DESIGN. emit never blocks: it schedules and
returns, so an upload does not wait for a subscriber thumbnailing a HEIC. and a
subscriber cannot fail the operation that triggered it — the upload is written and
answered before anybody is told, so a plugin that is down, slow or throwing gets a
line in the log. tested both ways, sync and async, plus that one failing listener
does not stop the next.
PLUGINS SUBSCRIBE THROUGH THE MANIFEST, same shape as `fileTypes`: `hooks: {
'files:uploaded': '/v1/on-upload' }`. resolved at EMIT time rather than at boot,
because plugins are installed, updated and disabled while the server runs and a
registration made at startup describes a machine that has since changed. nothing
here names a plugin.
CORE IS THE FIRST SUBSCRIBER, and that is deliberate rather than a shortcut. the
thumbnail cache belongs to the platform — its key, its route — so a plugin doing
this work would produce files the browser never looks at. the warmer calls the same
`buildThumbnail` the request path calls, which means HEIC still warms through
av-club via the fileTypes claim, without this file knowing that plugin exists.
AND A SINGLE-FLIGHT MAP, which the warmer made necessary. two callers now want the
same thumbnail as a matter of course: the warmer starts on upload and the browser
asks a moment later. measured before the fix — four concurrent requests plus the
warmer each decoded the same 4032x3024 HEIC and took a slot from a limit of four,
which is a warmer competing with the person waiting rather than helping them. after:
all five share one build, 5.5s once, then 130ms.
verified end to end: upload returns in 56ms, four concurrent requests all 200 in a
single 5.5s build, cached 130ms. 1679 tests, docs:check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
651 lines
33 KiB
TypeScript
651 lines
33 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 { registerThumbnailWarmer } from './servers/events/warm-thumbnails';
|
|
import { registerPluginHooks } from './servers/events/plugin-hooks';
|
|
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 { startWatcherRevive } from './servers/api/command-hq/watcher-revive';
|
|
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';
|
|
import { pluginWsWebsocket, upgradePluginWs } from './servers/api/plugin-ws/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,
|
|
'plugin-ws': pluginWsWebsocket,
|
|
chat: chatWebsocket,
|
|
'chat-notify': chatNotifyWebsocket,
|
|
'task-runner': taskRunnerWebsocket,
|
|
pipeline: pipelineWebsocket,
|
|
sidecar: sidecarWebsocket,
|
|
};
|
|
|
|
/**
|
|
* Every websocket path Bun serves, and the provider each one upgrades to.
|
|
*
|
|
* ── Why this exists rather than a hand-written route table ──
|
|
*
|
|
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
|
|
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
|
|
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
|
|
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
|
|
*
|
|
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
|
|
* days after their handlers and registry entries were commented out — not exploitable, since
|
|
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
|
|
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
|
|
* shape.
|
|
*
|
|
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
|
|
* handler or without a registry claim fails the boot rather than serving quietly.
|
|
*/
|
|
const WS_ROUTES: Record<string, UpgradableProvider> = {
|
|
'/api/tasks/run/ws': 'task-runner',
|
|
'/api/tasks/pipeline/ws': 'pipeline',
|
|
'/api/terminal/ws': 'terminal',
|
|
'/api/chat/ws': 'chat',
|
|
'/api/chat/notify/ws': 'chat-notify',
|
|
};
|
|
|
|
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
|
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
|
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
|
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy', 'plugin-ws'])];
|
|
|
|
/*
|
|
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. The socket half now comes from
|
|
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
|
|
parallel list that agrees with it by hand.
|
|
|
|
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
|
|
failure this catches is the other one — a route reachable with nothing claiming it.
|
|
*/
|
|
assertPermissionTotality({
|
|
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
|
wsProviders: SERVED_WS_PROVIDERS,
|
|
});
|
|
|
|
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
|
|
// into a refusal to boot with the name in it.
|
|
for (const [path, provider] of Object.entries(WS_ROUTES)) {
|
|
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
|
|
}
|
|
|
|
// 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();
|
|
startWatcherRevive();
|
|
|
|
// 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`);
|
|
|
|
/*
|
|
Subscribe to platform events before anything can emit one.
|
|
|
|
Both are pure registration — no I/O, nothing that can fail — so they sit above the plugin mount rather
|
|
than inside its try. If either threw, the server not starting would be the correct outcome, and
|
|
wrapping them in the plugin block's `catch` would hide that.
|
|
|
|
The thumbnail warmer is core subscribing to core: the cache belongs to the platform, so the work has to
|
|
happen here. Plugin hooks are the general mechanism, resolved per event from whatever is installed.
|
|
*/
|
|
registerThumbnailWarmer();
|
|
registerPluginHooks();
|
|
|
|
// 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',
|
|
);
|
|
|
|
/**
|
|
* A provider reachable by upgrading a URL.
|
|
*
|
|
* Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
|
|
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
|
* deliberately absent — they upgrade through their own handlers, not through here.
|
|
*/
|
|
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
|
|
|
|
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
|
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, user.scope ?? null))) {
|
|
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);
|
|
},
|
|
/*
|
|
Every plugin's socket, through one wildcard declared at boot.
|
|
|
|
This is the route that made a plugin websocket possible at all. `WS_ROUTES` is read once before
|
|
`serve()`, so nothing installed later can add an entry to it — but a wildcard does not need to,
|
|
because the plugin name is a path segment rather than a route. The same trick `dev-proxy` above has
|
|
been using all along; it simply had no second consumer until now.
|
|
*/
|
|
'/api/plugin-ws/*': (req: Request, server: any) => {
|
|
if (req.headers.get('upgrade') === 'websocket') return upgradePluginWs(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 });
|
|
},
|
|
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
|
|
...Object.fromEntries(
|
|
Object.entries(WS_ROUTES).map(([path, provider]) => [
|
|
path,
|
|
(req: Request, server: any) => upgradeWs(req, server, provider),
|
|
]),
|
|
),
|
|
// 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)
|