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 { 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 { 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); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); // 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); protectedRouter.route('/server-settings', serverSettingsRouter); protectedRouter.route('/users', usersRouter); protectedRouter.route('/plans', plansRouter); protectedRouter.route('/skills', skillsRouter); protectedRouter.route('/tasks', tasksRouter); protectedRouter.route('/agents', agentsRouter); protectedRouter.route('/tools', toolsRouter); protectedRouter.route('/processes', processesRouter); protectedRouter.route('/rescan', rescanRouter); protectedRouter.route('/scrape', scrapeRouter); protectedRouter.route('/upload', uploadRouter); protectedRouter.route('/user', settingsRouter); protectedRouter.route('/dashboards', dashboardsRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/terminal', terminalRouter); protectedRouter.route('/memos', memosRouter); protectedRouter.route('/gitea', giteaRouter); protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level) protectedRouter.route('/notify', notifyRouter); protectedRouter.route('/headscale', headscaleRouter); protectedRouter.route('/transmission', transmissionRouter); protectedRouter.route('/invoiceshelf', invoiceshelfRouter); protectedRouter.route('/jellyfin', jellyfinRouter); protectedRouter.route('/photos', photosRouter); protectedRouter.route('/wallet', walletRouter); protectedRouter.route('/vpn', vpnRouter); protectedRouter.route('/system-monitor', systemMonitorRouter); protectedRouter.route('/activity', activityRouter); protectedRouter.route('/dock', dockRouter); protectedRouter.route('/integrations', integrationsRouter); protectedRouter.route('/queue', queueRouter); protectedRouter.route('/email', emailRouter); protectedRouter.route('/browser', browserRouter); protectedRouter.route('/bug-report', bugReportRouter); protectedRouter.route('/chat', chatRouter); protectedRouter.route('/pipeline-jobs', pipelineJobsRouter); protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI protectedRouter.route('/desktop', desktopRouter); 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); });