Files
platform/src/servers/plugins/marketplace.ts
T
pastilhasandClaude Opus 5 b196c9cb55 soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek
had been switched off since 2026-08-13: hono.ts carried the router import and
its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and
registerProxiedPrefix was never called; officer-slskd was not in the ecosystem
at all. Because the permission entry had been reduced to api: [], the totality
check was satisfied and the server booted clean — the UI rendered and every
request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a
permission, and is silent about a feature that mounts nothing.

Also closes a live data hazard: schema.ts had the soulseek export commented
out, so the three tables were absent from the drizzle barrel and bun db:push
would have dropped soulseek_favorites, soulseek_browse_snapshots and
soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts
documented the workaround rather than the problem; deleted with the rest.

The connection stays in core service_connections under service 'slskd' while
the mount is now /api/soulseek. Those differ on purpose.

Test fixtures: slskd was canonical in three app-store tests, the third
extraction running to break them. installer.test.ts now builds a literal
fixture instead of reaching into the catalogue — it pins planSteps, which is
installer logic, and no catalogue entry is both provisioned and members:'none'
any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea.

Unrelated but mine: check:theme caught 5 palette classes I introduced in
CommandHQ earlier tonight against a baseline of 0. Converted to bg-success /
bg-warning. check:theme is not part of bun test, which is why the green suite
never showed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:55:35 +00:00

402 lines
17 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: 'photos',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Photos',
summary: 'Your Immich library — browse, search, upload from the phone',
color: '#10b981',
icon: 'Images',
source: 'https://gitea.officer.dev/plugins/photos.git',
permissions: [
{
key: 'photos',
label: 'Photos',
description: 'Browse the photo library',
},
],
// No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'transmission',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Transmission',
summary: 'Torrent downloads — queue, files, peers and speed, from your own daemon',
color: '#dc2626',
icon: 'Download',
source: 'https://gitea.officer.dev/plugins/transmission.git',
permissions: [
{
key: 'transmission',
label: 'Transmission',
description: 'Torrent downloads',
},
],
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
has: { api: true, schema: false, sidecar: true, web: true },
},
{
appName: 'soulseek',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Soulseek',
summary: 'Search and download from the Soulseek network, via your own slskd daemon',
color: '#22c55e',
icon: 'Music2',
source: 'https://gitea.officer.dev/plugins/soulseek.git',
permissions: [
{
key: 'soulseek',
label: 'Soulseek',
description: 'Search and download from the Soulseek network',
},
],
// `schema: true` unlike transmission: the CONNECTION lives in core `service_connections`, but the
// favourites and the cached browse tree are this plugin's own three tables.
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;
}