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>
341 lines
15 KiB
TypeScript
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;
|
|
}
|