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