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}`);