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>
95 lines
4.2 KiB
TypeScript
95 lines
4.2 KiB
TypeScript
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}`);
|