Files
platform/src/servers/hono.ts
T
pastilhasandClaude Opus 5 09518e1e1f the three files fb1cf0f6 was supposed to carry
fb1cf0f6 deleted the todo router, the board and the screen but did NOT remove the
imports of them. so a checkout of that commit has hono.ts importing
`./api/todos/router`, which is not there — and the server cannot start.

fofos hit it within a minute of pulling. it was down until this commit.

WHY IT PASSED EVERY CHECK HERE: the files were modified in my working tree and
never staged, so this machine typechecked, tested and ran against the fix while the
commit shipped without it. every green light I reported was true of the tree in
front of me and false of the thing I pushed.

HOW IT HAPPENED, precisely, because the shape is worth knowing: the `git add` naming
these three also named a path that no longer existed. `git add` ABORTS ON AN
UNMATCHED PATHSPEC — it does not stage the rest and warn. It staged nothing. the
`git status` I printed straight afterwards showed them as ` M`, unstaged, and I read
the output as confirmation rather than as the warning it was.

the lesson is not "look harder at git status". it is that a commit which deletes a
module and a commit which stops importing it must be the same commit, and the only
thing that proves it is a check against the COMMIT rather than the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:19:00 +01:00

314 lines
16 KiB
TypeScript

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createRouter } from './create-router';
import type { HonoVariables } from './create-router';
import { authRouter } from './api/auth';
import { serverSettingsRouter } from './api/server-settings/server-settings';
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
import { themesRouter } from './api/themes/themes';
import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { rolesRouter } from './api/roles/router';
import { apiKeysRouter } from './api/api-keys/router';
import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks';
import { agentsRouter } from './api/agents/agents';
import { toolsRouter } from './api/tools/tools';
import { processesRouter } from './api/processes/processes';
import { rescanRouter } from './api/items/rescan';
import { scrapeRouter } from './api/scrape/scrape';
import { uploadRouter } from './api/upload/upload';
import { settingsRouter } from './api/settings/settings';
import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { devProxyRouter } from './api/dev-proxy/router';
import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders, pluginForRootPath } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { sendoffRouter } from './api/sendoff/router';
import { subAgentsRouter } from './api/sub-agents/router';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
import { notifyRouter } from './api/notify/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router';
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
import { queueRouter } from './api/queue/queue';
// The browser relay is switched off — see server.tsx. Restoring this mount means restoring the
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
import { bugReportRouter } from './api/bug-report/bug-report';
import { agentStatusRouter } from './api/agent-status/router';
import { claudeLoginRouter } from './api/auth/claude-login';
import { chatRouter } from './api/chat/chat';
import { commandHqRouter } from './api/command-hq/router';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares';
export { Hono };
export { createRouter };
export type { HonoVariables };
/**
* A plugin's router and where it mounts — a plain pair, so this file needs no plugin knowledge at all.
* Built by `plugins/mount.ts`, which is the side that knows what a manifest is.
*/
export type MountedPlugin = {
prefix: string;
router: ReturnType<typeof createRouter>;
/**
* An OPTIONAL second router served above the account gate — at declared top-level paths, and for
* `/api/*` requests carrying a declared protocol header. Absent for every plugin that does not have a
* foreign protocol to host; see `unauthenticatedRootPaths` in plugins/manifest.ts.
*/
publicRouter?: ReturnType<typeof createRouter>;
/** Top-level segments this plugin claims, already de-duplicated against other plugins. */
rootSegments: string[];
/** Headers that hand an `/api/*` request to `publicRouter` instead of to Officer. */
protocolHeaders: string[];
};
// ── The app is BUILT, not assembled once ──
//
// It used to be a module-level `new Hono()` with forty statements run at import. That cannot express
// installing a plugin: Hono's default SmartRouter throws `Can not add a route since the matcher is
// already built` the moment a route is added after serving begins, and Hono has no API to REMOVE a route
// at all — so uninstall was impossible even with a router that allowed adding.
//
// So nothing is added to a live app. A fresh one is built from the current plugin set and swapped in:
//
// honoServer = buildHonoApp(plugins) // install, uninstall, enable, disable — all the same call
//
// `server.tsx` serves it through a CLOSURE (`(req, server) => honoServer.fetch(req, server)`), not the
// bound `honoServer.fetch`, so the reassignment above IS the swap. Verified end to end: a route 404s
// before install, 200s after, and 404s again after uninstall, with core routes untouched throughout.
//
// Two things this buys over adding routes to a live app: the default SmartRouter is kept, so the fast
// RegExpRouter path survives — and uninstall is expressible, which an add-only API cannot do.
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
// not a loosening: the check it replaced defaulted to off, so this is what every real install already
// did. The perimeter is the tailnet and the lock is a valid token on every protected route, plus the
// permission gate below.
const corsMiddleware = cors({
origin: (origin) => origin ?? '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
});
// A plugin's TOP-LEVEL paths are the one place CORS must NOT run, because hono's cors() answers every
// OPTIONS itself as a preflight — 204, no body, none of the route's own headers — and never calls the
// route beneath it.
//
// The case that taught this is DAV. OPTIONS is not a preflight to a DAV client: it is how the client asks
// what the server can do, and the answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook`
// header. iOS sends it during account setup and refuses the account when it is missing, reporting the
// failure as "Cannot connect using SSL" — a message about TLS for a problem that has nothing to do with
// TLS, which is how this cost an evening.
//
// Nothing is lost by skipping it. A top-level path exists because something that is NOT a browser has to
// reach it — a DAV client, a phone, a protocol handler — and those have no origin to check. This was
// `isDavPath`, a literal list of four paths, until caldav became a plugin and took them with it; asking
// the claim registry means the carve-out follows whoever owns the route instead of naming it here.
const isPluginRootPath = (path: string) => pluginForRootPath(path) !== null;
// The mount table, as DATA rather than forty statements.
//
// The reason is the permission registry: assertPermissionTotality refuses to boot unless every mounted
// prefix maps to exactly one permission, and that check is only worth anything if it reads the real mount
// list. A hand-copied second list would drift, and the drift would be invisible until someone tried a
// prefix nobody had gated — which is precisely how the websocket hole happened.
//
// Order is irrelevant here: every prefix is distinct, so hono's registration-order matching has nothing to
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
['/server-settings', serverSettingsRouter],
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter],
['/roles', rolesRouter],
['/skills', skillsRouter],
['/tasks', tasksRouter],
['/agents', agentsRouter],
['/tools', toolsRouter],
['/processes', processesRouter],
['/rescan', rescanRouter],
['/scrape', scrapeRouter],
['/upload', uploadRouter],
['/user', settingsRouter],
['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/sendoff', sendoffRouter],
['/sub-agents', subAgentsRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
['/notify', notifyRouter], // core since 2026-08-19 — the one outbound-notification surface
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
['/system-monitor', systemMonitorRouter],
['/activity', activityRouter],
['/dock', dockRouter],
['/integrations', integrationsRouter],
['/queue', queueRouter],
['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
['/claude-auth', claudeLoginRouter],
['/chat', chatRouter],
['/command-hq', commandHqRouter],
['/pipeline-jobs', pipelineJobsRouter],
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
];
/** Every prefix served behind the account gate. Read by the permission totality check at boot. */
export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix);
/**
* Mounted above the account gate, and so exempt from permission checks — see EXEMPT_API_PREFIXES in
* permissions/totality.ts, which has to justify each one.
*/
export const UNPROTECTED_API_PREFIXES: string[] = [
'/auth',
'/landing-page-data',
'/themes',
'/waitlist',
'/sidecar',
'/agent-handoff',
'/dev-proxy',
];
/**
* Build the whole application from the plugins currently installed.
*
* Pure: it reads nothing and mutates nothing. Everything it needs arrives as an argument, so a caller
* can build an app for a hypothetical plugin set — which is what makes the swap testable without a
* database, a filesystem or a running server.
*/
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
const app = new Hono<{ Variables: HonoVariables }>();
app.use((ctx, next) => (isPluginRootPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
/*
A plugin's PUBLIC router, mounted above everything — the gate, `userMiddleware`, the whole platform API.
Two shapes, both for the same reason: a client speaking a protocol Officer does not define, which
derives its own URLs and cannot be told to look elsewhere. See `unauthenticatedRootPaths` in
plugins/manifest.ts for why this exists at all and how narrow it is kept.
root paths `/identity/*` and friends — a single claimed segment each, refused if reserved or
already taken.
protocol header `/api/*` when the request carries a declared header. Registered BEFORE the platform's
own `/api` mount, because hono matches in registration order and this has to win.
ROUTING, not authentication — a forged header reaches the plugin, which demands its
own credential; it cannot reach anything of Officer's.
*/
for (const plugin of plugins) {
if (!plugin.publicRouter) continue;
for (const segment of plugin.rootSegments) app.route(`/${segment}`, plugin.publicRouter);
}
const protocolPlugins = plugins.filter((p) => p.publicRouter && p.protocolHeaders.length);
if (protocolPlugins.length) {
app.use('/api/*', async (ctx, next) => {
const appName = pluginForProtocolHeaders(ctx.req.raw.headers);
if (!appName) return next();
const claimant = protocolPlugins.find((p) => p.prefix === `/${appName}` || p.prefix.endsWith(`/${appName}`));
if (!claimant?.publicRouter) return next();
return claimant.publicRouter.fetch(ctx.req.raw, ctx.env);
});
}
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
app.use(permissionGateMiddleware);
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
app.route('/api/auth', authRouter);
app.route('/api/landing-page-data', landingPageDataRouter);
app.route('/api/themes', themesRouter);
app.route('/api/waitlist', waitlistRouter);
app.get('/api/integrations/google/callback', googleCallbackHandler);
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
// so userMiddleware would 401 it and a permission lookup would have no account to resolve. The token
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
app.route('/api/agent-handoff', agentHandoffRouter);
// Loopback dev servers, framed by the Preview panel.
//
// UNPROTECTED because an iframe navigation carries no Authorization header — `userMiddleware` would 401
// the document before the router saw it, which is exactly what happened: the pane showed the
// middleware's "Unauthorized", not this router's own message. It is not open: the router verifies the
// OWNER's token on `/session` and requires the cookie that issues on everything else.
app.route('/api/dev-proxy', devProxyRouter);
const protectedRouter = createRouter();
protectedRouter.use(bodyParser());
/*
A session is required for everything here EXCEPT a plugin's declared `unauthenticatedPaths`.
The permission gate above is not enough on its own, and the reason is worth stating: that gate only
runs when a token was presented (`payload && !isOwner && …`), so it is what stops a valid non-owner
reaching something they were not granted. What stops an ANONYMOUS request is this line — so a path
that is supposed to answer without a session has to skip it here, not only there.
`ctx.req.path` is the full request path even inside a mounted router, so the patterns can be absolute
and resolved once at mount rather than re-derived per request.
*/
protectedRouter.use((ctx, next) => (isPluginPublicPath(ctx.req.path) ? next() : userMiddleware(ctx, next)));
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
// Plugin routes, mounted behind the same account gate as everything else — a plugin is part of the
// application, not a guest in it, so it gets no separate door and no weaker middleware.
//
// `mountPrefix` decides where, from the manifest's `publisher` and nothing else. Nothing here may
// branch on provenance: the moment first-party and third-party differ anywhere but that one function,
// they become two systems and only one of them is exercised.
for (const plugin of plugins) protectedRouter.route(plugin.prefix, plugin.router);
app.route('/api', protectedRouter);
// Every error is JSON, shape `{ error: string }` — success is already JSON, so a text/plain failure
// was the one response a client had to special-case. An OBJECT returnValue passes through untouched:
// callers rely on its fields, and wrapping it would break them.
app.onError((error, ctx) => {
if (error instanceof CustomError) {
if (error.returnValue) {
if (typeof error.returnValue === 'string') {
return ctx.json({ error: error.returnValue }, error.statusCode);
} else {
return ctx.json(error.returnValue, error.statusCode);
}
}
return ctx.json({ error: error.message }, error.statusCode);
}
console.error('Unexpected error:', error.message);
console.log(error.stack);
return ctx.json({ error: 'Internal Server Error' }, 500);
});
return app;
}
/**
* The live app. `let`, and reassigned by `rebuildHonoApp` — see the note at the top of this file.
*
* Starts with no plugins because discovery reads the disk and the database, which is asynchronous and
* must not happen at import. `server.tsx` rebuilds once both have answered.
*/
export let honoServer = buildHonoApp();
/** Swap the live app for one built from `plugins`. The whole of install, uninstall, enable and disable. */
export function rebuildHonoApp(plugins: MountedPlugin[]): Hono<{ Variables: HonoVariables }> {
honoServer = buildHonoApp(plugins);
return honoServer;
}