Files
platform/src/servers/plugins/manifest.ts
T
pastilhasandClaude Opus 5 8367fada13 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>
2026-08-18 14:47:04 +00:00

476 lines
23 KiB
TypeScript

// What a plugin declares about itself, and what the tree declares for it.
//
// ── The manifest holds only what a directory listing cannot say ──
//
// Everything structural is convention, and presence is the declaration: `sidecar/index.ts` means there is
// a sidecar, `api/router.ts` means there are routes, `db/schema.ts` means there are tables, `web/` means
// there is a frontend. The manifest carries the residue — an identity fact, or something a human chose.
//
// That is why there is no `sidecar`, `schema` or `frontend` field here, and no dock or title field either:
// the tile is `{ label, icon, color, to: mountPrefix() }` and the title is `label`, all of which are
// already below. Writing them twice could only ever drift.
//
// The reasoning behind every decision here is recorded in plugins/offscale/PLUGIN.md — the worked
// example, kept next to the plugin it produced rather than in the platform's docs.
/**
* A permission the plugin adds to the platform's permission system.
*
* Called `permissions`, and the plugin system used that word first — before the platform did. On
* 2026-08-15 the rest of the codebase followed: the registry moved to `permissions/`, its table to
* `role_permissions`, and the sidecar's routing keys became `handles`.
*
* `capability` now means exactly one thing, the file-based item store under `$OFFICER_ROOT/capabilities`
* — skills, tools, tasks. Three other uses survive because they are somebody else's vocabulary rather
* than ours: the wallet's Lightning backend features, the pty sidecar's terminfo queries, and
* InvoiceShelf's per-resource flags.
*/
export type PluginPermission = {
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
key: string;
label: string;
description: string;
// There is deliberately NO `ownerOnly`, and no kind of any sort.
//
// Every plugin permission is grantable, per role, at read or write — the same configuration the
// platform's own permissions get, on the same page, with no special cases to learn. A plugin that
// wanted to be owner-only would be a plugin the owner cannot delegate, and "which members may reach
// this" is a decision belonging to whoever runs the server rather than whoever wrote the code.
//
// Finer visibility — whose rows a member sees, what a read means for this plugin's data — is the
// PLUGIN's business and lives in its own queries. The platform's answer is uniform: read, write, or
// nothing. See plugins/offscale/PLUGIN.md.
/**
* Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes
* a document and never saves one. Without declaring them, a read-level account meets what reads as a
* broken feature where a withheld permission should be.
*/
readOnlyWrites?: string[];
};
/** The package managers the platform knows how to drive. Mirrors `PM` in machine-setup's lib/base.sh. */
export type PackageManager = 'apt' | 'pacman' | 'dnf' | 'brew';
/**
* A host binary the plugin shells out to, and how to get it.
*
* ── Why a BINARY plus per-manager package names, and not a package name ──
*
* Offscale needed nothing. Music needs `ffmpeg` and `ffprobe`, and without them its indexer does not
* fail — it writes a complete library where every track is a bare filename with no cover, and reports
* success. A refusal wearing the costume of a normal result, which is the failure shape this codebase
* keeps finding. So a plugin has to be able to say what it needs, and the platform has to check.
*
* The shape is taken from `scripts/setup-old/setup.sh`, which had already solved this:
*
* if has <binary>; then skip; else
* case $PM in apt) …; pacman) …; brew) skip "built-in" ;; esac
* fi
*
* Probing the BINARY rather than the package is what makes that `brew) built-in` case free: if it is on
* PATH we never look at `packages` at all, so a manager needing no package simply has no entry.
*
* And the names are per manager rather than canonical-with-overrides, deliberately. `packages.sh` says
* why, having tried the alternative: `build-essential`/`base-devel`, `fd`/`fd-find`, and some things are
* not a package elsewhere at all. A translation table hides both behind indirection; a per-manager map
* says what each system actually gets.
*/
export type OsDependency = {
/** Looked up on PATH. The name the plugin's code actually spawns, not a package. */
binary: string;
/** Shown to the owner before they install. What breaks without it, in their words not a package's. */
reason: string;
/**
* Package to install per manager. A manager left out means "cannot be installed automatically here" —
* which for a binary that is present anyway is correct and silent, and otherwise becomes an instruction
* to the owner rather than a guess.
*/
packages: Partial<Record<PackageManager, string>>;
};
export type PluginManifest = {
/**
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
* code paths. Constant today; the seam third parties hang off later.
*/
publisher: string;
/** The plugin's own semver. Updates compare against this. */
version: string;
/** Which platform versions this build is good for. Refused at install when it does not match. */
platform: string;
label: string;
summary: string;
/**
* A lucide icon name, resolved at render — the FALLBACK, used only when the plugin ships no
* `assets/icon.png`. Optional for that reason: a plugin with its own artwork has nothing to say here.
*/
icon?: string;
/**
* Accent colour. Four jobs, not one: the dock tile's background in `badge` style, the active glow,
* the active indicator dot, and the tint on the plugins list. Required even for a plugin whose tile
* draws no background, because the other three still use it.
*/
color: string;
/**
* How the dock draws this plugin's tile.
*
* badge a rounded square filled with `color`, the icon inset — what a lucide GLYPH needs, since a
* white glyph on nothing is invisible.
* bare no background; the artwork fills the tile — what a brand mark wants, since a logo framed
* in an arbitrary swatch reads as a mistake.
*
* DEFAULTED from whether the plugin ships `assets/icon.png`: artwork gets `bare`, a glyph gets
* `badge`. Presence is the declaration here as everywhere else, and this field only exists to
* override that — a plugin whose artwork genuinely wants a coloured backdrop can say `badge`.
*/
tile?: 'badge' | 'bare';
permissions: PluginPermission[];
/**
* Paths under this plugin's own mount that are served ABOVE the account gate.
*
* ── Why this exists ──
*
* Some integrations receive requests that structurally cannot carry a session. Kiosk is the first:
* iOS `installd` fetches an install manifest and a binary ITSELF — not the app, not the browser — and a
* device enrolling posts its identifier from the OS, not from a page. None of those has anywhere to put
* a bearer token, and the platform's own `?token=` escape hatch does not help because none of them is
* our code.
*
* The alternative was smuggling a full-power credential through a URL, which is strictly worse: an
* `ofk_` key is authority over the whole platform — files, terminal, vault — and it would end up in a
* manifest, a server log and a phone's install history.
*
* So this is the honest model. These paths really are public, and saying so lets the platform enforce
* exactly which ones rather than having a plugin invent its own bypass.
*
* ── What a plugin is trusted with, and what it is not ──
*
* NOT trusted: which paths. `validateManifest` refuses anything that could reach beyond the plugin's
* own mount, and `assertPluginPublicPaths` re-checks at boot, so a plugin cannot widen its surface by
* editing a file after review.
*
* Trusted: what those paths do. Authorising them is the plugin's job — Kiosk uses a per-user key it
* mints and can rotate, which is a credential scoped to one plugin and one purpose.
*
* ── Rules, each refused rather than corrected ──
*
* - must start with `/`, and is relative to the plugin's mount (`/artefact/…`, never `/api/kiosk/…`)
* - no `..`, no `//`, no query or fragment
* - a wildcard only as a trailing `/*`, never bare `*` and never mid-path
* - a bare `/` or `/*` is refused: that is the whole plugin, which is not a declaration
*
* Absent means the plugin serves nothing without a session, which is what every plugin today wants.
*/
unauthenticatedPaths?: string[];
/**
* TOP-LEVEL paths this plugin serves, above the account gate and outside its own `/api/<name>` mount.
*
* ── Why this exists, when `unauthenticatedPaths` already does ──
*
* Some clients are not ours and cannot be told where to look. A Bitwarden client asked for a
* self-hosted server takes ONE URL and derives `/identity`, `/icons`, `/notifications` and `/events`
* from it itself — at the root, always. Hosting the same thing under a plugin's mount is technically
* equivalent and practically useless: it turns "paste your Officer URL" into five fields copied by hand
* into an advanced settings pane, which is not a thing anyone will do and not a thing to ship.
*
* So this is the escape hatch for a protocol the platform does not define and cannot move. It is
* deliberately narrower than it looks:
*
* - one segment only (`/identity`, never `/a/b`), so a plugin claims a name rather than a namespace
* - refused if it collides with anything the platform serves, checked against a reserved list
* - refused if another installed plugin already claims it
* - every entry logged at boot, by plugin, because a root path answering without a session is not
* something anyone should have to read a manifest to discover
*
* Absent for every plugin but one, and it should stay that way — reach for `unauthenticatedPaths`
* unless a client genuinely cannot be pointed anywhere else.
*/
unauthenticatedRootPaths?: string[];
/**
* Request headers that hand `/api/*` to this plugin instead of to Officer.
*
* The other half of the same problem. Vaultwarden serves `/api/sync`, `/api/ciphers`; Officer serves
* `/api/chat`, `/api/settings`. A Bitwarden client pointed at the bare Officer URL calls the first set
* at the same prefix as the second, and the PATH cannot tell them apart — so the client says who it is,
* and every Bitwarden client stamps `Bitwarden-Client-Name` (or older ones `Device-Type`).
*
* ── This is ROUTING, not authentication ──
*
* Trusting a client-supplied header is fine here, and it is worth being precise about why: the worst a
* forged header achieves is reaching the plugin's own public router, which then demands whatever
* credential it demands. Nothing is authorised by this. An attacker who sets the header does not gain
* access to Officer's API — they lose it, for their own request.
*/
protocolHeaders?: string[];
/**
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
*
* Checked before anything else happens on install: present ones are never named on a command line, and
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
* that runs and quietly produces nothing.
*/
osDependencies?: OsDependency[];
};
/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
export type DiscoveredPlugin = {
/**
* THE id — route segment, table prefix, sidecar suffix, install key.
*
* Taken from the DIRECTORY NAME rather than declared, so the id cannot disagree with where the code
* sits. The cost is that renaming a directory re-identifies the plugin; the benefit is that the two can
* never drift, and a wrong table prefix is a much quieter failure than a missing directory.
*/
appName: string;
/** Absolute path to the plugin's directory. */
dir: string;
manifest: PluginManifest;
/**
* `assets/icon.png` — the dock tile's image, published to `public/plugins/<appName>/` on install.
*
* Null means the plugin ships none and falls back to `manifest.icon`, a lucide NAME. The file wins
* because it has no ceiling: `resolveIcon` knows 106 glyphs out of lucide's ~1,500, and a plugin
* naming one outside that set silently renders a neutral box.
*/
icon: string | null;
/** `api/router.ts` — a backend router, mounted at `mountPrefix`. */
api: string | null;
/** `db/schema.ts` — tables, pushed on install. Every name must be prefixed `<appName>_`. */
schema: string | null;
/** `sidecar/index.{ts,mjs}` — a process for PM2. */
sidecar: { script: string; runtime: 'bun' | 'node' } | null;
/**
* A frontend, as PANELS AND A LAYOUT — never a free-form component.
*
* The rule: every plugin route renders a Workspace with at least one panel. It is enforced by shape
* rather than by review — the plugin does not get to render the screen, it contributes panels and says
* how they are arranged, and the shell renders `WorkspaceView` around them. A plugin that wanted to draw
* something else has nowhere to put it.
*
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
* with a partial frontend.
*/
web: { panels: string; layout: string } | null;
};
/**
* Where a plugin's routes live, on both the API and the frontend.
*
* `publisher` is the only input, deliberately. First-party plugins sit at the root because Officer Dev
* owns that namespace anyway and provenance is then legible at a glance in a log; third-party plugins sit
* under `/p/<publisher>/`, which is what makes it impossible for any plugin to shadow a core route — and
* therefore what lets the platform keep adding core routes forever without breaking an install.
*
* NOTHING else in the codebase may branch on provenance. If that difference leaks past this one function
* — a special case in the router, a bypassed check, a different install branch — first-party and
* third-party become two systems, and only one of them gets tested.
*/
export const FIRST_PARTY_PUBLISHER = 'officerdev';
/**
* Check one declared public path, returning a reason it is refused or null.
*
* Pure and exported so it can be tested directly. Every rule here exists to make one sentence true: **a
* declared path can only ever reach code inside this plugin's own mount.** A plugin that could escape it
* would be declaring the platform's routes public, which is a different and much worse thing than
* declaring its own.
*/
export function publicPathProblem(path: unknown): string | null {
if (typeof path !== 'string' || !path) return 'must be a non-empty string';
if (!path.startsWith('/')) return `"${path}" must start with "/" and be relative to the plugin's mount`;
if (path.includes('..')) return `"${path}" may not contain ".."`;
if (path.includes('//')) return `"${path}" may not contain "//"`;
if (path.includes('?') || path.includes('#')) return `"${path}" may not contain a query or fragment`;
// A wildcard is allowed only as the final segment. `/a/*/b` and `/a*` are both refused: the first is a
// pattern this does not need, and the second is the kind of thing that looks narrow and is not.
const star = path.indexOf('*');
if (star !== -1 && !path.endsWith('/*')) return `"${path}" may only use a wildcard as a trailing "/*"`;
if (path === '/' || path === '/*') return 'the whole plugin cannot be public — declare the paths you mean';
return null;
}
/**
* Top-level names the platform serves itself. A plugin claiming one would shadow it for every request,
* so each is refused by name rather than by ordering luck.
*
* `api` is the important one: it is where everything of Officer's lives, and `protocolHeaders` is the
* only sanctioned way to touch it.
*/
const RESERVED_ROOT = new Set([
'api',
'auth',
'plugins',
'assets',
'build',
'public',
'dav',
'.well-known',
'index.html',
]);
/**
* Check one declared ROOT path, returning a reason it is refused or null.
*
* Stricter than `publicPathProblem` because the surface is: this is the platform's own namespace, not the
* plugin's, so a plugin gets to claim a single name and nothing structural.
*/
export function rootPathProblem(path: unknown): string | null {
if (typeof path !== 'string' || !path) return 'must be a non-empty string';
if (!path.startsWith('/')) return `"${path}" must start with "/"`;
const segment = path.slice(1);
if (!/^[a-z][a-z0-9-]{0,38}$/.test(segment)) {
return `"${path}" must be a single lowercase segment, e.g. "/identity"`;
}
if (RESERVED_ROOT.has(segment)) return `"${path}" is reserved by the platform`;
return null;
}
export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string {
const { appName } = plugin;
return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER
? `/${appName}`
: `/p/${plugin.manifest.publisher}/${appName}`;
}
/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;
/** Every package manager the installer can drive. Anything else in a manifest is refused by name. */
export const PACKAGE_MANAGERS: PackageManager[] = ['apt', 'pacman', 'dnf', 'brew'];
/**
* What may appear in an `osDependencies` entry — and this is the strictest rule in the file, on purpose.
*
* These two strings are the only values in a manifest that reach a root command line. They are passed to
* `Bun.spawn` as an argv array and never through a shell, so a metacharacter could not be interpreted
* even if one got here — but "could not be interpreted by the shell we happen to use today" is not a
* property to rest a root install on. So the characters are not there to be interpreted.
*
* Deliberately narrower than what package managers actually accept. No `+`, no `~`, no `:` — so `apt`'s
* `pkg:arch` qualifier and any version pin are refused. A plugin that needs one wants a conversation,
* not a regex that already allows it.
*/
const BINARY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}$/;
/** Reasons an `osDependencies` list is unusable. Empty when it is fine. */
function osDependencyProblems(deps: unknown): string[] {
if (deps === undefined) return [];
if (!Array.isArray(deps)) return ['osDependencies must be an array when present'];
const problems: string[] = [];
for (const [i, dep] of deps.entries()) {
const at = `osDependencies[${i}]`;
if (!dep || typeof dep !== 'object') {
problems.push(`${at} must be an object`);
continue;
}
const { binary, reason, packages } = dep as Partial<OsDependency>;
if (typeof binary !== 'string' || !BINARY_RE.test(binary)) {
problems.push(`${at}.binary must be a plain command name — letters, digits, dot, dash, underscore`);
}
// Required rather than optional: it is what the owner reads when deciding whether to let this
// machine install something as root, and "ffprobe" alone does not answer that question.
if (typeof reason !== 'string' || !reason.trim()) {
problems.push(`${at}.reason is required — say what stops working without it`);
}
if (!packages || typeof packages !== 'object') {
problems.push(`${at}.packages must be an object keyed by package manager`);
continue;
}
for (const [manager, name] of Object.entries(packages)) {
if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) {
problems.push(
`${at}.packages has unknown package manager "${manager}" — one of ${PACKAGE_MANAGERS.join(', ')}`,
);
continue;
}
if (typeof name !== 'string' || !PACKAGE_RE.test(name)) {
problems.push(`${at}.packages.${manager} must be a plain package name — lowercase, digits, dot, plus, dash`);
}
}
}
return problems;
}
/** A publisher shares the app name's constraints — it is a path segment too. */
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;
/**
* Validate a manifest read off disk. Returns the reasons it is unusable, empty when it is fine.
*
* Returns every problem rather than the first, because an install that fails one field at a time is an
* install someone retries four times.
*/
export function manifestProblems(appName: string, manifest: Partial<PluginManifest> | null): string[] {
const problems: string[] = [];
if (!manifest) return ['no manifest, or it did not export `manifest`'];
if (!APP_NAME_RE.test(appName)) {
problems.push(`directory name "${appName}" must be lowercase letters, digits and dashes, starting with a letter`);
}
if (typeof manifest.publisher !== 'string' || !PUBLISHER_RE.test(manifest.publisher)) {
problems.push('publisher must be lowercase letters, digits and dashes');
}
for (const field of ['version', 'platform', 'label', 'summary', 'color'] as const) {
if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
}
if (manifest.unauthenticatedRootPaths !== undefined) {
if (!Array.isArray(manifest.unauthenticatedRootPaths)) {
problems.push('unauthenticatedRootPaths must be an array of paths');
} else {
for (const [i, path] of manifest.unauthenticatedRootPaths.entries()) {
const problem = rootPathProblem(path);
if (problem) problems.push(`unauthenticatedRootPaths[${i}] ${problem}`);
}
}
}
if (manifest.protocolHeaders !== undefined) {
if (!Array.isArray(manifest.protocolHeaders)) {
problems.push('protocolHeaders must be an array of header names');
} else {
for (const [i, header] of manifest.protocolHeaders.entries()) {
if (typeof header !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(header)) {
problems.push(`protocolHeaders[${i}] must be a lowercase header name`);
}
}
}
}
if (manifest.unauthenticatedPaths !== undefined) {
if (!Array.isArray(manifest.unauthenticatedPaths)) {
problems.push('unauthenticatedPaths must be an array of paths');
} else {
for (const [i, path] of manifest.unauthenticatedPaths.entries()) {
const problem = publicPathProblem(path);
if (problem) problems.push(`unauthenticatedPaths[${i}] ${problem}`);
}
}
}
if (!Array.isArray(manifest.permissions)) {
problems.push('permissions must be an array (use [] when the plugin gates nothing)');
} else {
for (const [i, permission] of manifest.permissions.entries()) {
if (!permission || typeof permission.key !== 'string' || !permission.key) {
problems.push(`permissions[${i}].key is required`);
continue;
}
// The key shares the platform's permission namespace, so a plugin colliding with a built-in one
// would silently widen or narrow it. Prefixing is not enforced here — the installer checks against
// the live registry, which is the only thing that knows what is taken.
if (typeof permission.label !== 'string' || !permission.label) {
problems.push(`permissions[${i}].label is required`);
}
}
}
problems.push(...osDependencyProblems(manifest.osDependencies));
return problems;
}