// 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[]; /** * Sub-paths, relative to the plugin's mount prefix, that ONLY the owner may reach — at any granted * level, by any method. * * For the administrative corner of a surface that is otherwise worth sharing. Jellyfin's `/_config` * stores a server's admin token and drives account provisioning; the rest of the plugin is exactly what * a Member should have. Without this the choice was the whole plugin or none of it. * * Note what this is NOT. It does not make the PERMISSION owner-only — see the comment above, which * still stands: a permission nobody can delegate is a contradiction. The permission stays fully * grantable and these few paths are marked as administering the plugin rather than using it. * * DECLARED here, ENFORCED by the platform's permission gate. The plugin knows which of its paths are * settings; the platform is the thing that cannot forget to check. A plugin doing this itself would * work until the tenth one added a route and did not, with no boot failure and no warning — the shape * `assertPermissionTotality` exists to prevent. * * Prefix-matched: `/_config` covers `/_config/3/users`. */ ownerOnly?: 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 ; 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>; /** * WHO has to be able to run this. * * `owner` (the default) — only the server owner runs it. Correct for anything an `execution` plugin * uses, since those run as the owner in the owner's home by definition. * * `shared` — every account with a Linux user must be able to run it, because a `confined` plugin's * work happens as THEM. That is a different install target and a different check: a binary in the * owner's `~/.local/bin` is neither on a member's PATH nor guaranteed readable, since reaching it * means traversing the owner's home. * * The distinction was invisible until it was looked for. On alpha the members' binaries "worked" * only because `/home/` happened to be mode 751 — world-traversable — so an absolute path * resolved. Mode 750 would have broken every member silently, and no probe would have noticed, * because the probe only ever asked the owner. * * See `docs/plugin-dependency-scope.md`. */ scope?: 'owner' | 'shared'; /** * 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/` 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. */ /** * A websocket this plugin serves, relayed by the platform. * * Declaring this makes `/api/plugin-ws/` a byte relay onto the address below. It exists because * Bun's route table is fixed at `serve()`, so a plugin installed afterwards could never add a socket route * — which is why `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a * display at all. * * Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix. * There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing * can drift out of step because there is only one decision. * * `{ port }` for a fixed loopback port the plugin's own service listens on. `{ sidecar }` when the target * is a sidecar, whose port is ephemeral and announced at registration — a manifest cannot know that * number, which is the whole reason the indirection exists. * * The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a * request-forgery primitive reachable by anyone the plugin is granted to. */ websocket?: { port: number } | { sidecar: string }; osDependencies?: OsDependency[]; /** * npm packages this plugin imports. Absent means it needs none, which is every plugin but Atelier. * * Ranges, not pins, and deliberately: every plugin's declarations are merged into ONE shared install at * `plugins/node_modules/`, so two plugins asking for `^0.33.5` and `^0.33.9` share one copy rather than * carrying 50 MB of the same platform binaries twice. Where ranges differ they are intersected — both * plugins have to work — so incompatible requirements fail the install loudly instead of handing one * plugin a version the other cannot use. * * This is the one place in the codebase where a dependency is resolved at install time rather than from * a committed lockfile. That is a real cost against the rule `CLAUDE.md` states, taken knowingly: the * alternative is a lockfile per plugin and no sharing at all. What it buys back is that every package a * plugin can pull is DECLARED here, visible before install, and the merged file is regenerated from * these manifests rather than edited — so nothing can appear in it without a plugin having asked. * * See `npm-deps.ts`. */ npmDependencies?: Record; /** * This plugin creates accounts in its upstream service, one per Officer account. * * Declared HERE rather than asserted at registration, so it is reviewed when the owner installs the * plugin and shown beside the permissions — "this plugin will be told who your users are" is exactly * the sort of thing a person wants on the install screen rather than discovered afterwards. * * What it buys the plugin: on registering, its sidecar is sent the account list — id, email, username, * name, role, owner excluded. See `listProvisionableUsers`, which is a hand-written projection so a * password hash cannot reach a plugin by accident. * * See plugins/USER_PROVISIONING.md for what a plugin is then expected to do with it. */ userProvisioning?: boolean; }; /** 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//` 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 `_`. */ 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//`, 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, scope } = dep as Partial; 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 (scope !== undefined && scope !== 'owner' && scope !== 'shared') { problems.push(`${at}.scope must be "owner" or "shared" when present`); } 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 | 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`); } // A leading slash is not cosmetic: these are matched against the path left after the plugin's own // prefix is stripped, which always starts with one. `_config` would match nothing and would do it // silently — an owner-only route that is quietly reachable by everyone is the worst possible way to // get this wrong, so it is a manifest error rather than a surprise. for (const [j, sub] of (permission.ownerOnly ?? []).entries()) { if (typeof sub !== 'string' || !sub.startsWith('/')) { problems.push(`permissions[${i}].ownerOnly[${j}] must be a path starting with "/"`); } } } } problems.push(...osDependencyProblems(manifest.osDependencies)); return problems; }