Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one port it announced, exactly as the pty sidecar already does; createSidecarProxy handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens inside the plugin, which is the only process that knows. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
634 lines
32 KiB
TypeScript
634 lines
32 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';
|
|
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();
|
|
|
|
// 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',
|
|
);
|
|
|
|
/**
|
|
* 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))) {
|
|
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)
|