A preference sounds helpful and fails silently. When the wished-for number is taken the plugin is handed a different one, the install succeeds, and whatever was configured against the old number goes on pointing at nothing - with nobody told. This afternoon that nearly happened: 3011 was only free because fofos-dev had been stopped twenty minutes earlier, and had it still been running the site would have been given a random port behind an nginx host still aimed at 3011. So the number is always drawn fresh and always kept. Always drawn means it is never something you can assume; always kept means the proxy host is configured against it exactly once. The failure mode that remains - having to go and read the number - is one that announces itself, which the other one did not. isFree went with it; it existed only to test a wish.
1072 lines
55 KiB
TypeScript
1072 lines
55 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[];
|
|
/**
|
|
* Sub-paths, relative to the plugin's mount prefix, that a READ grant may still mutate because they
|
|
* hold only the CALLER'S OWN data.
|
|
*
|
|
* Caldav is the case that needed it: a member granted read on the calendar must still be able to mint
|
|
* and revoke their own DAV app password, because that password is how their phone reaches their own
|
|
* calendar. Every one of those routes is scoped to the caller's user id in the plugin's own queries —
|
|
* `personal` is the platform agreeing that a write there is not a write to anything shared.
|
|
*
|
|
* Distinct from `readOnlyWrites`, which is for requests that LOOK like writes and are not. These are
|
|
* real writes, to rows nobody else can see.
|
|
*/
|
|
personal?: 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[];
|
|
};
|
|
|
|
/**
|
|
* What a plugin is FOR, in words someone browsing a store already understands.
|
|
*
|
|
* ── Why tags and not a category ──
|
|
*
|
|
* Almost every plugin is more than one thing. Contacts and Calendar is Personal and Degoogling; a
|
|
* torrent client is Homelab and Media; the proxy manager is Server Management and Security. Forcing one
|
|
* category means picking the least wrong, and a store organised by least-wrong answers is one nobody
|
|
* browses twice.
|
|
*
|
|
* ── Why a CLOSED set ──
|
|
*
|
|
* Free-text tags produce `homelab`, `home-lab`, `Home Lab` and `selfhosted` within a month, and a filter
|
|
* list that is mostly synonyms is worse than no filter. Adding a tag is a deliberate edit here, which is
|
|
* the same trade `WELL_KNOWN_CLAIMABLE` makes for a different reason.
|
|
*
|
|
* ── Naming ──
|
|
*
|
|
* These are read by someone who does not know what CardDAV is. `Personal`, not `Personal Assistance`,
|
|
* which sounds like the agent rather than the subject. `Development`, not `Development / Programming`,
|
|
* because a slash reads as two tags and this ends up in a URL. `Degoogling` earns its place by being the
|
|
* term people actually search for.
|
|
*
|
|
* Order is the order they are offered in: what a newcomer recognises first, down to what only some
|
|
* people want.
|
|
*/
|
|
export const PLUGIN_TAGS = [
|
|
'Personal',
|
|
'Homelab',
|
|
'Server Management',
|
|
'Development',
|
|
'Security',
|
|
'Degoogling',
|
|
'Fun',
|
|
] as const;
|
|
|
|
export type PluginTag = (typeof PLUGIN_TAGS)[number];
|
|
|
|
/** 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 | string[]>>;
|
|
/**
|
|
* 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/<owner>` 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';
|
|
/**
|
|
* An architecture this dependency needs enabled before its package can be installed.
|
|
*
|
|
* Debian and Ubuntu only, and it means exactly one thing: run `dpkg --add-architecture <arch>` followed
|
|
* by `apt-get update` before installing, if that architecture is not already enabled.
|
|
*
|
|
* ── Why this is a field and not a package name ──
|
|
*
|
|
* Because it cannot be one. A 32-bit package on a 64-bit host is `wine32:i386`, and installing it fails
|
|
* outright until the host has been told i386 exists — which is a change to the machine's dpkg
|
|
* configuration, not a package to fetch. Minesweeper found this the direct way: `wine` alone installs
|
|
* 64-bit-only, the plugin reports a clean install, and the game dies with `Bad EXE format`.
|
|
*
|
|
* ── Why it is an enum and not a command ──
|
|
*
|
|
* This runs as root. A manifest that could supply the command would be a manifest that can run anything
|
|
* as root on every machine that installs it, which is a far larger grant than "this plugin may install
|
|
* wine". The manifest picks a value from `DPKG_ARCHITECTURES`; the platform owns the sentence it turns
|
|
* into. Same principle as the plugin websocket naming a sidecar rather than a port.
|
|
*
|
|
* It is also SYSTEM-WIDE and does not go away when the plugin does. `plugins:delete` will not disable an
|
|
* architecture something else may now depend on, so this is shown to the owner before they consent
|
|
* rather than mentioned in a log afterwards.
|
|
*
|
|
* Ignored where it means nothing — a pacman or brew host is already multi-arch or has no such concept.
|
|
*/
|
|
multiarch?: DpkgArchitecture;
|
|
/**
|
|
* 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 };
|
|
};
|
|
};
|
|
|
|
/** One field on a plugin's settings form. See `settings` on the manifest for why this is declared. */
|
|
export type PluginSetting = {
|
|
/** The key in the JSON body sent to `<prefix>/config`. */
|
|
key: string;
|
|
/** What the owner sees beside the input. */
|
|
label: string;
|
|
/** One line under it — what this is for, or where to find the value. Optional. */
|
|
hint?: string;
|
|
/** `url` and `text` render as text inputs; `secret` is write-only and renders as a password. */
|
|
type: 'url' | 'text' | 'secret';
|
|
/** Refuse to save without it. */
|
|
required?: boolean;
|
|
/** Shown greyed in an empty input, never submitted. */
|
|
placeholder?: 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;
|
|
/**
|
|
* What this plugin is for — one or more of `PLUGIN_TAGS`, in the store's own words.
|
|
*
|
|
* The plugin declares it and the marketplace caches it, exactly as `label` and `summary` are. The
|
|
* alternative — curating tags in the registry — means editing a table every time a plugin ships, and a
|
|
* plugin that cannot say what it is. A curated OVERRIDE on top will be wanted when third parties
|
|
* arrive, and that is a different field.
|
|
*
|
|
* Optional so no existing plugin breaks; an untagged plugin simply appears under no filter, which is
|
|
* visible in the store rather than silent.
|
|
*/
|
|
tags?: PluginTag[];
|
|
/**
|
|
* 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[];
|
|
|
|
/**
|
|
* The frontend screens this plugin contributes, when it contributes more than one.
|
|
*
|
|
* Omit it and the plugin has exactly one screen at its mount prefix, labelled `label` — which is every
|
|
* plugin but caldav, and the reason nothing else had to change when this arrived.
|
|
*
|
|
* ── Why the label is HERE and the existence is on disk ──
|
|
*
|
|
* Presence is the declaration everywhere else in this file, and it still is: `web/<segment>/panels.ts`
|
|
* is what makes a screen exist. What a directory listing cannot say is what to CALL it and which glyph
|
|
* to draw, and those are exactly what this file is for — a human choice, not a structural fact.
|
|
*
|
|
* The two are cross-checked at discovery and a mismatch is REFUSED, not reconciled: a segment here with
|
|
* no directory, or a directory with no entry here, names a screen that would render nothing or a screen
|
|
* with no tile. Both are the quiet kind of broken.
|
|
*
|
|
* ── The segment is a URL a first-party plugin chooses ──
|
|
*
|
|
* `/calendar` and `/contacts`, not `/caldav/calendar`. That is only sound because `mountPrefix` confines
|
|
* third parties to `/p/<publisher>/` — so a third-party plugin declaring `screens` is refused. Without
|
|
* that check this field would be a way for any installed plugin to shadow a core route.
|
|
*/
|
|
screens?: { segment: string; label: string; icon?: string }[];
|
|
|
|
/**
|
|
* 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[];
|
|
|
|
/**
|
|
* Ports this plugin needs the PLATFORM to allocate and remember.
|
|
*
|
|
* ── Not the same thing as a sidecar's own port ──
|
|
*
|
|
* A sidecar already gets a port, and nothing here changes that: it binds `port: 0`, the kernel picks
|
|
* one, and it announces the number over the registration socket. That port is a private detail between
|
|
* two processes — loopback, re-chosen on every restart, and nothing outside Officer ever needs it.
|
|
*
|
|
* This is for a port something ELSE connects to. A plugin serving a website has to be reachable from
|
|
* nginx, which means the number must survive a restart and must be knowable by whoever writes the proxy
|
|
* configuration. Ephemeral fails both, which is why the first site plugin had to hardcode 3011 and hope.
|
|
*
|
|
* ── What the platform guarantees, and what it does not ──
|
|
*
|
|
* Guaranteed: the same plugin and name always get the same number back, and no two plugins are ever
|
|
* given the same one — that second part is a unique index, not a convention, because a collision is two
|
|
* services fighting over a listener rather than a duplicate row.
|
|
*
|
|
* NOT guaranteed: that the port is still free when the sidecar starts. Something else on the machine
|
|
* can take it while Officer is down. A sidecar that cannot bind its remembered port must fail loudly
|
|
* rather than quietly choose another, because the number is written down where this platform cannot
|
|
* see it.
|
|
*
|
|
* ── How it reaches the sidecar ──
|
|
*
|
|
* As `PORT_<NAME>` in its environment, uppercased, written into the pm2 entry at install. The sidecar
|
|
* reads it rather than being told over the socket, because a socket answer would arrive after the
|
|
* process has already had to decide what to bind.
|
|
*
|
|
* ```ts
|
|
* extraPorts: [{ name: 'site' }] // → PORT_SITE=<n> in the sidecar's environment
|
|
* ```
|
|
*
|
|
* A manifest does NOT get to ask for a particular number, and that is deliberate. A preference sounds
|
|
* helpful and fails silently: when the wished-for port is taken the plugin is handed a different one,
|
|
* the install succeeds, and whatever was configured against the old number keeps pointing at nothing.
|
|
* Nobody is told. Drawing a fresh one every time makes the number always something you had to go and
|
|
* read, which is the state that keeps the configuration honest.
|
|
*
|
|
* Binding is still the sidecar's business, including which interface. A port that exists to be reached
|
|
* from a container cannot be loopback, and the platform has no way to know which a plugin means.
|
|
*/
|
|
extraPorts?: { name: 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/<appName>` a frame relay onto `ws://127.0.0.1:<port>/ws`, where the
|
|
* port is the one the named sidecar announced at registration. 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.
|
|
*
|
|
* The target is a SIDECAR, always, and never a port number a manifest picked.
|
|
*
|
|
* A fixed-port form existed for about an hour and was removed before it shipped anywhere. The hole is
|
|
* total: nothing stops a manifest naming a port it does not own, so `{ port: 5432 }` plus a grant is a
|
|
* raw TCP relay onto Postgres, authenticated by the platform, for anyone that plugin is granted to. `:22`
|
|
* and officer's own port are the same sentence. Checking that the HOST was loopback — which the relay
|
|
* does — is not the same as checking the PORT belongs to the plugin, and only the second one matters.
|
|
*
|
|
* A sidecar's port is trustworthy for a reason a declaration can never be: the platform watched it
|
|
* arrive. The sidecar binds an ephemeral port and announces it at registration, so the association
|
|
* between "this port" and "this plugin" is something the platform observed rather than something a
|
|
* manifest asserted. Provenance, not a claim.
|
|
*
|
|
* The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony — it is the thing
|
|
* that makes the port mean anything. It costs nothing else: the socket lives at `/ws` on the SAME
|
|
* listener the sidecar already serves its routes from, so a plugin does not choose between having a
|
|
* socket and having ordinary HTTP.
|
|
*
|
|
* **The sidecar serves a websocket, not a raw stream.** Whatever it is really talking to — a VNC display,
|
|
* a line protocol, another process entirely — the conversion happens inside the plugin, because the
|
|
* plugin is the only thing that knows. The platform moves frames it does not read.
|
|
*
|
|
* 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.
|
|
*/
|
|
/**
|
|
* Settings the owner must be able to set, rendered by the platform on the plugin's own detail page.
|
|
*
|
|
* ── Why a declaration and not a component ──
|
|
*
|
|
* A plugin with no `web/` has nowhere to put a settings screen — no route, no dock tile, no panel. That
|
|
* is most backend plugins, and it left them configurable only by an HTTP call somebody had to know
|
|
* about. `transcribe` shipped in exactly that state: installed, running, and with no way to tell it
|
|
* where its server was.
|
|
*
|
|
* The plugin could ship a component instead, and that survives settings too complicated to describe.
|
|
* But every backend plugin would then carry a frontend to render one text field, and they would each
|
|
* look slightly different. A declaration means the platform renders one consistent form and the plugin
|
|
* ships no UI at all — which for a URL and a token is the whole requirement.
|
|
*
|
|
* ── The contract ──
|
|
*
|
|
* The platform GETs and PUTs `<mountPrefix>/config`, and the plugin owns what that means. Values are
|
|
* never stored by the platform: it is a form over the plugin's own route, so a plugin that keeps a
|
|
* credential keeps it, and a plugin that validates before saving still does.
|
|
*
|
|
* A field marked `secret` is write-only across the boundary — sent when set, never read back, and
|
|
* rendered as a password input. The plugin returns whether it HAS one, never the value, the same shape
|
|
* the Jellyfin access token already uses.
|
|
*/
|
|
settings?: PluginSetting[];
|
|
|
|
websocket?: { sidecar: string };
|
|
/**
|
|
* File types this plugin can show that the platform cannot.
|
|
*
|
|
* ── Why this exists ──
|
|
*
|
|
* The file browser's thumbnailer reads what sharp and ffmpeg read, and that list is a constant in
|
|
* core. HEIC is the case that forced the question: sharp's bundled libheif has no HEVC decoder, and
|
|
* teaching core to shell out to ImageMagick would mean the platform acquiring a system dependency for
|
|
* one format — which is a plugin's job, not core's.
|
|
*
|
|
* So a plugin may CLAIM extensions. `ls` unions these with core's own, so a file only promises a
|
|
* thumbnail when something can actually produce one; `/thumb` forwards a claimed type to the plugin
|
|
* that claimed it; and the viewer asks for `render` when opening one full size.
|
|
*
|
|
* ── The dependency still points the right way ──
|
|
*
|
|
* Core does not import plugin code and does not know av-club exists. It reads a claim at runtime from
|
|
* whatever is installed and enabled, calls a route by name, and falls back to a file icon when nothing
|
|
* claims the type. Uninstall the plugin and the file browser is exactly what it was.
|
|
*
|
|
* `thumbnail` and `render` are paths on the plugin's own sidecar, called with `?path=<absolute>` —
|
|
* plus `&size=` for a thumbnail and `&width=` for a render. Both must answer image bytes.
|
|
*/
|
|
fileTypes?: {
|
|
/** Lowercase, no leading dot. `['heic', 'heif']`. */
|
|
extensions: string[];
|
|
/** Sidecar path returning a small image. Omit and the platform will not offer thumbnails for these. */
|
|
thumbnail?: string;
|
|
/** Sidecar path returning a full-size displayable image. Omit and the viewer will not offer them. */
|
|
render?: string;
|
|
};
|
|
|
|
/**
|
|
* Platform events this plugin wants to be told about, and where to tell it.
|
|
*
|
|
* `{ 'files:uploaded': '/v1/on-upload' }` — the key is an event from `servers/events/bus.ts`, the
|
|
* value a path on this plugin's own sidecar. Core POSTs `{ event, paths, email }` to it after the
|
|
* operation has already succeeded.
|
|
*
|
|
* **A hook cannot fail the thing that triggered it.** The upload is done and answered before anybody
|
|
* is told; a plugin that is down, slow or erroring gets a line in the log. Nothing here is a way to
|
|
* veto or modify an operation, and that is deliberate — a hook that could refuse an upload would make
|
|
* every upload's reliability the worst of every installed plugin's.
|
|
*/
|
|
hooks?: Partial<Record<string, string>>;
|
|
|
|
/**
|
|
* An MCP server this plugin serves, offered to the agent as its own.
|
|
*
|
|
* ── Why a path and not a URL ──
|
|
*
|
|
* The path is under this plugin's own mount, so `/mcp` becomes `/api/<plugin>/mcp` on THIS Officer.
|
|
* A URL would let a plugin point the agent at any host, and the agent's config carries a token that
|
|
* signs as the owner — so the one field that must not be free-form is the destination.
|
|
*
|
|
* ── Why this exists rather than wrapping the tools ──
|
|
*
|
|
* A plugin that fronts something with its own MCP server — openseo is the first — can either
|
|
* reimplement that vocabulary as Officer tools or hand the real one over. Reimplementing means a
|
|
* second copy that goes stale, and it fails silently: a tool exists upstream that Officer cannot see.
|
|
* Handing it over costs this field.
|
|
*
|
|
* What a wrapper would have bought — authentication, logging, refusal — is already true of the path,
|
|
* because the agent reaches it through Officer's own API with Officer's own gate in front. Wrapping
|
|
* would buy it a second time.
|
|
*/
|
|
mcp?: {
|
|
/** Under the plugin's mount. `/mcp` on a plugin mounted at `/api/openseo` is `/api/openseo/mcp`. */
|
|
path: 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<string, string>;
|
|
|
|
/**
|
|
* 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/<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: {
|
|
screens: DiscoveredScreen[];
|
|
/**
|
|
* `web/file-actions.ts`, when the plugin has one.
|
|
*
|
|
* Presence is the declaration, as with `panels.ts` — the manifest says nothing about it because
|
|
* there is nothing a human choice adds: an action carries its own label and decides for itself
|
|
* whether it applies.
|
|
*
|
|
* Not per screen. The file browser's context menu is the same menu wherever it is mounted, so an
|
|
* action belongs to the plugin.
|
|
*/
|
|
fileActions: string | null;
|
|
} | null;
|
|
};
|
|
|
|
/**
|
|
* One frontend screen: a route, the panels on it, and how they are arranged.
|
|
*
|
|
* ── Why a plugin can have more than one ──
|
|
*
|
|
* A plugin was one screen because the two things it declares — a mount prefix and a frontend — happened to
|
|
* be the same string. Caldav is where that stops holding: one sidecar, one permission, one install, and
|
|
* two screens that have nothing to do with each other on screen. Calendar and Contacts are not sections of
|
|
* one page and forcing them into one would be a worse product than the core screens they replace.
|
|
*
|
|
* ── segment ──
|
|
*
|
|
* `null` for the ordinary plugin, which means "mount at the prefix" and is every plugin but this one. A
|
|
* named segment is a first-party plugin choosing its own URL — `/calendar`, not `/caldav/calendar` — which
|
|
* is only safe because `mountPrefix` still confines third parties to `/p/<publisher>/`. A third-party
|
|
* plugin naming a segment is refused at discovery, and that check is what keeps this from becoming a way
|
|
* to shadow a core route.
|
|
*/
|
|
export type DiscoveredScreen = {
|
|
/** Directory name under `web/`, or null for the flat single-screen form. */
|
|
segment: string | null;
|
|
/** Dock tile label. The manifest's own `label` for the flat form. */
|
|
label: string;
|
|
/** Lucide icon NAME for this screen's tile, falling back to the plugin's. */
|
|
icon?: string;
|
|
/**
|
|
* `assets/<segment>.png` — this screen's own artwork, when it ships some.
|
|
*
|
|
* A single-screen plugin has one tile and `assets/icon.png` is it. Two screens sharing that one image
|
|
* would be two identical tiles side by side, saying nothing about which is which — so a screen may
|
|
* bring its own, found by name for the same reason the icon is: presence is the declaration, and
|
|
* `publishAssets` already copies the whole assets directory.
|
|
*/
|
|
art: string | null;
|
|
panels: string;
|
|
layout: string;
|
|
};
|
|
|
|
/**
|
|
* 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',
|
|
]);
|
|
|
|
/**
|
|
* Names under `/.well-known` a first-party plugin may claim.
|
|
*
|
|
* `.well-known` is IANA's registry namespace (RFC 8615) and the whole point of it is that a name in there
|
|
* means one specific thing — so claiming a name from it is not the platform inventing a URL, it is a
|
|
* plugin answering the question that name asks. `caldav` and `carddav` are RFC 6764's discovery paths:
|
|
* a client is given a bare domain and probes them before it has any credential.
|
|
*
|
|
* An ALLOW-LIST rather than "any name under .well-known", because the namespace also holds things that
|
|
* must never move — `acme-challenge` is how this machine gets its TLS certificates, and a plugin owning
|
|
* it could take the domain. Adding a name here is a deliberate act; there is no pattern that grants one.
|
|
*/
|
|
const WELL_KNOWN_CLAIMABLE = new Set(['caldav', 'carddav']);
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* ── firstParty ──
|
|
*
|
|
* A reserved name is refused for everyone EXCEPT a first-party plugin, and that exception exists because
|
|
* of what "reserved" was recording. `dav` and `.well-known` are in the list because the PLATFORM serves
|
|
* them today, not because they are dangerous in themselves — and when caldav becomes a plugin, the thing
|
|
* the reservation was protecting is the plugin. Refusing it would mean the platform permanently owning
|
|
* routes for a feature it no longer contains.
|
|
*
|
|
* A THIRD-PARTY plugin is still refused every reserved name, and that is the line the owner drew on
|
|
* 2026-08-27: this is fine for a plugin we wrote, and letting a stranger's plugin claim `/dav` — or
|
|
* anything else the platform is trusted to answer — is a question that needs thinking about properly
|
|
* rather than falling out of a flag. `api` stays refused for everyone, first-party included: it is where
|
|
* all of Officer lives and `protocolHeaders` is the only sanctioned way to touch it.
|
|
*/
|
|
export function rootPathProblem(path: unknown, firstParty = false): 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);
|
|
|
|
// A two-segment claim under `.well-known`, which the single-segment rule below would refuse. First-party
|
|
// only and allow-listed by name — see WELL_KNOWN_CLAIMABLE.
|
|
const wellKnown = /^\.well-known\/([a-z][a-z0-9-]{0,38})$/.exec(segment);
|
|
if (wellKnown) {
|
|
if (!firstParty) return `"${path}" is reserved by the platform`;
|
|
if (!WELL_KNOWN_CLAIMABLE.has(wellKnown[1]!)) {
|
|
return `"${path}" is not a claimable .well-known name`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!/^[a-z][a-z0-9-]{0,38}$/.test(segment)) {
|
|
return `"${path}" must be a single lowercase segment, e.g. "/identity"`;
|
|
}
|
|
// `api` is refused even to us. Everything of Officer's lives under it.
|
|
if (segment === 'api') return `"${path}" is reserved by the platform`;
|
|
if (RESERVED_ROOT.has(segment) && !firstParty) 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}$/;
|
|
/**
|
|
* A package name, optionally qualified by an architecture.
|
|
*
|
|
* `wine32:i386` is the motivating case and it is not an edge case: on Debian and Ubuntu a 32-bit package
|
|
* on a 64-bit host can only be named that way. The suffix is drawn from `DPKG_ARCHITECTURES` below rather
|
|
* than being any word, so this stays a closed vocabulary — the point of a narrow regex here is that a
|
|
* manifest cannot get a metacharacter onto an `apt-get` command line, and widening it to `[a-z0-9]+`
|
|
* after the colon would still be safe but would stop being a vocabulary.
|
|
*/
|
|
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}(:(i386|amd64|arm64|armhf|ppc64el|s390x))?$/;
|
|
|
|
/**
|
|
* Architectures `dpkg --add-architecture` accepts, and the only values a manifest may ask to enable.
|
|
*
|
|
* Closed on purpose. `multiarch` turns into a command run as root, so the manifest gets to pick from a
|
|
* list rather than to supply a string — the same reasoning as the package regex one line up, applied to
|
|
* the one field that would otherwise be a way to say "run this as root" out loud.
|
|
*/
|
|
export const DPKG_ARCHITECTURES = ['i386', 'amd64', 'arm64', 'armhf', 'ppc64el', 's390x'] as const;
|
|
export type DpkgArchitecture = (typeof DPKG_ARCHITECTURES)[number];
|
|
|
|
/** 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<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 (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;
|
|
}
|
|
const multiarch = (dep as { multiarch?: unknown }).multiarch;
|
|
if (multiarch !== undefined && !DPKG_ARCHITECTURES.includes(multiarch as never)) {
|
|
// Refused rather than ignored: a typo here silently drops the one step that makes the package
|
|
// installable at all, and the failure surfaces much later as the plugin not working.
|
|
problems.push(`${at}.multiarch must be one of ${DPKG_ARCHITECTURES.join(', ')}`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
// A LIST is allowed, because one binary genuinely can need more than one package. wine is the case:
|
|
// `wine` provides the launcher on PATH and `wine32:i386` provides the 32-bit loader it needs to run
|
|
// an i386 executable, and installing either alone produces a wine that cannot run the thing the
|
|
// plugin came for. Every element is validated the same way and they reach apt as separate argv
|
|
// entries, so a list is no more powerful than the names in it.
|
|
const names = Array.isArray(name) ? name : [name];
|
|
const bad = names.length === 0 || names.some((n) => typeof n !== 'string' || !PACKAGE_RE.test(n));
|
|
if (bad) {
|
|
problems.push(
|
|
`${at}.packages.${manager} must be a plain package name (or a non-empty list of them) — ` +
|
|
`lowercase, digits, dot, plus, dash, optionally with an architecture suffix ` +
|
|
`(${DPKG_ARCHITECTURES.join(', ')})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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.tags !== undefined) {
|
|
if (!Array.isArray(manifest.tags)) {
|
|
problems.push('tags must be an array');
|
|
} else {
|
|
for (const tag of manifest.tags) {
|
|
// Named rather than counted: "tags[2] is invalid" sends the author back to count array entries.
|
|
if (!(PLUGIN_TAGS as readonly string[]).includes(tag)) {
|
|
problems.push(`tag ${JSON.stringify(tag)} is not one of: ${PLUGIN_TAGS.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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, manifest.publisher === FIRST_PARTY_PUBLISHER);
|
|
if (problem) problems.push(`unauthenticatedRootPaths[${i}] ${problem}`);
|
|
}
|
|
}
|
|
}
|
|
if (manifest.extraPorts !== undefined) {
|
|
if (!Array.isArray(manifest.extraPorts)) {
|
|
problems.push('extraPorts must be an array');
|
|
} else {
|
|
const seen = new Set<string>();
|
|
for (const [i, entry] of manifest.extraPorts.entries()) {
|
|
const name = (entry as { name?: unknown } | null)?.name;
|
|
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,30}$/.test(name)) {
|
|
problems.push(`extraPorts[${i}].name must be a lowercase name, e.g. "site"`);
|
|
continue;
|
|
}
|
|
// The name becomes an environment variable, so two that differ only by case would collide there
|
|
// while looking distinct here.
|
|
if (seen.has(name)) problems.push(`extraPorts[${i}] repeats the name "${name}"`);
|
|
seen.add(name);
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|