the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.
hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.
so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.
buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.
alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.
discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.
a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.
verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+160
-104
@@ -63,7 +63,29 @@ export { Hono };
|
||||
export { createRouter };
|
||||
export type { HonoVariables };
|
||||
|
||||
export const honoServer = new Hono<{ Variables: 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> };
|
||||
|
||||
// ── 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
|
||||
@@ -87,93 +109,6 @@ const corsMiddleware = cors({
|
||||
const isDavPath = (path: string) =>
|
||||
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
|
||||
|
||||
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
|
||||
// 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.
|
||||
honoServer.use(capabilityGateMiddleware);
|
||||
|
||||
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||
honoServer.route('/api/auth', authRouter);
|
||||
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||
honoServer.route('/api/waitlist', waitlistRouter);
|
||||
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||
// honoServer.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin
|
||||
|
||||
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
|
||||
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
|
||||
// than a mode of the router above: that one requires an Officer session and swaps the caller's
|
||||
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
|
||||
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
|
||||
// open is not a new exposure.
|
||||
// honoServer.route('/vaultwarden', publicVaultRouter); // switched off with the above
|
||||
|
||||
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
|
||||
//
|
||||
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
|
||||
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
|
||||
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
|
||||
// header. An ordinary Officer request never matches, so nothing that worked before changes.
|
||||
// for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter);
|
||||
//
|
||||
// honoServer.use('/api/*', async (ctx, next) => {
|
||||
// if (!isBitwardenClient(ctx.req.raw.headers)) return next();
|
||||
// return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
|
||||
// });
|
||||
honoServer.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 capability 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.
|
||||
honoServer.route('/api/agent-handoff', agentHandoffRouter);
|
||||
|
||||
// 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.
|
||||
honoServer.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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// honoServer.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.
|
||||
// honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
|
||||
// honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
|
||||
|
||||
const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
protectedRouter.use(userMiddleware);
|
||||
|
||||
// The mount table, as DATA rather than forty statements.
|
||||
//
|
||||
// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted
|
||||
@@ -228,8 +163,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
|
||||
];
|
||||
|
||||
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
|
||||
|
||||
/** Every prefix served behind the account gate. Read by the capability totality check at boot. */
|
||||
export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix);
|
||||
|
||||
@@ -246,21 +179,144 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
|
||||
'/agent-handoff',
|
||||
];
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
/**
|
||||
* 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 }>();
|
||||
|
||||
honoServer.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);
|
||||
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
|
||||
// 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(capabilityGateMiddleware);
|
||||
|
||||
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||
app.route('/api/auth', authRouter);
|
||||
app.route('/api/landing-page-data', landingPageDataRouter);
|
||||
app.route('/api/waitlist', waitlistRouter);
|
||||
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||
// app.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin
|
||||
|
||||
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
|
||||
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
|
||||
// than a mode of the router above: that one requires an Officer session and swaps the caller's
|
||||
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
|
||||
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
|
||||
// open is not a new exposure.
|
||||
// app.route('/vaultwarden', publicVaultRouter); // switched off with the above
|
||||
|
||||
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
|
||||
//
|
||||
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
|
||||
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
|
||||
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
|
||||
// header. An ordinary Officer request never matches, so nothing that worked before changes.
|
||||
// for (const prefix of VAULT_ONLY_PREFIXES) app.route(prefix, publicVaultRouter);
|
||||
//
|
||||
// app.use('/api/*', async (ctx, next) => {
|
||||
// if (!isBitwardenClient(ctx.req.raw.headers)) return next();
|
||||
// return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
|
||||
// });
|
||||
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 capability 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);
|
||||
|
||||
// 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());
|
||||
protectedRouter.use(userMiddleware);
|
||||
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);
|
||||
}
|
||||
return ctx.text(error.message, error.statusCode);
|
||||
}
|
||||
|
||||
console.error('Unexpected error:', error.message);
|
||||
console.log(error.stack);
|
||||
return ctx.text('Internal Server Error', 500);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user