`platform/themes/<id>/` — untracked, same rule as `plugins/`, only the instructions
tracked. Drop a directory in and the theme appears in the picker on the next page load.
themes/my-theme/theme.css required — `[data-theme='my-theme'] { … }`
themes/my-theme/theme.json required — { name, family, dark }
themes/my-theme/assets/ optional — wallpaper, logo
── Why this is cheaper than installing a plugin ──
A plugin is code: it needs a manifest module, mounting, and a frontend rebuild to reach
the bundler. A theme is a block of CSS custom properties, so it needs none of that. The
server reads the directory at request time and the client adds a `<link>`. No install
step, no rebuild, no restart — which is the whole reason this is eighty lines of
discovery rather than four hundred.
The id is the DIRECTORY NAME, not a manifest field, for the same reason a plugin's
`appName` is not one: an id that can disagree with where its files sit eventually will.
Discovery checks that the CSS actually targets its own directory name, because the
failure is otherwise silent — the theme appears, is selected, and nothing changes.
── Problems are reported, not swallowed ──
A malformed theme returns a reason rather than vanishing:
broken-probe: theme.css does not target [data-theme='broken-probe']
The client logs those, so an author has somewhere to look instead of wondering why
their theme never appeared.
── Boundaries kept ──
The `theme` package still performs no IO. It gained an `extraThemes` prop; Officer
fetches and injects, exactly as it supplies the panel registry to `workspaces`. A
failure to fetch is not an error — no server, no session, no directory all mean "you get
the built-in themes", which is a complete product.
`/api/themes` is a `core` capability: read-only, and not deniable, because an account
that cannot read the theme list renders unstyled. Installing remains a directory on
disk; an upload endpoint would be a way to write arbitrary CSS into the page from a
request.
Verified in-process: listing, the problem report, CSS served as text/css, and a
traversal attempt answered 404.
── Needs a restart ──
Server-side change. Until then the client's fetch 404s and silently keeps the built-ins,
so nothing breaks in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
18 KiB
TypeScript
333 lines
18 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 { 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 { 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';
|
|
// 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';
|
|
// 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 { 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> };
|
|
|
|
// ── 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],
|
|
// ['/email', emailRouter], // plugin — switched off 2026-08-13
|
|
// ['/browser', browserRouter], // switched off — see server.tsx
|
|
['/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], // 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',
|
|
'/waitlist',
|
|
// '/vault', // switched off with the Vaultwarden mounts — see above
|
|
'/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)));
|
|
|
|
// 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);
|
|
// 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 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());
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|