# marketplace.officer.dev The public plugin catalogue, and the API the platform installs from. Built 2026-08-16. ## What it is, in one paragraph A website listing plugins, backed by its own Postgres database, serving two read-only JSON routes. The platform reads those routes to populate its Marketplace screen and to find out where to clone a plugin from. **The website installs nothing** — installing means cloning a repository, pushing a schema and starting a process on someone else's machine, so it happens entirely inside the owner's own Officer. ``` gitea.officer.dev/plugins/* the repositories (source of truth) │ scripts/marketplace-sync.ts (manual, reads manifest.ts + README.md) ▼ officer_marketplace (postgres) plugins + plugin_metadata │ src/servers/marketplace/server.tsx :9100 ▼ marketplace.officer.dev GET /api/plugins, /api/plugins/:appName │ fetchCatalogue() live → cached → bundled ▼ an Officer install /marketplace → clone → push schema → mount ``` ## The parts | Path | What it is | | ------------------------------------ | ----------------------------------------------------------------------------------------------- | | `src/databases/marketplace_db/` | Schema and queries for `officer_marketplace`. Deliberately NOT a workspace package — see below. | | `scripts/marketplace-sync.ts` | Fills the database from the plugin repositories. Run by hand. | | `src/servers/marketplace/server.tsx` | The public process. Two GET routes, a health route, and the SPA. | | `src/apps/marketplace/` | The website. | | `src/servers/plugins/marketplace.ts` | The PLATFORM's client. Fetch, cache, fall back. | ## Why a second database `officer` is one owner's machine — their accounts, dashboards, mail, wallet. The marketplace is a public catalogue that is identical for everyone and is served by a process with no login, no session and no user. Sharing a connection would put an unauthenticated public surface one query away from every private table on the box. Keeping it separate means the catalogue API structurally cannot read any of it. It is also the one database here that can be dropped and rebuilt from the repositories it indexes, which is a very different operational posture from the platform's. `MARKETPLACE_POSTGRES_URL` overrides the connection; by default it is `POSTGRES_URL` with the database name swapped, because in every deployment that exists they are on the same server. ## Why the marketplace is not a core process `officer-setup`'s `CORE_PROCESSES` does not include it, on purpose. It is one website that officer.dev hosts — not something every install should run. A copy on every machine would serve a stale catalogue on a port nobody asked to open. On the host that serves it, the PM2 entry is added to that machine's `ecosystem.config.cjs` by hand: ```js { name: 'officer-marketplace', script: 'bun', args: 'run src/servers/marketplace/server.tsx', cwd: '', watch: false, env: { NODE_ENV: 'production', MARKETPLACE_PORT: '9100' } } ``` then `pm2 start ecosystem.config.cjs --only officer-marketplace && pm2 save`. ## Two tables, split by owner `plugins` is the **registry**: app name, developer, clone URL, `listed`. Authoritative, hand-curated, small. Nothing outside this database may write it. `plugin_metadata` is a **cache** of what each repository says about itself — label, summary, icon, permissions, os dependencies, README. All of it already exists in the plugin's `manifest.ts`, which stays the source of truth. Keeping them apart is what makes the cache safe to be wrong: a metadata row can be deleted, rebuilt or found stale, and none of that can lose the fact that a plugin exists or where it lives. Merging them would make a refresh a write to the registry — which is how a failed fetch turns into a plugin disappearing from the store. **Nothing here is trusted by an install.** The catalogue is what the owner sees _before_ consenting; after the clone, the plugin's own manifest is what the platform reads. A metadata row that disagrees with the repository is a marketplace bug, never a second source of truth. ## Sync ```bash bun --env-file=.env scripts/marketplace-sync.ts ``` Reads every directory under `plugins/`, imports its `manifest.ts`, derives `has` from the directory rather than from a declaration, reads `README.md`, and upserts. Re-running is always safe: it overwrites, never merges. It 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 — the install fails at authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong". ## The platform degrades, it does not break `fetchCatalogue()` answers **live → cached → bundled**, in that order, and never throws. The Marketplace screen is also where an owner manages plugins they already have, so the store being unreachable must not take that page with it. The cache is per-process with a 5-minute TTL; the bundled list is the old constant, kept current, so a machine with no internet still shows something true. Responses are validated per entry. Not against a hostile marketplace — one that serves a malicious `source` has already won, since the owner asked to install what it lists — but against a **wrong** one: a proxy serving HTML as JSON, a half-deployed API, a field renamed on one side. A non-`https://` source is refused outright, because that string reaches `git clone`. ## Open items The full list, ranked, is `docs/marketplace-todo.md`. The ones that shape the design: - **`[blocker]` Third-party submissions cannot use this sync.** It reads a manifest by importing it, which is code execution. Fine for our own repositories; not a mechanism a stranger's plugin can go through. Submissions need either a sandbox or a published `manifest.json` alongside the TypeScript one. - **`[open]` Sync reads local clones, not the remotes.** The machine running it must have the plugins checked out. The version that clones each repo into a temp directory is a small change and was left out of the first pass. - **`[open]` `rawUrl()` assumes Gitea and a `main` branch.** A GitHub-hosted plugin or one defaulting to `master` gets a 404 for its icon. The forge should become part of the registry rather than inferred. - **`[open]` No versions table.** `plugin_metadata` is one row per plugin — a snapshot, not a history. A store that can offer "install 1.2 instead of 1.3" needs a different shape. - **`[open]` Nothing refreshes automatically.** Publishing a plugin update does not change the catalogue until somebody runs the sync. A cron or a Gitea webhook is the obvious next step. - **`[open]` No search, no categories, no counts.** Three plugins do not need them.