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
314 lines
16 KiB
TypeScript
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;
|
|
}
|