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; 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 { 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 { 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 { 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; }