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>
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
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>
|
|
);
|