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>
124 lines
7.1 KiB
Markdown
124 lines
7.1 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
|
|
|
|
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.
|