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 { 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 } from './plugins/public-paths'; import { agentHandoffRouter } from './api/agent-handoff/router'; import { terminalRouter } from './api/terminal/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server'; // import { memosRouter } from './api/memos/router'; import { appStoreRouter } from './api/app-store/router'; import { davSyncRouter } from './api/dav/sync-router'; import { davRouter } from './api/dav/router'; import { claimIosProfile } from './api/dav/ios-profile'; 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; /** * 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; /** 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'], }); // The DAV doors are the one place CORS must NOT run, because hono's cors() answers every OPTIONS itself // as a preflight — 204, no body, no DAV headers — and never calls the route beneath it. // // 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 CalDAV client is not a browser and has no origin to check. const isDavPath = (path: string) => path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav'; // 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][] = [ ['/server-settings', serverSettingsRouter], // Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts. ['/theme-favorites', themeFavoritesRouter], ['/users', usersRouter], ['/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], // ['/memos', memosRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) ['/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) => (isDavPath(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); // CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is: // DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a // platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see // api/dav/sync-router.ts. // The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration // order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to // offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the // authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts. app.get('/dav/provision/:file', (ctx) => { const file = ctx.req.param('file'); const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null; const body = token ? claimIosProfile(token) : null; // Expired, already used, or never existed — all the same 404. There is nothing useful to tell a // caller who has the wrong token, and distinguishing the cases would confirm that a token once existed. if (!body) return ctx.text('not found', 404); return new Response(body as unknown as BodyInit, { headers: { // Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or // text/xml the file downloads and the OS does nothing with it. 'Content-Type': 'application/x-apple-aspen-config', 'Cache-Control': 'no-store', }, }); }); app.route('/dav', davSyncRouter); // Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of // fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any // credential, so they must sit above every auth gate. Without them iOS in particular degrades to // demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel // worse than the commercial product it is replacing. // `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually // wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real // client while looking perfectly healthy in a browser. app.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); 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); app.onError((error, ctx) => { if (error instanceof CustomError) { if (error.returnValue) { if (typeof error.returnValue === 'string') { return ctx.text(error.returnValue, error.statusCode); } else { return ctx.json(error.returnValue, error.statusCode); } } return ctx.text(error.message, error.statusCode); } console.error('Unexpected error:', error.message); console.log(error.stack); return ctx.text('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; }