Files
platform/src/servers/plugins/marketplace.ts
T
pastilhasandClaude Opus 5 f6a1194905 vault, extracted into plugins/vault — the platform half
Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.

Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── The one new piece of platform: src/servers/account-lifecycle.ts ──

Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.

Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)

Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.

── Two things deleted rather than carried, both dark since 2026-08-13 ──

The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.

The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.

── Tests ──

Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.

bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.

Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.

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

341 lines
15 KiB
TypeScript

import type { OsDependency, PluginPermission } from './manifest';
// What the platform could install but does not have yet.
//
// ── Why this exists ──
//
// Discovery answers "what is on this machine". That was enough while every plugin shipped inside the
// platform's own repository, because being on disk and being available were the same fact. They stopped
// being the same fact on 2026-08-15, when music moved to gitea.officer.dev/plugins/music and left the
// tree entirely: it is real, installable and completely invisible to `discoverPlugins`.
//
// So there are three states now, not two:
//
// available in the catalogue, not on disk — nothing here yet; clone it first
// on disk a directory, no `plugin_installs` row — a developer working on one
// installed both — mounted, running
//
// ── Why a local constant rather than a fetch ──
//
// This is deliberately a stand-in for marketplace.officer.dev, and it is shaped as the wire format that
// marketplace would return rather than as something convenient for a TypeScript file. When the real one
// exists, `fetchCatalogue()` replaces the constant and nothing above it changes. That is the whole point
// of writing it this way now: the seam is chosen while it costs nothing.
//
// ── Why the entry repeats the manifest ──
//
// A local plugin's capabilities are read from its directory — `api/router.ts` present means it has
// routes. A remote one has no directory to read, so everything the plugins page shows BEFORE installing
// has to be declared here. That includes the two things worth seeing before consenting to an install:
// which permissions it wants, and which host binaries it will make the machine acquire.
//
// The duplication is real and it is the price of describing something you do not have. It is checked
// rather than trusted: after a clone, the plugin's own manifest is what the platform reads, and a
// catalogue that lied about it is a catalogue bug, not a new source of truth.
/** One plugin as the marketplace describes it. The manifest, plus where to get it. */
export type CatalogueEntry = {
/** The directory it will be cloned into, and its id everywhere else. */
appName: string;
publisher: string;
version: string;
platform: string;
label: string;
summary: string;
color: string;
tile?: 'badge' | 'bare';
/**
* An absolute URL, unlike a manifest's `icon`, which is a lucide NAME or an `assets/icon.png` that
* only exists once the plugin is on disk. A marketplace has to show artwork for something the machine
* has never seen, so the catalogue carries a link rather than a file.
*/
iconUrl?: string;
/**
* A lucide NAME, for a plugin that ships no artwork. Mirrors `manifest.icon`, and the same precedence
* applies: `iconUrl` wins where both are given. Without this a glyph plugin draws the generic puzzle
* piece in the store and its real icon only after install, which reads as the icon having changed.
*/
icon?: string;
/**
* Where to clone from. HTTPS, never SSH: a marketplace serves the same URL to every machine, and an
* `ssh://` clone assumes a key on that host for that user — true on the machine that published the
* plugin and false everywhere else. Anonymous HTTPS also keeps credentials out of the install path
* entirely, which is the property that makes a public marketplace simple.
*
* `[open]` A private or paid plugin needs authentication here, and that is a different feature: the
* credential has to live somewhere the platform can read and must never appear in this catalogue.
*/
source: string;
/** What the owner is agreeing to, shown before the button rather than discovered afterwards. */
permissions: PluginPermission[];
/** Host binaries the install will acquire. Same reasoning — visible before consent. */
osDependencies?: OsDependency[];
/** What it ships. Read from the directory for a local plugin; declared here for one that is not. */
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
};
/**
* What this machine knows without asking anyone.
*
* This WAS the catalogue until marketplace.officer.dev existed. It is now the floor under it: the
* answer when the store cannot be reached and nothing has been cached yet.
*
* Kept rather than deleted, and kept CURRENT, because the alternative is a first-boot machine with no
* internet showing an empty store — which is indistinguishable from a store with nothing in it. Three
* entries is a small price for "the plugins page always has something true on it".
*
* It will drift from the real catalogue, and that is acceptable in exactly one direction: it may be
* missing something new. It must never describe a plugin that no longer exists, because that produces
* an install button that fails at clone time.
*/
const BUNDLED: CatalogueEntry[] = [
{
appName: 'email',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Email',
summary: 'IMAP and Gmail accounts, synced locally and searchable offline',
color: '#38bdf8',
icon: 'Mail',
source: 'https://gitea.officer.dev/plugins/email.git',
permissions: [
{
key: 'email',
label: 'Email',
description: 'Mail accounts and messages',
},
],
// Email ships all four: the proxy router, `email_accounts`, the sync sidecar, and the mail panels.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'vault',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Vault',
summary: 'Your Vaultwarden, behind an Officer session — passwords for the apps that speak Bitwarden',
color: '#175ddc',
icon: 'KeyRound',
source: 'https://gitea.officer.dev/plugins/vault.git',
permissions: [
{
key: 'vault',
label: 'Vault',
description: 'The password vault, and the Officer-held session that opens it',
},
],
// No osDependencies: the sidecar is a Bun HTTP/WS proxy and reaches its upstream with `fetch`.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'music',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Music',
summary: 'The music library — browse, play, favourites and playlists',
color: '#22c55e',
// Artwork rather than a glyph, so the tile draws no coloured square behind it.
tile: 'bare',
iconUrl: 'https://gitea.officer.dev/plugins/music/raw/branch/main/assets/icon.png',
source: 'https://gitea.officer.dev/plugins/music.git',
permissions: [
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
],
osDependencies: [
{
binary: 'ffprobe',
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
{
binary: 'ffmpeg',
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
],
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'offscale',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
color: '#818cf8',
// Ships artwork as of 2026-08-15, so the tile is `bare` like music's. `icon` stays as the fallback
// for the case the assets fail to publish — the glyph is strictly better than the generic box.
tile: 'bare',
iconUrl: 'https://gitea.officer.dev/plugins/offscale/raw/branch/main/assets/icon.png',
icon: 'Network',
source: 'https://gitea.officer.dev/plugins/offscale.git',
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes, keys and ACLs',
readOnlyWrites: ['/ssh-test', '/policy/assist'],
},
],
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'example',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Example',
summary: 'The reference plugin — one route, nothing else',
color: '#94a3b8',
icon: 'Puzzle',
source: 'https://gitea.officer.dev/plugins/example.git',
permissions: [{ key: 'example', label: 'Example', description: 'The reference plugin' }],
// No `db/` — the one part it deliberately omits, so the smallest real plugin stays the smallest.
has: { api: true, schema: false, sidecar: true, web: true },
},
];
// ── Reaching the real one ──
/** Where the catalogue lives. Overridable so a fork, an air-gapped install or a test can point elsewhere. */
const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev';
/**
* How long a good answer is reused before asking again.
*
* Long enough that opening the plugins page repeatedly does not hammer the store, short enough that a
* plugin published this morning shows up this morning. The cache is per PROCESS and dies with it — a
* restart is always a fresh look.
*/
const CACHE_TTL_MS = 5 * 60 * 1000;
/**
* How long to wait for the store before giving up on it.
*
* The plugins page is blocked on this call, so the ceiling is "how long may that page take to render
* when marketplace.officer.dev is unreachable". Four seconds is already at the edge of what a page can
* spend before it feels broken — and a DNS black hole would otherwise hang it for the OS timeout.
*/
const FETCH_TIMEOUT_MS = 4000;
let cache: { entries: CatalogueEntry[]; at: number } | null = null;
/**
* A catalogue entry drives `git clone`, so nothing arrives from the network untyped.
*
* This is not defence against a hostile marketplace — a store that serves a malicious `source` has
* already won, since the owner asked to install what it lists. It is defence against a WRONG one: a
* half-deployed API, a proxy serving an error page as JSON, a field renamed on one side. Every one of
* those otherwise reaches the install path as `undefined` and fails somewhere far less legible.
*
* Entries that fail are dropped individually rather than failing the whole response: one malformed
* plugin should not empty the store.
*/
function isEntry(value: unknown): value is CatalogueEntry {
if (!value || typeof value !== 'object') return false;
const e = value as Record<string, unknown>;
return (
typeof e.appName === 'string' &&
e.appName.length > 0 &&
typeof e.source === 'string' &&
e.source.startsWith('https://') &&
typeof e.label === 'string' &&
typeof e.summary === 'string' &&
Array.isArray(e.permissions) &&
typeof e.has === 'object' &&
e.has !== null
);
}
/**
* Everything the marketplace offers.
*
* Three answers in preference order — live, cached, bundled — and the page cannot tell which it got.
* That is deliberate: the plugins page's job is to show what can be installed, and it can always show
* something true. The alternative, an error state on the store being unreachable, makes an owner's own
* installed plugins unmanageable because a website is down.
*
* Never throws. `catalogueEntry` below is on the install path, and an install that fails because the
* catalogue lookup threw would be reported as "that plugin does not exist".
*/
export async function fetchCatalogue(): Promise<CatalogueEntry[]> {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.entries;
try {
const res = await fetch(`${MARKETPLACE_URL}/api/plugins`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`marketplace responded ${res.status}`);
const body: unknown = await res.json();
if (!Array.isArray(body)) throw new Error('marketplace returned a non-array');
const entries = body.filter(isEntry);
const dropped = body.length - entries.length;
if (dropped > 0) console.warn(`[marketplace] dropped ${dropped} malformed entr(y/ies)`);
// An empty valid response is cached like any other: a store may legitimately list nothing, and
// treating empty as failure would pin a stale cache forever the first time it happened.
cache = { entries, at: Date.now() };
return entries;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
if (cache) {
console.warn(`[marketplace] unreachable (${reason}) — serving the cached catalogue`);
return cache.entries;
}
console.warn(`[marketplace] unreachable (${reason}) — serving the bundled catalogue`);
return BUNDLED;
}
}
/** One entry by app name, or null. Used by install to find where to clone from. */
export async function catalogueEntry(appName: string): Promise<CatalogueEntry | null> {
const all = await fetchCatalogue().catch(() => BUNDLED);
return all.find((e) => e.appName === appName) ?? null;
}
/**
* A plugin's README, from the marketplace's single-entry endpoint.
*
* Separate from `fetchCatalogue` because the list endpoint deliberately does not carry READMEs — three
* of them is already 38KB of markdown nobody has asked to read, on the request that draws the tiles.
*
* Fetched by the SERVER rather than by the browser, though CORS would allow either. An Officer client is
* routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The
* server has the internet connection that already had to work for the catalogue itself, so routing
* through it means the README appears in exactly the cases the plugin list did.
*
* Null on any failure. A missing README is a section that does not render, never an error state on a
* page whose actual job is installing things.
*/
export async function fetchReadme(appName: string): Promise<string | null> {
try {
const res = await fetch(`${MARKETPLACE_URL}/api/plugins/${encodeURIComponent(appName)}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { accept: 'application/json' },
});
if (!res.ok) return null;
const body: unknown = await res.json();
if (!body || typeof body !== 'object') return null;
const readme = (body as { readme?: unknown }).readme;
return typeof readme === 'string' && readme.length > 0 ? readme : null;
} catch {
return null;
}
}
/** Testing seam. The cache is process-lifetime, so a test that does not clear it leaks into the next. */
export function clearCatalogueCache(): void {
cache = null;
}