Files
platform/src/servers/plugins/manifest.ts
T
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00

307 lines
15 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[];
/**
* 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';
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 (!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;
}