Files
platform/src/servers/plugins/manifest.ts
T
pastilhasandClaude Opus 5 09f2cfa267 a plugin can depend on a release binary, and prove it is the right one
osDependencies could only say "this package, from this manager". Gitea's `tea`
is not in any distribution — it is a Go binary from dl.gitea.com — and Debian
has an UNRELATED package of that name: a graphical text editor. So the one
dependency gitea actually wants could not be expressed, and its marketplace
page showed no dependencies at all while music's showed two.

`release: { url, identify }` covers it. {os} and {arch} are filled from the
host in the spelling Go releases use, and the binary is fetched to
~/.local/bin — no root, and nothing a plugin downloaded from the internet
sitting beside what the distribution installed. Written to a temporary name and
moved into place, so a half-downloaded file is never on PATH under the real
one.

`identify` is the part that matters. Bun.which('tea') cannot tell Gitea's CLI
from the text editor, so presence was never the same question as identity —
and reporting the wrong program as satisfied is worse than reporting it
missing, because the install succeeds and every call afterwards fails
strangely. A binary that fails to identify is now `present: false, imposter:
true`.

Found while testing: Bun.which() with no options reads the PATH the PROCESS
started with, which under PM2 frequently excludes ~/.local/bin entirely. A
release binary fetched during an install would have been reported missing
seconds later by stillMissing, failing an install that had just succeeded. The
search now passes PATH explicitly, with ~/.local/bin first.

Verified against the real binaries on this machine: the genuine tea identifies,
a fake that prints text-editor output is caught as an imposter, and both
dl.gitea.com URLs (linux-amd64, darwin-arm64) answer 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:48:18 +00:00

516 lines
25 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>>;
/**
* A release binary, for a dependency no package manager carries.
*
* `packages` covers the ordinary case: the thing is in the distribution and `apt install` fetches it.
* Some tools are not, and the motivating one is Gitea's `tea` — distributed as a Go binary from
* dl.gitea.com, and **Debian has an unrelated package of the same name**, a graphical text editor.
* Declaring `apt: 'tea'` would install the wrong program, report success, and leave every call failing
* strangely.
*
* `{os}` and `{arch}` are substituted from the host: `linux`/`darwin`, `amd64`/`arm64` — the spelling
* Go releases use, since that is what publishes binaries this way.
*
* Downloaded to `~/.local/bin`, not a system directory: this needs no root, and a binary a plugin
* fetched from the internet has no business in `/usr/local/bin` beside things the distribution put
* there.
*/
release?: {
/** e.g. `https://dl.gitea.com/tea/0.9.2/tea-0.9.2-{os}-{arch}` */
url: string;
/**
* How to prove the binary on PATH is THIS one and not a namesake.
*
* Presence is not identity. `Bun.which('tea')` cannot tell Gitea's CLI from the text editor, so
* without this the platform would find a `tea`, report the dependency satisfied, and be wrong. The
* command is run with these arguments and its output must contain `contains`.
*/
identify?: { args: string[]; contains: 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;
/**
* `assets/splash.png` — the app's launch scene, published beside the icon.
*
* The platform itself draws no splash; a plugin has no launch. It is carried because the plugin
* repository is where a product's artwork lives now, and the mobile app that fronts the same
* product needs somewhere to read it from that is not a designer's folder. Presence is the
* declaration, exactly as with the icon.
*
* The pair is deliberate: `assets/icon.png` is a reduction of `assets/splash.png`'s world, and
* keeping them in one place is what stops the two drifting apart.
*/
splash: 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;
}