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:
@@ -2,7 +2,15 @@ import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
||||
import type { MountedPlugin } from '../hono';
|
||||
import { rebuildHonoApp } from '../hono';
|
||||
import { setPluginPermissions, type Permission } from '../permissions/registry';
|
||||
import { resolvePluginPublicPaths, setPluginPublicPaths, type PublicPath } from './public-paths';
|
||||
import {
|
||||
resolvePluginPublicPaths,
|
||||
resolvePluginRootPaths,
|
||||
setPluginProtocolHeaders,
|
||||
setPluginPublicPaths,
|
||||
setPluginRootClaims,
|
||||
type PublicPath,
|
||||
type RootClaim,
|
||||
} from './public-paths';
|
||||
import { discoverPlugins } from './discover';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
import { generatePluginsModule, rebuildFrontend } from './generate';
|
||||
@@ -63,13 +71,27 @@ export async function snapshotPlugins(): Promise<PluginsSnapshot> {
|
||||
export async function loadPluginRouter(plugin: DiscoveredPlugin): Promise<MountedPlugin | null> {
|
||||
if (!plugin.api) return null;
|
||||
|
||||
const module = (await import(plugin.api)) as { router?: unknown };
|
||||
const module = (await import(plugin.api)) as { router?: unknown; publicRouter?: unknown };
|
||||
const router = module.router;
|
||||
if (!router || typeof (router as { fetch?: unknown }).fetch !== 'function') {
|
||||
throw new Error(`${plugin.appName}: api/router.ts must export \`router\` (a Hono router)`);
|
||||
}
|
||||
|
||||
return { prefix: mountPrefix(plugin), router: router as MountedPlugin['router'] };
|
||||
// Optional, and only meaningful alongside a manifest that declares where it is served. A plugin that
|
||||
// exports one without declaring anything gets it loaded and mounted nowhere, which is the safe way
|
||||
// round: the manifest is what the owner consented to, not the export.
|
||||
const publicRouter =
|
||||
module.publicRouter && typeof (module.publicRouter as { fetch?: unknown }).fetch === 'function'
|
||||
? (module.publicRouter as MountedPlugin['router'])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
prefix: mountPrefix(plugin),
|
||||
router: router as MountedPlugin['router'],
|
||||
...(publicRouter ? { publicRouter } : {}),
|
||||
rootSegments: [],
|
||||
protocolHeaders: plugin.manifest.protocolHeaders ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The plugins that should be mounted right now: installed, enabled, and carrying an `api/router.ts`. */
|
||||
@@ -161,7 +183,38 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
|
||||
console.log(`[plugins] served WITHOUT a session: ${shown}`);
|
||||
}
|
||||
|
||||
// Top-level claims, resolved against ONE accumulating set so two plugins cannot both own `/identity`.
|
||||
// First come wins and the loser is named, rather than the last mount silently shadowing the first.
|
||||
const taken = new Set<string>();
|
||||
const rootClaims: RootClaim[] = [];
|
||||
const headerClaims: { header: string; appName: string }[] = [];
|
||||
for (const { plugin } of enabled) {
|
||||
const { segments, refused } = resolvePluginRootPaths(plugin, taken);
|
||||
for (const reason of refused) console.error(`[plugins] root path refused — ${reason}`);
|
||||
for (const segment of segments) rootClaims.push({ segment, appName: plugin.appName });
|
||||
for (const header of plugin.manifest.protocolHeaders ?? []) {
|
||||
headerClaims.push({ header: header.toLowerCase(), appName: plugin.appName });
|
||||
}
|
||||
}
|
||||
setPluginRootClaims(rootClaims);
|
||||
setPluginProtocolHeaders(headerClaims);
|
||||
if (rootClaims.length) {
|
||||
console.log(`[plugins] top-level paths: ${rootClaims.map((c) => `/${c.segment} (${c.appName})`).join(', ')}`);
|
||||
}
|
||||
if (headerClaims.length) {
|
||||
console.log(
|
||||
`[plugins] /api diverted by header: ${headerClaims.map((c) => `${c.header} -> ${c.appName}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const mounted = await mountablePlugins(snapshot);
|
||||
// The claims were resolved above from the manifests; attach them to the entries hono actually mounts,
|
||||
// so the router set and the claim set can never describe different plugins.
|
||||
for (const entry of mounted) {
|
||||
const appName = entry.prefix.split('/').filter(Boolean).pop();
|
||||
entry.rootSegments = rootClaims.filter((c) => c.appName === appName).map((c) => c.segment);
|
||||
entry.protocolHeaders = headerClaims.filter((c) => c.appName === appName).map((c) => c.header);
|
||||
}
|
||||
rebuildHonoApp(mounted);
|
||||
|
||||
// Republish every enabled plugin's `assets/` on the way past.
|
||||
|
||||
Reference in New Issue
Block a user