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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user