The site is up on HTTPS with a Let's Encrypt certificate, proxied to :9100, and serving the three plugins out of `officer_marketplace`. An Officer install with no configuration now reads its catalogue from it. ── What is running ── PM2 entry added to THIS machine's ecosystem only. Deliberately not in officer-setup's CORE_PROCESSES: the marketplace is one website 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. nginx (NPM host id 7) → 172.20.0.1:9100, with a ufw rule for the docker bridge. That rule is not optional and its absence does not look like a firewall problem: without it nginx HANGS until proxy_connect_timeout rather than being refused, which is how the apptest hosts wasted an hour a few days ago. ── READMEs ── `music`, `offscale` and `example` now have proper READMEs, written against their own code and pushed to their own repos. They render on the plugin's page in the store and are what a self-hoster reads before installing. The example's is a tutorial as much as a shop window — it is the document a plugin author reads first, and every convention in it was verified in `discover.ts` rather than restated from memory. `music`'s remote was HTTPS with no credentials on this box, so it could not be pushed; switched to SSH like the other two. The catalogue still advertises HTTPS — `marketplace-sync` converts — which is the property that matters, since an `ssh://` clone URL only works on the machine that published it. ── Writing them was the cheapest audit available ── Reading each plugin against its own documentation turned up ~20 real defects, all recorded in TODO.md and none fixed here. The pattern worth naming: offscale is still branded "headscale" in user-visible panel names and in a persisted terminal session id, and its PLUGIN.md states outright that the ACL assistant is deliberately unwired — while `sidecar/assist.ts`, `claude-proxy.ts` and `PolicyAssistant.tsx` all ship. Documentation that confidently describes the opposite of the code is worse than none. Also fixed the docs on the frozen lockfile: both CLAUDE.md and bunfig.toml named `bun install --no-frozen-lockfile` as the escape hatch, and bun 1.3.14 has no such flag — it is ignored and the install fails anyway. tsgo clean, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.0 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
[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.