Files
platform/src/servers/hono.ts
T
pastilhasandClaude Opus 5 2bd96a9a98 tell a member why their agent is not working, instead of 403
f0af723 granted chat to every role by default, which is right, but the route
still refuses non-owners — so a new member gets a tile that resolves and an API
that 403s, the exact broken state b4f88ec and eda004a were built to remove.

The fix is not to withdraw the grant. It is to answer the question the member
actually has, which is "what do I do about it": their own claude, in their own
home, needs them to sign in once with their own Anthropic account. The platform
cannot do that for them — logging in is an interactive act against an account
that is theirs, and the alternative, pointing them at the owner's credential
proxy, spends the owner's subscription on their turns.

GET /api-status returns two booleans about the caller's own home plus the one
instruction that fits their case, so the UI can render a terminal saying "run
claude once" rather than an error.

Its own router, deliberately not on chatRouter: that router refuses every
non-owner wholesale and is right to — reads there leak the owner's project
directory names — which means an endpoint on it could not be read by the
accounts that need it most. Same `chat` capability, no owner gate, and nothing
in the response describes anyone but the caller.

Frontend not done: nothing calls this yet, so behaviour is still unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:58:18 +00:00

273 lines
14 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 { 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, VAULT_ONLY_PREFIXES, isBitwardenClient } 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 { 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';
// 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 { agentStatusRouter } from './api/agent-status/router';
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);
// …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);
// 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],
['/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],
['/app-store', appStoreRouter],
['/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],
['/agent-status', agentStatusRouter],
['/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);
});