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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user