marketplace.officer.dev: the catalogue becomes a service

The plugins page read a TypeScript constant. It now reads a public API backed by
its own database, and falls back to that constant when it cannot.

── Four pieces ──

`src/databases/marketplace_db` — a second Postgres database, `officer_marketplace`.
Two tables, split by owner and lifetime: `plugins` is the registry (app name,
developer, clone URL) and is authoritative; `plugin_metadata` is a CACHE of what
each repository says about itself, so the website can render without cloning three
repos per page load. Keeping them apart is what makes the cache safe to be wrong —
a refresh can never lose the fact that a plugin exists.

`scripts/marketplace-sync.ts` — fills it from the plugin repositories. Reads each
`manifest.ts` (imported, since a manifest is TypeScript), derives `has` from the
directory rather than trusting a declaration, and rewrites SSH remotes to HTTPS:
`example` and `offscale` had `git@gitea…` origins, which would have handed every
installer a URL that only works on the machine that published it.

`src/servers/marketplace/server.tsx` — its own process, its own port, two GET
routes and no others. Not part of the platform server: it has no users, no
sessions and no request it answers differently per caller, and mounting a public
unauthenticated surface inside the process holding mail, a wallet and passkeys
means writing permission-gate exemptions that later turn out to be too wide.

`src/apps/marketplace` — the public site. A window, deliberately: nothing here
installs anything, because installing means cloning a repo and starting a process
on someone else's machine. Every plugin page ends at "install it from your own
Officer".

── The platform side is the part that had to be careful ──

`fetchCatalogue()` now answers live → cached → bundled, in that order, and never
throws. The plugins page is where an owner manages plugins they ALREADY have, so
the store being down must not take that page with it. Network responses are
validated per entry — not against a hostile marketplace, which has already won if
the owner installs what it lists, but against a wrong one: a proxy serving HTML,
a half-deployed API, a field renamed on one side. A non-https `source` is refused
outright because that string reaches `git clone`.

8 tests on the degradation path. Verified end to end against the running service:
live catalogue, cached answer surviving the store dying, bundled fallback.

── One thing found on the way ──

`bunfig.toml` and CLAUDE.md both document `bun install --no-frozen-lockfile` as
the way to change a dependency. Bun 1.3.14 has no such flag — only
`--frozen-lockfile` — so the documented escape hatch does not work. Sidestepped by
not making marketplace_db a workspace member (relative imports, zero lockfile
change), and `bun install` is frozen-clean again. Worth fixing the docs.

tsgo clean, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:07:49 +00:00
co-authored by Claude Opus 5
parent 8c66042e42
commit 921fbff9fe
15 changed files with 1202 additions and 10 deletions
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bun
import { existsSync, readFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { upsertMetadata, upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries';
import { discoverPlugins } from '../src/servers/plugins/discover';
// Fill the marketplace database from the plugin repositories.
//
// ── What this reads, and why it is not the marketplace's own opinion ──
//
// Every field except "where to clone from" comes out of the plugin's `manifest.ts` and its README. The
// marketplace stores a COPY so the website can render without cloning three repositories per page load;
// the manifest stays the source of truth, and the platform re-reads it after cloning. A row here that
// disagrees with the repository is a stale cache, not a second opinion — which is why re-running this is
// always safe and never merges: it overwrites.
//
// ── Reading the manifest by importing it ──
//
// A manifest is TypeScript, not JSON, so it is imported and evaluated rather than parsed. That is code
// execution, and it is fine for OUR repositories and not fine as a general mechanism.
//
// `[blocker for third-party submissions]` A stranger's plugin cannot be imported by the machine that
// serves the catalogue. Whoever builds submissions has to either evaluate manifests in a sandbox or make
// authors publish a `manifest.json` alongside. Recorded rather than solved: nobody can submit yet.
//
// ── Local clones, for now ──
//
// It syncs from `plugins/` on this machine rather than by cloning each repo into a temp directory. That
// keeps tonight's scope to "get real data into the database" — and it means the machine running the sync
// must have the plugins checked out. See `docs/marketplace.md` → "Sync" for the version that clones.
type Sync = { appName: string; developer: string; repoUrl: string };
/**
* A clone URL the whole internet can use.
*
* `plugins/example` and `plugins/offscale` have `git@gitea.officer.dev:plugins/<name>.git` remotes,
* because whoever set them up had a key on that host. Publishing that to a catalogue would hand every
* installer a URL that only works on the machine it was published from — the install fails at
* authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong".
*/
export function toHttpsRemote(remote: string): string | null {
const trimmed = remote.trim();
if (!trimmed) return null;
if (trimmed.startsWith('https://')) return trimmed;
// git@host:path/to/repo.git → https://host/path/to/repo.git
const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
if (scp) return `https://${scp[1]}/${scp[2]}`;
// ssh://git@host/path.git → https://host/path.git
if (trimmed.startsWith('ssh://')) {
try {
const url = new URL(trimmed);
return `https://${url.host}${url.pathname}`;
} catch {
return null;
}
}
return null;
}
const git = (dir: string, args: string[]): string | null => {
const result = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
if (result.exitCode !== 0) return null;
return new TextDecoder().decode(result.stdout).trim() || null;
};
const PLUGINS_DIR = resolve(import.meta.dir, '..', 'plugins');
async function syncOne(dir: string): Promise<Sync | null> {
const appName = basename(dir);
const manifestPath = join(dir, 'manifest.ts');
if (!existsSync(manifestPath)) {
console.warn(`[marketplace-sync] ${appName}: no manifest.ts — skipped`);
return null;
}
const remote = git(dir, ['remote', 'get-url', 'origin']);
const repoUrl = remote ? toHttpsRemote(remote) : null;
if (!repoUrl) {
console.warn(`[marketplace-sync] ${appName}: no usable https remote (${remote ?? 'none'}) — skipped`);
return null;
}
const { manifest } = (await import(manifestPath)) as { manifest: Record<string, unknown> };
if (!manifest) {
console.warn(`[marketplace-sync] ${appName}: manifest.ts exports no \`manifest\` — skipped`);
return null;
}
// What the plugin SHIPS, read from the directory rather than declared — the same rule discovery
// follows, so a catalogue entry cannot claim a sidecar the repository does not have.
const has = {
api: existsSync(join(dir, 'api', 'router.ts')),
schema: existsSync(join(dir, 'db', 'schema.ts')),
sidecar: existsSync(join(dir, 'sidecar', 'index.ts')) || existsSync(join(dir, 'sidecar', 'index.mjs')),
web: existsSync(join(dir, 'web')),
};
const readmePath = ['README.md', 'readme.md'].map((n) => join(dir, n)).find((p) => existsSync(p));
const readme = readmePath ? readFileSync(readmePath, 'utf-8') : null;
const developer = String(manifest.publisher ?? 'unknown');
const pluginId = await upsertPlugin({ appName, developer, repoUrl });
await upsertMetadata(pluginId, {
version: String(manifest.version ?? '0.0.0'),
platform: String(manifest.platform ?? '>=1.0.0'),
label: String(manifest.label ?? appName),
summary: String(manifest.summary ?? ''),
color: (manifest.color as string) ?? null,
tile: (manifest.tile as string) ?? null,
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
// claimed when the file is actually there — a broken image is worse than a glyph.
iconUrl: existsSync(join(dir, 'assets', 'icon.png')) ? rawUrl(repoUrl, 'assets/icon.png') : null,
icon: (manifest.icon as string) ?? null,
permissions: manifest.permissions ?? [],
osDependencies: manifest.osDependencies ?? null,
has,
readme,
sourceCommit: git(dir, ['rev-parse', 'HEAD']),
});
console.log(
`[marketplace-sync] ${appName} ← ${repoUrl}${readme ? '' : ' (no README)'}${
has.web ? '' : ' (no web/)'
}`,
);
return { appName, developer, repoUrl };
}
/**
* A raw-file URL for a Gitea repository.
*
* `[open]` Gitea-shaped and branch-`main`-shaped. A GitHub-hosted plugin needs the other form, and a
* repo whose default branch is `master` gets a 404. Fine while every plugin is on our own Gitea; the
* moment one is not, this needs the forge to be part of the registry rather than inferred.
*/
function rawUrl(repoUrl: string, path: string): string {
return `${repoUrl.replace(/\.git$/, '')}/raw/branch/main/${path}`;
}
const dirs = existsSync(PLUGINS_DIR)
? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })].map((d) => join(PLUGINS_DIR, d))
: [];
if (dirs.length === 0) {
console.log(`[marketplace-sync] no plugin directories under ${PLUGINS_DIR} — nothing to sync`);
process.exit(0);
}
// Discovery is not used to FIND them — a plugin that fails discovery is exactly the kind of thing a
// catalogue should still be able to describe — but its broken list is worth surfacing here.
const { broken } = await discoverPlugins().catch(() => ({ broken: [] as { appName: string; error: string }[] }));
for (const { appName, error } of broken) console.warn(`[marketplace-sync] ${appName} is broken on disk: ${error}`);
const synced: Sync[] = [];
for (const dir of dirs) {
const result = await syncOne(dir).catch((err) => {
console.error(`[marketplace-sync] ${basename(dir)} failed: ${err instanceof Error ? err.message : err}`);
return null;
});
if (result) synced.push(result);
}
console.log(`[marketplace-sync] ${synced.length}/${dirs.length} plugin(s) in the catalogue`);
process.exit(0);
+234
View File
@@ -0,0 +1,234 @@
import { Link, Route, Routes, useParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useCatalogue, usePlugin, type CatalogueEntry, type Load } from './catalogue';
/**
* The public catalogue.
*
* It is a WINDOW, and that is the whole design. Nothing here installs anything: installing means cloning
* a repository, pushing a schema and starting a process on someone's own machine, and a website has no
* business being able to do that. So every plugin page ends at the same place — copy the app name, open
* your own Officer, install it there.
*
* That constraint is what keeps this side simple enough to be public: no accounts, no sessions, no write
* routes, nothing to compromise. The interesting security property of this store is that there is
* nothing here worth attacking.
*/
export const App = () => (
<div className="min-h-screen">
<Header />
<Routes>
<Route path="/" element={<CataloguePage />} />
<Route path="/plugins/:appName" element={<PluginPage />} />
<Route path="*" element={<Missing />} />
</Routes>
<Footer />
</div>
);
const Header = () => (
<header className="border-b border-line">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-5">
<Link to="/" className="flex items-baseline gap-2 no-underline">
<span className="text-xl font-bold tracking-tight text-duck-yellow">officer</span>
<span className="text-xl font-light text-body">marketplace</span>
</Link>
<a
href="https://officer.dev"
className="text-sm text-faint no-underline transition-colors hover:text-body"
target="_blank"
rel="noreferrer"
>
officer.dev →
</a>
</div>
</header>
);
const Footer = () => (
<footer className="mt-24 border-t border-line">
<div className="mx-auto max-w-5xl px-6 py-8 text-xs text-faint">
Plugins are installed from your own Officer server. This site only lists them.
</div>
</footer>
);
/** One place where loading, empty and broken are told apart — see `useJson` for why that matters. */
function Pending<T>({ load, children }: { load: Load<T>; children: (data: T) => React.ReactNode }) {
if (load.state === 'loading') return <p className="px-6 py-16 text-center text-faint">Loading…</p>;
if (load.state === 'error') return <p className="px-6 py-16 text-center text-duck-orange">{load.message}</p>;
return <>{children(load.data)}</>;
}
const CataloguePage = () => {
const load = useCatalogue();
return (
<main className="mx-auto max-w-5xl px-6 py-12">
<h1 className="text-3xl font-semibold text-body">Plugins</h1>
<p className="mt-2 max-w-2xl text-faint">
Everything here is free and open. Install from the marketplace inside your own Officer — nothing is
installed from this page.
</p>
<Pending load={load}>
{(plugins) =>
plugins.length === 0 ? (
<p className="mt-16 text-center text-faint">No plugins listed yet.</p>
) : (
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{plugins.map((plugin) => (
<PluginCard key={plugin.appName} plugin={plugin} />
))}
</div>
)
}
</Pending>
</main>
);
};
const PluginCard = ({ plugin }: { plugin: CatalogueEntry }) => (
<Link
to={`/plugins/${plugin.appName}`}
className="group flex gap-4 rounded-xl border border-line bg-ink-soft p-5 no-underline transition-colors hover:border-duck-teal"
>
<Icon plugin={plugin} />
<div className="min-w-0">
<h2 className="truncate font-medium text-body group-hover:text-duck-yellow">{plugin.label}</h2>
<p className="mt-1 line-clamp-2 text-sm text-faint">{plugin.summary}</p>
<p className="mt-3 text-xs text-faint">
{plugin.publisher} · v{plugin.version}
</p>
</div>
</Link>
);
/**
* Artwork if the repository ships it, a coloured initial if not.
*
* No lucide here on purpose. The platform can draw `icon: 'Network'` because it bundles the icon set;
* pulling that whole dependency into a public page to render three glyphs is not a trade worth making,
* and a letter in the plugin's own colour is honest about what it is.
*/
const Icon = ({ plugin }: { plugin: CatalogueEntry }) =>
plugin.iconUrl ? (
<img
src={plugin.iconUrl}
alt=""
className="h-12 w-12 shrink-0 rounded-lg object-cover"
// A catalogue must survive its own artwork 404ing — a broken-image glyph next to every plugin is
// worse than no image at all.
onError={(ev) => {
ev.currentTarget.style.display = 'none';
}}
/>
) : (
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg text-lg font-semibold text-ink"
style={{ background: plugin.color }}
>
{plugin.label.charAt(0).toUpperCase()}
</div>
);
const PluginPage = () => {
const { appName } = useParams<{ appName: string }>();
const load = usePlugin(appName ?? '');
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link to="/" className="text-sm text-faint no-underline hover:text-body">
← All plugins
</Link>
<Pending load={load}>
{(plugin) => (
<>
<div className="mt-6 flex gap-5">
<Icon plugin={plugin} />
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-body">{plugin.label}</h1>
<p className="mt-1 text-faint">{plugin.summary}</p>
<p className="mt-2 text-xs text-faint">
{plugin.publisher} · v{plugin.version} · requires Officer {plugin.platform}
</p>
</div>
</div>
<Install appName={plugin.appName} source={plugin.source} />
{plugin.permissions.length > 0 && (
<section className="mt-10">
<h2 className="text-sm font-semibold uppercase tracking-wider text-faint">Permissions it asks for</h2>
<ul className="mt-3 space-y-2">
{plugin.permissions.map((permission) => (
<li key={permission.key} className="rounded-lg border border-line bg-ink-soft p-3">
<span className="text-sm text-body">{permission.label}</span>
<p className="mt-0.5 text-sm text-faint">{permission.description}</p>
</li>
))}
</ul>
</section>
)}
{plugin.osDependencies && plugin.osDependencies.length > 0 && (
<section className="mt-8">
<h2 className="text-sm font-semibold uppercase tracking-wider text-faint">
Software it installs on your machine
</h2>
<ul className="mt-3 space-y-2">
{plugin.osDependencies.map((dep) => (
<li key={dep.binary} className="rounded-lg border border-line bg-ink-soft p-3">
<code className="text-sm text-duck-teal">{dep.binary}</code>
<p className="mt-0.5 text-sm text-faint">{dep.reason}</p>
</li>
))}
</ul>
</section>
)}
{plugin.readme ? (
<section className="markdown mt-12 border-t border-line pt-8">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{plugin.readme}</ReactMarkdown>
</section>
) : (
<p className="mt-12 border-t border-line pt-8 text-sm text-faint">
This plugin has no README yet.
</p>
)}
</>
)}
</Pending>
</main>
);
};
/** What to do with a plugin you want. Not a button — see the note on `App`. */
const Install = ({ appName, source }: { appName: string; source: string }) => (
<div className="mt-8 rounded-xl border border-line bg-ink-soft p-5">
<h2 className="text-sm font-semibold text-body">Installing it</h2>
<p className="mt-2 text-sm text-faint">
Open your own Officer, go to <span className="text-body">Marketplace</span>, and install{' '}
<code className="text-duck-teal">{appName}</code> from there. Your server clones it directly from the
repository below — nothing passes through this site.
</p>
<a
href={source.replace(/\.git$/, '')}
target="_blank"
rel="noreferrer"
className="mt-3 inline-block break-all font-mono text-xs text-faint no-underline hover:text-duck-teal"
>
{source}
</a>
</div>
);
const Missing = () => (
<main className="mx-auto max-w-3xl px-6 py-24 text-center">
<p className="text-faint">Nothing here.</p>
<Link to="/" className="mt-4 inline-block text-duck-yellow no-underline">
Back to the catalogue
</Link>
</main>
);
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
/**
* The wire format, restated for the browser.
*
* Third copy of this shape — the platform has one, the database package has one, this has one — and that
* is the correct number rather than a failure to factor. They are three programs that deploy separately:
* a marketplace that requires the platform to be redeployed in step with it is not a marketplace, and a
* website that imports server code cannot be served as static files.
*/
export type CatalogueEntry = {
appName: string;
publisher: string;
version: string;
platform: string;
label: string;
summary: string;
color: string;
tile?: 'badge' | 'bare';
iconUrl?: string;
icon?: string;
source: string;
permissions: { key: string; label: string; description: string }[];
osDependencies?: { binary: string; reason: string }[];
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
readme?: string;
};
export type Load<T> = { state: 'loading' } | { state: 'error'; message: string } | { state: 'ready'; data: T };
/**
* Fetch, with the three states kept distinct.
*
* Loading, empty and broken are different things and the page says which. An empty catalogue rendered as
* a spinner looks like a slow site; a broken one rendered as an empty catalogue looks like a store with
* nothing in it — which is the worst of the three, because it is the only one nobody reports.
*/
function useJson<T>(url: string): Load<T> {
const [load, setLoad] = useState<Load<T>>({ state: 'loading' });
useEffect(() => {
let alive = true;
setLoad({ state: 'loading' });
fetch(url)
.then(async (res) => {
if (!res.ok) throw new Error(res.status === 404 ? 'Not found' : `The catalogue is unavailable (${res.status})`);
return (await res.json()) as T;
})
.then((data) => alive && setLoad({ state: 'ready', data }))
.catch((err: unknown) => {
if (alive) setLoad({ state: 'error', message: err instanceof Error ? err.message : 'Something went wrong' });
});
return () => {
alive = false;
};
}, [url]);
return load;
}
export const useCatalogue = () => useJson<CatalogueEntry[]>('/api/plugins');
export const usePlugin = (appName: string) => useJson<CatalogueEntry>(`/api/plugins/${appName}`);
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Officer Marketplace</title>
<meta name="description" content="Plugins for Officer — install them from your own server in one click." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Officer Marketplace" />
<meta property="og:description" content="Plugins for Officer — install them from your own server in one click." />
<meta property="og:url" content="https://marketplace.officer.dev" />
<meta property="og:site_name" content="Officer Marketplace" />
<link rel="icon" type="image/x-icon" href="https://officer.dev/favicon.ico" />
<meta name="theme-color" content="#1F2620" />
<script type="module" src="./main.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+81
View File
@@ -0,0 +1,81 @@
@import "tailwindcss";
/* The platform's palette, so a plugin tile looks the same in the store as it does in the dock.
Copied rather than imported: `officer-web/styles/globals.css` carries the whole app's theme —
sidebars, charts, forty variables this page never uses — and dragging it in would make every
change to the app's chrome a change to the public website. */
@theme {
--color-duck-yellow: #f4c430;
--color-duck-orange: #ea580c;
--color-duck-teal: #0891b2;
--color-duck-beige: #e7d4b5;
--color-ink: #0e1411;
--color-ink-soft: #151d18;
--color-line: #26332b;
--color-body: #cfd8d2;
--color-faint: #8a978f;
}
@layer base {
html {
color-scheme: dark;
}
body {
margin: 0;
background: var(--color-ink);
color: var(--color-body);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
}
/* README typography. Hand-rolled rather than @tailwindcss/typography: the plugin never renders untrusted
markdown (it comes from repositories we index), but a whole plugin dependency for six selectors on one
page is not worth the install-size or the version to keep current. */
@layer components {
.markdown {
line-height: 1.7;
}
.markdown h1,
.markdown h2,
.markdown h3 {
color: var(--color-body);
font-weight: 600;
margin: 2rem 0 0.75rem;
line-height: 1.3;
}
.markdown h1 { font-size: 1.5rem; }
.markdown h2 { font-size: 1.25rem; }
.markdown h3 { font-size: 1.05rem; }
.markdown p, .markdown li { color: var(--color-faint); }
.markdown p, .markdown ul, .markdown ol, .markdown pre, .markdown table { margin: 0.85rem 0; }
.markdown ul { list-style: disc; padding-left: 1.25rem; }
.markdown ol { list-style: decimal; padding-left: 1.25rem; }
.markdown a { color: var(--color-duck-teal); }
.markdown code {
background: #0b100d;
border: 1px solid var(--color-line);
border-radius: 4px;
padding: 0.1rem 0.3rem;
font-size: 0.85em;
color: var(--color-duck-beige);
}
.markdown pre {
background: #0b100d;
border: 1px solid var(--color-line);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
}
.markdown pre code { background: none; border: 0; padding: 0; }
.markdown blockquote {
border-left: 3px solid var(--color-line);
padding-left: 1rem;
color: var(--color-faint);
}
.markdown table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
.markdown th, .markdown td { border: 1px solid var(--color-line); padding: 0.4rem 0.6rem; text-align: left; }
.markdown img { max-width: 100%; border-radius: 8px; }
.markdown hr { border: 0; border-top: 1px solid var(--color-line); margin: 2rem 0; }
}
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'drizzle-kit';
// Same .env loading as officer_db's config: drizzle-kit runs as its own process from this directory and
// inherits nothing from the server.
const envPath = resolve(__dirname, '../../../.env');
try {
const text = readFileSync(envPath, 'utf-8');
for (const line of text.split('\n')) {
const match = line.match(/^(\w+)=(.*)$/);
if (match) {
const [, key, val] = match;
if (!process.env[key!]) process.env[key!] = val!.replace(/^["']|["']$/g, '');
}
}
} catch {}
// Derived exactly the way the runtime derives it — see src/db.ts. Duplicated rather than imported
// because drizzle-kit loads this file outside the workspace's module resolution.
const url = (() => {
if (process.env.MARKETPLACE_POSTGRES_URL) return process.env.MARKETPLACE_POSTGRES_URL;
const base = process.env.POSTGRES_URL;
if (!base) throw new Error('MARKETPLACE_POSTGRES_URL or POSTGRES_URL is required');
const parsed = new URL(base);
parsed.pathname = '/officer_marketplace';
return parsed.toString();
})();
export default defineConfig({
schema: './src/schema.ts',
out: './migrations',
dialect: 'postgresql',
dbCredentials: { url },
});
+45
View File
@@ -0,0 +1,45 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
/**
* The marketplace's own database.
*
* ── Why a second database rather than more tables in `officer` ──
*
* They answer to different people. `officer` is one owner's machine: their accounts, their dashboards,
* what THEY have installed. The marketplace is a public catalogue that is the same for everyone and is
* served by a process with no platform login, no session and no user. A plugin being listed has nothing
* to do with any particular install, and `plugin_installs` in the platform's database has nothing to do
* with the store.
*
* The practical half: this database is the one thing here that can be dropped and rebuilt from the
* repositories it indexes, and a public HTTP surface reads it. Keeping that behind its own connection
* means the catalogue API cannot reach a single row of anybody's mail, wallet or passkeys — a property
* that is worth more than the convenience of one connection string.
*
* ── Where the URL comes from ──
*
* `MARKETPLACE_POSTGRES_URL` when set. Otherwise the platform's `POSTGRES_URL` with the database name
* swapped for `officer_marketplace`, because the two live on the same server in every deployment that
* exists — and a derived default is one fewer thing for `officer-setup.sh` to write correctly. Setting
* the variable explicitly is what a split across servers would need, and it costs nothing to support.
*/
const MARKETPLACE_DB_NAME = 'officer_marketplace';
export function marketplaceUrl(): string {
const explicit = process.env.MARKETPLACE_POSTGRES_URL;
if (explicit) return explicit;
const base = process.env.POSTGRES_URL;
if (!base) throw new Error('MARKETPLACE_POSTGRES_URL or POSTGRES_URL is required');
// Swap only the path. Parsing rather than string-replacing, so a password containing the database
// name — or a name that appears in the host — cannot be rewritten by accident.
const url = new URL(base);
url.pathname = `/${MARKETPLACE_DB_NAME}`;
return url.toString();
}
const client = postgres(marketplaceUrl());
export const marketplaceDb = drizzle(client, { schema });
@@ -0,0 +1,3 @@
export { marketplaceDb } from './db';
export * as schema from './schema';
export * from './plugins/queries';
@@ -0,0 +1,134 @@
import { eq } from 'drizzle-orm';
import { marketplaceDb } from '../db';
import { pluginMetadata, plugins } from './schema';
/**
* One catalogue entry, in the shape the platform already consumes.
*
* This is `CatalogueEntry` from `servers/plugins/marketplace.ts`, and the duplication is deliberate: it
* is the WIRE FORMAT, and the two sides are different programs that will be deployed at different times.
* Importing the platform's type here would make a change to it a silent break in a running website, and
* a marketplace that must be redeployed in lockstep with every install is not a marketplace.
*/
export type CatalogueEntry = {
appName: string;
publisher: string;
version: string;
platform: string;
label: string;
summary: string;
color: string;
tile?: 'badge' | 'bare';
iconUrl?: string;
icon?: string;
source: string;
permissions: unknown[];
osDependencies?: unknown[];
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
/** Only on the single-plugin endpoint. A list of full READMEs is a page nobody asked for. */
readme?: string;
};
type Row = {
appName: string;
developer: string;
repoUrl: string;
metadata: typeof pluginMetadata.$inferSelect | null;
};
/**
* A registry row plus its cached metadata, as the API returns it.
*
* Returns null when there is no metadata yet — a registered plugin that has never been synced is real,
* but there is nothing to render, and a half-filled tile is worse than an absent one. `listCatalogue`
* drops those; the count of dropped rows is what a sync failure looks like from outside.
*/
function toCatalogueEntry(row: Row): CatalogueEntry | null {
const m = row.metadata;
if (!m) return null;
return {
appName: row.appName,
publisher: row.developer,
version: m.version,
platform: m.platform,
label: m.label,
summary: m.summary,
color: m.color ?? '#94a3b8',
...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}),
...(m.iconUrl ? { iconUrl: m.iconUrl } : {}),
...(m.icon ? { icon: m.icon } : {}),
source: row.repoUrl,
permissions: (m.permissions as unknown[]) ?? [],
...(m.osDependencies ? { osDependencies: m.osDependencies as unknown[] } : {}),
has: m.has as CatalogueEntry['has'],
};
}
/** Everything listed, newest registration last. Unlisted rows never leave this function. */
export async function listCatalogue(): Promise<CatalogueEntry[]> {
const rows = await marketplaceDb
.select({
appName: plugins.appName,
developer: plugins.developer,
repoUrl: plugins.repoUrl,
metadata: pluginMetadata,
})
.from(plugins)
.leftJoin(pluginMetadata, eq(pluginMetadata.pluginId, plugins.id))
.where(eq(plugins.listed, true))
.orderBy(plugins.appName);
return rows.map(toCatalogueEntry).filter((entry): entry is CatalogueEntry => entry !== null);
}
/** One plugin, with its README. Null for an unknown or unlisted app name. */
export async function catalogueEntry(appName: string): Promise<CatalogueEntry | null> {
const [row] = await marketplaceDb
.select({
appName: plugins.appName,
developer: plugins.developer,
repoUrl: plugins.repoUrl,
metadata: pluginMetadata,
})
.from(plugins)
.leftJoin(pluginMetadata, eq(pluginMetadata.pluginId, plugins.id))
.where(eq(plugins.appName, appName))
.limit(1);
if (!row || !row.metadata) return null;
const entry = toCatalogueEntry(row);
if (!entry) return null;
return { ...entry, ...(row.metadata.readme ? { readme: row.metadata.readme } : {}) };
}
// ── Writes. Used by the sync script, never by the public API ──
type RegisterParams = { appName: string; developer: string; repoUrl: string };
/** Register a plugin, or update where it lives. Returns its id. */
export async function upsertPlugin({ appName, developer, repoUrl }: RegisterParams): Promise<number> {
const [row] = await marketplaceDb
.insert(plugins)
.values({ appName, developer, repoUrl })
.onConflictDoUpdate({
target: plugins.appName,
set: { developer, repoUrl, updatedAt: new Date() },
})
.returning({ id: plugins.id });
if (!row) throw new Error(`upsertPlugin: no row returned for ${appName}`);
return row.id;
}
export type MetadataParams = Omit<typeof pluginMetadata.$inferInsert, 'id' | 'pluginId' | 'fetchedAt'>;
/** Replace a plugin's cached metadata. One row per plugin — see the schema for why. */
export async function upsertMetadata(pluginId: number, data: MetadataParams): Promise<void> {
await marketplaceDb
.insert(pluginMetadata)
.values({ ...data, pluginId })
.onConflictDoUpdate({
target: pluginMetadata.pluginId,
set: { ...data, fetchedAt: new Date() },
});
}
@@ -0,0 +1,101 @@
import { boolean, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
// What the marketplace offers, and what it knows about each thing it offers.
//
// ── Two tables, because they have different owners and different lifetimes ──
//
// `plugins` is the REGISTRY: the app name, who wrote it, where to clone it from. Every row is put there
// deliberately by whoever runs the marketplace, and nothing outside this database may change one. It is
// small, hand-curated, and the only part that is authoritative.
//
// `plugin_metadata` is a CACHE of what that repository says about itself — its label, its icon, the
// permissions it will ask for, its README. All of it already exists in the plugin's own `manifest.ts`,
// which is the real source of truth and stays that way. This table exists so the website can render a
// catalogue without cloning three repositories on every page load.
//
// Keeping them apart is what makes the cache safe to be wrong. A metadata row can be deleted, rebuilt
// from the repository, or found to be stale, and none of that can lose the fact that the plugin exists
// or where it lives. Merging them would make a refresh a write to the registry, which is exactly the
// kind of thing that turns a failed fetch into a plugin disappearing from the store.
//
// ── The platform re-reads everything after cloning, deliberately ──
//
// Nothing here is trusted by an install. The catalogue is what the owner sees BEFORE consenting — the
// permissions it will ask for, the host binaries it will install — and after the clone the plugin's own
// manifest is what the platform reads. A metadata row that disagrees with the repository is a marketplace
// bug, never a second source of truth.
/**
* A plugin the marketplace lists. Identity and provenance only.
*
* `repoUrl` is HTTPS, never SSH: the marketplace serves one URL to every machine, and an `ssh://` clone
* assumes a key on that host for that user — true where the plugin was published and false everywhere
* else. Anonymous HTTPS also keeps credentials out of the install path entirely.
*/
export const plugins = pgTable(
'plugins',
{
id: serial('id').primaryKey(),
/** The directory it clones into and its id everywhere else. Matches the plugin's own `appName`. */
appName: text('app_name').notNull(),
/** Who publishes it. A name today; an account when submissions exist. */
developer: text('developer').notNull(),
repoUrl: text('repo_url').notNull(),
/**
* Off hides it from the public API without deleting it or its metadata. A plugin taken down for a
* bad release has to come back with its history, and a delete makes that a re-registration.
*/
listed: boolean('listed').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_marketplace_plugins_app_name').on(table.appName)],
);
/**
* What a plugin's repository says about itself, as of the last sync.
*
* One row per plugin — this is a snapshot, not a history. Versions are a separate feature and would be a
* separate table; pretending this one holds them by letting rows accumulate would give a store with no
* way to say which row is current.
*
* The JSON columns hold the manifest's own shapes (`PluginPermission[]`, `OsDependency[]`, the `has`
* flags) rather than being flattened into columns. They are read as a unit, rendered as a unit, and
* their shape is owned by `plugins/manifest.ts` in the platform — flattening would mean a migration here
* every time a manifest gains a field, for no query anyone runs.
*/
export const pluginMetadata = pgTable(
'plugin_metadata',
{
id: serial('id').primaryKey(),
pluginId: integer('plugin_id')
.notNull()
.references(() => plugins.id, { onDelete: 'cascade' }),
version: text('version').notNull(),
/** The platform range it declares support for, e.g. `>=1.0.0`. */
platform: text('platform').notNull(),
label: text('label').notNull(),
summary: text('summary').notNull(),
color: text('color'),
/** `badge` or `bare` — whether the tile draws a coloured square behind the icon. */
tile: text('tile'),
/** Absolute URL to artwork. A store must show an icon for something the machine has never seen. */
iconUrl: text('icon_url'),
/** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */
icon: text('icon'),
permissions: jsonb('permissions').notNull(),
osDependencies: jsonb('os_dependencies'),
/** `{ api, schema, sidecar, web }` — what the plugin ships. */
has: jsonb('has').notNull(),
/** The repository's README, rendered on the plugin's page. Null until one exists. */
readme: text('readme'),
/** The commit this snapshot was taken from, so a stale row can be told from an unchanged one. */
sourceCommit: text('source_commit'),
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_marketplace_plugin_metadata_plugin').on(table.pluginId)],
);
@@ -0,0 +1,5 @@
// drizzle-kit's view of the marketplace database.
//
// Unlike `officer_db`, there is no generated plugin barrel to keep out of the runtime graph: this
// database has one owner and a fixed set of tables. Nothing on this machine may be absent from it.
export * from './plugins/schema';
+94
View File
@@ -0,0 +1,94 @@
import index from '../../apps/marketplace/index.html';
import { catalogueEntry, listCatalogue } from '../../databases/marketplace_db/src/plugins/queries';
/**
* marketplace.officer.dev — the public plugin catalogue.
*
* ── Why this is not part of the platform's server ──
*
* It has no users. Nothing here reads a session, a token or a role, and there is no request it answers
* differently depending on who is asking — a catalogue is the same for everyone or it is not a
* catalogue. Mounting it inside the platform would have put a public, unauthenticated surface behind the
* same process that holds mail, a wallet and passkeys, and behind a permission gate it must always
* bypass. Those exemptions are precisely the ones that later turn out to have been too wide.
*
* Its own process, its own port, its own database connection. The failure modes stay separate too: the
* marketplace being down must not stop a platform booting, and a platform being down must not take the
* store offline.
*
* ── Read-only, and structurally so ──
*
* There are two GET routes and no others. Registering a plugin is `scripts/marketplace-sync.ts`, run by
* hand against the database. That is not a temporary shortcut standing in for a write API: while
* submissions do not exist, a public write surface is all risk and no feature.
*
* ── CORS is wide open, deliberately ──
*
* Every Officer install is a different origin — `officer.dev`, a tailnet name, `localhost` in dev — and
* the catalogue must answer all of them. There is nothing to protect: no credentials, no cookies, no
* per-caller answer. `*` is the honest description of a public catalogue, and an origin allowlist here
* would be security theatre that breaks a real install the first time somebody renames their host.
*/
const PORT = Number(process.env.MARKETPLACE_PORT ?? 9100);
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};
const json = (body: unknown, status = 200) =>
Response.json(body, { status, headers: { ...CORS, 'Cache-Control': 'public, max-age=60' } });
const preflight = () => new Response(null, { status: 204, headers: CORS });
/** App names come from a URL. Same rule as everywhere else: never let one address something else. */
const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
const server = Bun.serve({
port: PORT,
hostname: '0.0.0.0',
routes: {
'/api/plugins': {
OPTIONS: preflight,
async GET() {
try {
return json(await listCatalogue());
} catch (err) {
console.error('[marketplace] catalogue query failed —', err instanceof Error ? err.message : err);
// 503 rather than an empty list: a platform that caches the last good answer must be able to
// tell "the store is empty" from "the store is broken", and an empty 200 says the first.
return json({ error: 'catalogue unavailable' }, 503);
}
},
},
'/api/plugins/:appName': {
OPTIONS: preflight,
async GET(req) {
const { appName } = req.params;
if (!APP_NAME_RE.test(appName)) return json({ error: 'bad app name' }, 400);
try {
const entry = await catalogueEntry(appName);
return entry ? json(entry) : json({ error: 'no such plugin' }, 404);
} catch (err) {
console.error('[marketplace] entry query failed —', err instanceof Error ? err.message : err);
return json({ error: 'catalogue unavailable' }, 503);
}
},
},
// Liveness for the proxy and for `officer-setup.sh` to check. Touches no database on purpose: it
// answers "is this process up", and conflating that with "is Postgres up" makes both unanswerable.
'/api/health': () => json({ ok: true, service: 'marketplace' }),
// Everything else is the SPA — including deep links like /plugins/music, which must return the
// shell rather than a 404 for client-side routing to work at all.
'/*': index,
},
development: process.env.NODE_ENV !== 'production',
});
console.log(`[marketplace] listening on ${server.url}`);
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { clearCatalogueCache, fetchCatalogue } from './marketplace';
// The three answers — live, cached, bundled — and the rule that none of them is an exception.
//
// The plugins page is blocked on this call, and an owner's INSTALLED plugins are managed on that same
// page. So a marketplace that is down must degrade to something true rather than throw: the failure
// mode being defended against is "officer.dev has a bad afternoon and nobody can uninstall anything".
const realFetch = globalThis.fetch;
const entry = (appName: string) => ({
appName,
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: appName,
summary: 'a plugin',
color: '#fff',
source: `https://gitea.officer.dev/plugins/${appName}.git`,
permissions: [],
has: { api: true, schema: false, sidecar: false, web: true },
});
const respondWith = (body: unknown, status = 200) => {
globalThis.fetch = (async () =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })) as typeof fetch;
};
const failWith = (message: string) => {
globalThis.fetch = (async () => {
throw new Error(message);
}) as typeof fetch;
};
beforeEach(() => clearCatalogueCache());
afterEach(() => {
globalThis.fetch = realFetch;
clearCatalogueCache();
});
describe('fetchCatalogue', () => {
test('serves what the marketplace returns', async () => {
respondWith([entry('music'), entry('offscale')]);
expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music', 'offscale']);
});
test('falls back to the bundled catalogue when the store is unreachable', async () => {
failWith('getaddrinfo ENOTFOUND');
const entries = await fetchCatalogue();
// Not asserting the exact list — that changes as plugins are published. The property is that a
// machine with no network still has a usable store.
expect(entries.length).toBeGreaterThan(0);
expect(entries.every((e) => e.source.startsWith('https://'))).toBe(true);
});
test('a cached answer outlives the store going down', async () => {
respondWith([entry('music')]);
await fetchCatalogue();
// The cache is deliberately NOT cleared here — surviving the store going down is the whole point.
failWith('connection refused');
const entries = await fetchCatalogue();
expect(entries.map((e) => e.appName)).toEqual(['music']);
});
test('an HTTP error is a failure, not a catalogue', async () => {
respondWith({ error: 'catalogue unavailable' }, 503);
const entries = await fetchCatalogue();
expect(entries.some((e) => e.appName === 'music')).toBe(true); // the bundled list, not the error body
});
test('a proxy serving HTML instead of JSON does not empty the store', async () => {
globalThis.fetch = (async () => new Response('<html>502 Bad Gateway</html>', { status: 200 })) as typeof fetch;
const entries = await fetchCatalogue();
expect(entries.length).toBeGreaterThan(0);
});
test('malformed entries are dropped individually, not fatally', async () => {
respondWith([entry('music'), { appName: 'broken' }, entry('example')]);
expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music', 'example']);
});
test('a non-https source is refused — it drives git clone', async () => {
respondWith([{ ...entry('evil'), source: 'file:///etc/passwd' }, entry('music')]);
expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music']);
});
test('an empty catalogue is a valid answer and is cached as one', async () => {
respondWith([]);
expect(await fetchCatalogue()).toEqual([]);
// Cached: a store that legitimately lists nothing must not fall through to the bundled list, or an
// owner who unpublished everything would keep seeing plugins that are gone.
failWith('down');
expect(await fetchCatalogue()).toEqual([]);
});
});
+106 -10
View File
@@ -75,13 +75,20 @@ export type CatalogueEntry = {
};
/**
* The stand-in catalogue.
* What this machine knows without asking anyone.
*
* One entry, and it is not a fixture: `plugins/music` is a real public repository that a real
* `git clone` reaches with no credentials. The install path is therefore exercised end to end even
* though the catalogue itself is a constant.
* 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 CATALOGUE: CatalogueEntry[] = [
const BUNDLED: CatalogueEntry[] = [
{
appName: 'music',
publisher: 'officerdev',
@@ -156,19 +163,108 @@ const CATALOGUE: CatalogueEntry[] = [
},
];
// ── 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.
*
* Async and allowed to fail from the start, because the real one will be a network call. A catalogue
* that cannot be reached must leave the plugins page working with what is on disk — an owner who cannot
* see the store should still be able to manage what they have already installed.
* 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[]> {
return CATALOGUE;
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(() => [] as CatalogueEntry[]);
const all = await fetchCatalogue().catch(() => BUNDLED);
return all.find((e) => e.appName === appName) ?? 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;
}