Files
platform/src/servers/plugins/marketplace.ts
T
pastilhasandClaude Opus 5 5b486ed019 build marketplace icon URLs from HEAD, not from a branch name
Every available plugin drew a broken image on any server reading the remote
catalogue. The URLs said /raw/branch/main/, and four plugin repositories were
created on `master` — so those 404'd, and the others only worked by accident of
which branch somebody happened to use.

The comment above the builder already said "a repo whose default branch is
`master` gets a 404", marked [open]. It then happened. A warning in a comment
is not a guard.

Gitea resolves /raw/HEAD/ to whatever the default branch is, so the question
stops being asked. Verified against both spellings, and all 12 icons in the
live catalogue now answer 200.

It did not show on alpha because every plugin there is INSTALLED, and an
installed plugin's tile is served locally from /plugins/<name>/ rather than
from the catalogue.

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

487 lines
20 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: 'jellyfin',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Video',
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
color: '#a855f7',
icon: 'Clapperboard',
source: 'https://gitea.officer.dev/plugins/jellyfin.git',
permissions: [
{
key: 'jellyfin',
label: 'Video',
description: 'Browse and play the Jellyfin library',
},
],
// Fully self-contained: `jellyfin_servers` is a registry this plugin owns outright, because more
// than one Jellyfin is a thing someone has.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'invoices',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Invoices',
summary: 'InvoiceShelf books — invoices, estimates, customers and payments',
color: '#0ea5e9',
icon: 'ReceiptText',
source: 'https://gitea.officer.dev/plugins/invoices.git',
permissions: [
{
key: 'invoices',
label: 'Invoices',
description: 'InvoiceShelf books',
},
],
// `invoices`, not `invoiceshelf` — the appName drives the URL, and it should name what the plugin
// shows rather than the daemon behind it. See its PLUGIN.md for what that cost.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'bitcoin-wallet',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Bitcoin Wallet',
summary: 'A self-custodial bitcoin wallet — keys sealed on the server, never on a device',
color: '#f7931a',
icon: 'Bitcoin',
source: 'https://gitea.officer.dev/plugins/bitcoin-wallet.git',
permissions: [
{
key: 'bitcoin-wallet',
label: 'Bitcoin Wallet',
description: "Bitcoin wallet — each account gets its own, sealed under that account's own passphrase",
},
],
// Grantable, unlike the `kind: 'admin'` it had in the core registry — every query is scoped by
// user_id, so granting it gives an account its OWN wallet rather than access to anyone else's.
// See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'gitea',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Gitea',
summary: 'Repositories, issues and pull requests — your own Gitea, rendered in Officer',
color: '#609926',
icon: 'GitBranch',
source: 'https://gitea.officer.dev/plugins/gitea.git',
permissions: [
{
key: 'gitea',
label: 'Gitea',
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
},
],
// `schema: false` — the connection lives in core `service_connections`, like transmission's. The web
// half is one iframe over a proxy of Gitea's own UI rather than panels; see its PLUGIN.md.
has: { api: true, schema: false, 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/HEAD/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/HEAD/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;
}