plugins: let a plugin claim top-level paths and divert /api by header

Restores the setup the vault extraction regressed: paste the bare Officer URL
into a Bitwarden client and log in. Nothing else.

The extraction moved the Bitwarden door to /api/vault/bw, which is correct and
unusable — a client derives /api, /identity, /icons and /notifications from ONE
url it is given, so a prefixed base turns setup into five fields copied by hand
into an advanced pane. That is not a feature anyone completes, and the owner
said so. My regression, my wrong call: I treated "a plugin cannot serve
top-level paths" as a fact about the platform when it was a fact about what had
been built.

Two additions, both manifest-declared, both narrow:

  unauthenticatedRootPaths  single lowercase segments (`/identity`), served
                            above the gate. Refused if reserved (`/api`,
                            `/auth`, `/plugins`, `/dav`, …), refused if another
                            plugin already claims it, refused if it is anything
                            more structural than one name.

  protocolHeaders           hands `/api/*` to the plugin when the request
                            carries one. Needed because /api is the one
                            namespace both servers claim — Vaultwarden has
                            /api/sync, Officer has /api/chat — and the path
                            cannot tell them apart, so the client says who it
                            is. ROUTING, not authentication: a forged header
                            reaches the plugin, which demands its own
                            credential, and cannot reach anything of Officer's.
                            The worst it does is cost that caller Officer's API.

The Bun route table gets an entry per claimed segment, built from the plugin
set — `refreshPluginMounts` runs at line ~204, long before serve() at ~378, so
the claims are known. This is the constraint I earlier asserted made the whole
thing impossible, without checking it. The real cost is smaller and stated in
the code: a plugin claiming a root path is not fully installed until
`pm2 restart officer`, because the table is fixed once serve() is called.

Every claim is logged at boot by plugin — a root path answering without a
session should not be something you read a manifest to discover.

13 more tests (30 in the file): reserved segments, malformed segments, two
plugins colliding, and segment-not-substring matching so `/identityx` never
matches `/identity`. Suite 1074 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:47:04 +00:00
co-authored by Claude Opus 5
parent dc190bb790
commit 8367fada13
6 changed files with 371 additions and 8 deletions
+45 -2
View File
@@ -22,7 +22,7 @@ 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 { isPluginPublicPath } from './plugins/public-paths';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
// import { slskdRouter } from './api/slskd/router';
// import { transmissionRouter } from './api/transmission/router';
@@ -64,7 +64,20 @@ 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> };
export type MountedPlugin = {
prefix: string;
router: ReturnType<typeof createRouter>;
/**
* An OPTIONAL second router served above the account gate — at declared top-level paths, and for
* `/api/*` requests carrying a declared protocol header. Absent for every plugin that does not have a
* foreign protocol to host; see `unauthenticatedRootPaths` in plugins/manifest.ts.
*/
publicRouter?: ReturnType<typeof createRouter>;
/** Top-level segments this plugin claims, already de-duplicated against other plugins. */
rootSegments: string[];
/** Headers that hand an `/api/*` request to `publicRouter` instead of to Officer. */
protocolHeaders: string[];
};
// ── The app is BUILT, not assembled once ──
//
@@ -188,6 +201,36 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
/*
A plugin's PUBLIC router, mounted above everything — the gate, `userMiddleware`, the whole platform API.
Two shapes, both for the same reason: a client speaking a protocol Officer does not define, which
derives its own URLs and cannot be told to look elsewhere. See `unauthenticatedRootPaths` in
plugins/manifest.ts for why this exists at all and how narrow it is kept.
root paths `/identity/*` and friends — a single claimed segment each, refused if reserved or
already taken.
protocol header `/api/*` when the request carries a declared header. Registered BEFORE the platform's
own `/api` mount, because hono matches in registration order and this has to win.
ROUTING, not authentication — a forged header reaches the plugin, which demands its
own credential; it cannot reach anything of Officer's.
*/
for (const plugin of plugins) {
if (!plugin.publicRouter) continue;
for (const segment of plugin.rootSegments) app.route(`/${segment}`, plugin.publicRouter);
}
const protocolPlugins = plugins.filter((p) => p.publicRouter && p.protocolHeaders.length);
if (protocolPlugins.length) {
app.use('/api/*', async (ctx, next) => {
const appName = pluginForProtocolHeaders(ctx.req.raw.headers);
if (!appName) return next();
const claimant = protocolPlugins.find((p) => p.prefix === `/${appName}` || p.prefix.endsWith(`/${appName}`));
if (!claimant?.publicRouter) return next();
return claimant.publicRouter.fetch(ctx.req.raw, ctx.env);
});
}
// 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);