Restores the setup the vault extraction regressed: paste the bare Officer URL
into a Bitwarden client and log in. Nothing else.
The extraction moved the Bitwarden door to /api/vault/bw, which is correct and
unusable — a client derives /api, /identity, /icons and /notifications from ONE
url it is given, so a prefixed base turns setup into five fields copied by hand
into an advanced pane. That is not a feature anyone completes, and the owner
said so. My regression, my wrong call: I treated "a plugin cannot serve
top-level paths" as a fact about the platform when it was a fact about what had
been built.
Two additions, both manifest-declared, both narrow:
unauthenticatedRootPaths single lowercase segments (`/identity`), served
above the gate. Refused if reserved (`/api`,
`/auth`, `/plugins`, `/dav`, …), refused if another
plugin already claims it, refused if it is anything
more structural than one name.
protocolHeaders hands `/api/*` to the plugin when the request
carries one. Needed because /api is the one
namespace both servers claim — Vaultwarden has
/api/sync, Officer has /api/chat — and the path
cannot tell them apart, so the client says who it
is. ROUTING, not authentication: a forged header
reaches the plugin, which demands its own
credential, and cannot reach anything of Officer's.
The worst it does is cost that caller Officer's API.
The Bun route table gets an entry per claimed segment, built from the plugin
set — `refreshPluginMounts` runs at line ~204, long before serve() at ~378, so
the claims are known. This is the constraint I earlier asserted made the whole
thing impossible, without checking it. The real cost is smaller and stated in
the code: a plugin claiming a root path is not fully installed until
`pm2 restart officer`, because the table is fixed once serve() is called.
Every claim is logged at boot by plugin — a root path answering without a
session should not be something you read a manifest to discover.
13 more tests (30 in the file): reserved segments, malformed segments, two
plugins colliding, and segment-not-substring matching so `/identityx` never
matches `/identity`. Suite 1074 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
19 KiB
TypeScript
358 lines
19 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 { 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 { slskdRouter } from './api/slskd/router';
|
|
// import { transmissionRouter } from './api/transmission/router';
|
|
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
|
// import { jellyfinRouter } from './api/jellyfin/router';
|
|
// import { photosRouter } from './api/photos/router';
|
|
// import { walletRouter } from './api/wallet/router';
|
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
|
// import { caldavRouter } from './api/dav/sidecar-server';
|
|
// import { memosRouter } from './api/memos/router';
|
|
// import { giteaRouter } from './api/gitea/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 { browserRouter } from './api/browser/router';
|
|
// import { desktopRouter } from './api/desktop/rest';
|
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
|
import { agentStatusRouter } from './api/agent-status/router';
|
|
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'],
|
|
});
|
|
|
|
// 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<typeof createRouter>][] = [
|
|
['/server-settings', serverSettingsRouter],
|
|
['/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],
|
|
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
|
|
['/terminal', terminalRouter],
|
|
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
|
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
|
['/app-store', appStoreRouter],
|
|
['/plugins', pluginsRouter],
|
|
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
|
|
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
|
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
|
// ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13
|
|
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
|
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
|
|
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
|
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
|
|
['/system-monitor', systemMonitorRouter],
|
|
['/activity', activityRouter],
|
|
['/dock', dockRouter],
|
|
['/integrations', integrationsRouter],
|
|
['/queue', queueRouter],
|
|
// ['/browser', browserRouter], // switched off — see server.tsx
|
|
['/bug-report', bugReportRouter],
|
|
['/agent-status', agentStatusRouter],
|
|
['/chat', chatRouter],
|
|
['/command-hq', commandHqRouter],
|
|
['/pipeline-jobs', pipelineJobsRouter],
|
|
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
|
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
|
|
];
|
|
|
|
/** 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); // plugin — switched off 2026-08-13
|
|
|
|
// 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;
|
|
}
|