Files
platform/src/servers/hono.ts
T
pastilhasandClaude Opus 5 f2e38ed9a7 serve vaultwarden at officer own host, without officer auth
So the bitwarden browser extension can point here and the separate public vaultwarden
hostname can be taken down.

/api/vault cannot serve it: that router requires an officer session and REPLACES the caller
Authorization header with a server-held vaultwarden token. Right for our own clients — the
device then holds no vault credential — and impossible for a third-party client that gets
its own token from /identity/connect/token and has nowhere to put a platform JWT.

So a separate mount rather than a mode of that router: blending them would put an
unauthenticated branch inside the authenticated path. This one forwards Authorization
untouched and rewrites nothing.

Leaving it open is not a new exposure — everything here was already reachable at the
vaultwarden URL it replaces, behind the same master password, and officer cannot add a check
it has no credential for. It is also going behind tailscale.

Temporary. The end state is our own extension reusing @officer/vault, which already runs as
a plain JS bundle outside react native (the iOS autofill extension hosts it in
JavaScriptCore), against the /api/vault/session/login broker — then nothing addresses
vaultwarden directly and this mount is deleted rather than adjusted.

Needed its own entry in server.tsx: only listed paths reach hono and the rest fall through
to the SPA, so without it the endpoint answered 200 with the react shell — a missing route
that looks like a working one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:23:28 +01:00

258 lines
13 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 { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router';
import { plansRouter } from './api/plans/plans';
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 { taskLogsRouter } from './api/task-logs/task-logs';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { musicRouter } from './api/music/router';
import { vaultRouter } from './api/vault/router';
import { publicVaultRouter } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/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 { vpnRouter } from './api/vpn/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 { 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';
// Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar
// registers its listener when createSidecarProxy runs inside the router this file already imports.
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
import { queueRouter } from './api/queue/queue';
import { emailRouter } from './api/email/router';
import { browserRouter } from './api/browser/router';
import { desktopRouter } from './api/desktop/rest';
import { bugReportRouter } from './api/bug-report/bug-report';
import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
import { isMusicOriginExempt } from './_middlewares/origin-validation';
export { Hono };
export { createRouter };
export type { HonoVariables };
export const honoServer = new Hono<{ Variables: HonoVariables }>();
const corsMiddleware = cors({
origin: (origin, c) => {
const host = c.req.header('host');
// TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a
// browser client is not blocked by CORS after userMiddleware has already let it through.
if (isMusicOriginExempt(c.req.path)) return origin ?? '*';
return isOriginAllowed(origin, host) ? 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';
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
honoServer.use(originScopeMiddleware);
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. Origin
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
// notifications WebSocket is upgraded at the serve level (server.tsx).
honoServer.route('/api/vault', vaultRouter);
// 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);
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);
// 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
// prefix maps to exactly one capability, 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],
['/plans', plansRouter],
['/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 capability covers it
['/dashboards', dashboardsRouter],
['/task-logs', taskLogsRouter],
['/file-browser', fileBrowserRouter],
['/music', musicRouter],
['/slskd', slskdRouter],
['/terminal', terminalRouter],
['/memos', memosRouter],
['/gitea', giteaRouter],
['/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],
['/headscale', headscaleRouter],
['/transmission', transmissionRouter],
['/invoiceshelf', invoiceshelfRouter],
['/jellyfin', jellyfinRouter],
['/photos', photosRouter],
['/wallet', walletRouter],
['/vpn', vpnRouter],
['/system-monitor', systemMonitorRouter],
['/activity', activityRouter],
['/dock', dockRouter],
['/integrations', integrationsRouter],
['/queue', queueRouter],
['/email', emailRouter],
['/browser', browserRouter],
['/bug-report', bugReportRouter],
['/chat', chatRouter],
['/pipeline-jobs', pipelineJobsRouter],
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
['/desktop', desktopRouter],
];
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);
/**
* Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in
* capabilities/totality.ts, which has to justify each one.
*/
export const UNPROTECTED_API_PREFIXES: string[] = [
'/auth',
'/landing-page-data',
'/waitlist',
'/vault',
'/sidecar',
'/agent-handoff',
];
honoServer.route('/api', protectedRouter);
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);
}
}
return ctx.text(error.message, error.statusCode);
}
console.error('Unexpected error:', error.message);
console.log(error.stack);
return ctx.text('Internal Server Error', 500);
});