Split out of docs/marketplace.md so the design document stays about how it works. Ranked by what blocks something rather than by size: submissions first (until a stranger's manifest can be read without executing it, the catalogue is ours only and everything below is cosmetic), then plugin upgrades not migrating, then staleness, then scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.1 KiB
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:
{ name: 'officer-marketplace', script: 'bun', args: 'run src/servers/marketplace/server.tsx',
cwd: '<platform dir>', 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
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 publishedmanifest.jsonalongside 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 amainbranch. A GitHub-hosted plugin or one defaulting tomastergets a 404 for its icon. The forge should become part of the registry rather than inferred.[open]No versions table.plugin_metadatais 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.