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>
402 lines
17 KiB
TypeScript
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;
|
|
}
|