Files
platform/docs/marketplace.md
T
pastilhasandClaude Opus 5 53742a256f marketplace.officer.dev is live, and the plugin repos have READMEs
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>
2026-08-16 04:15:34 +00:00

122 lines
7.0 KiB
Markdown

# 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: '<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
```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
- **`[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.