From fc48a572d2713e4ffcc5f2312034c1c1b297b991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 10 Aug 2026 12:24:13 +0000 Subject: [PATCH] app store: the catalogue, the install-state table, and what phase 0 must not foreclose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice, on a worktree branch so none of it touches the tree the live server runs from. `sidecar_installs` — server-level, no userId, because a sidecar is one process serving the machine. That is the line that keeps the model coherent for several users: installed is server-level and owner-only, configured is per user in service_connections. A member can use Gitea without being able to install it or point it somewhere else. `installed` and `enabled` are separate because they answer different questions, which is what gives the reversible middle ground: disable stops the process and keeps container, config, schema and data. `completedSteps` makes install resumable rather than merely retryable — the failure mode being designed against is a half-installed service that neither works nor uninstalls. The catalogue is data, not code: no functions, no compile-time coupling, because the same shape has to arrive as JSON from marketplace.officer.dev later. Its test pins it to the real estate — it offers exactly the processes the light profile excludes, names processes that exist, and claims capabilities that exist. That last check earned itself immediately: it caught `vault` (no capability at all — it is EXEMPT because Bitwarden clients carry a Vaultwarden bearer, not a platform JWT) and `notify` (which does have one, where I had written null). Docker templates follow the convention already in use across 47 services in ~/dockers: a directory per service, compose inside, relative bind mounts so data sits beside it, USER_UID/USER_GID as the owner. An existing directory is evidence of an existing install and must be adopted, never overwritten. Records what Phase 0 must not foreclose: a remote marketplace, sidecars moving to their own repositories, and third-party plugins — including the note that catalogue.test.ts pins Phase 0's invariant rather than the design's, since that relationship inverts once sidecars leave this repo. Co-Authored-By: Claude Opus 5 --- docs/sidecar-app-store.md | 43 ++- .../officer_db/src/schema/app-store.ts | 80 ++++++ src/servers/app-store/catalogue.test.ts | 88 ++++++ src/servers/app-store/catalogue.ts | 252 ++++++++++++++++++ 4 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 src/databases/officer_db/src/schema/app-store.ts create mode 100644 src/servers/app-store/catalogue.test.ts create mode 100644 src/servers/app-store/catalogue.ts diff --git a/docs/sidecar-app-store.md b/docs/sidecar-app-store.md index f6c088f9..07e150ab 100644 --- a/docs/sidecar-app-store.md +++ b/docs/sidecar-app-store.md @@ -60,8 +60,12 @@ for someone who does not. The prompt is the fork. **Officer is the installer, never the owner.** Concretely: -- A real `docker-compose.yml` per service, written into a **user-owned directory** - (`~/officer-services//`), from our template. +- A real compose file per service, written into a **user-owned directory**, from our template — + following the convention the owner already uses for 47 services in `~/dockers/`: + one directory per service, `docker-compose.yaml` inside, and **relative bind mounts** + (`./data`, `./database`, `./storage`) so configuration and data sit beside the compose file where + both we and the user can find them. Named volumes are used by 3 of those 47 and are the exception; + templates use bind mounts, always. - Started with `docker compose up -d` **as the owner**, not as officer's own identity. - Found again by **label** (`officer.sidecar=`), not by holding a handle. @@ -75,8 +79,16 @@ Consequences, which are the point: The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and health checks already correct, so "install Gitea" does not become a tutorial. -`[open]` Rootful Docker runs container processes as root unless `user:` is set. Do we set it? And do we -support Podman for people who want genuinely rootless? +**`USER_UID` / `USER_GID` are set to the owner**, as the existing services already do. That answers the +"do containers run as root" question: no, and this is not a new convention — it is the one in use. + +**An existing directory is evidence, not an obstacle.** `~/dockers/` already holds `gitea`, `memos`, +`immich`, `jellyfin` and `invoice_shelf`. The installer must never write into a directory that exists; +finding one is the strongest possible signal that this is the "you already have one" case, and the store +should offer to ADOPT it — read its ports out of the compose file and write the connection — rather than +provision a second copy or overwrite a running service's data. + +`[open]` Podman, for anyone wanting genuinely rootless. --- @@ -174,6 +186,29 @@ What a plugin author is promised, and bound by. To be written properly; the shap --- +## What Phase 0 must not foreclose + +Three things are coming, and each one constrains a decision that looks free today. + +**1. `marketplace.officer.dev`.** Phase 1 keeps the catalogue inside this repo; later the app lists what +is on a remote marketplace instead. So catalogue entries must stay **serialisable data** — no functions, +no imports, nothing that only means something at compile time. They are plain objects today and must +remain so, because the same shape has to arrive as JSON over HTTP. Compose templates travel with them. + +**2. Every sidecar becomes its own repository.** Today `catalogue.test.ts` asserts the catalogue equals +"everything in ecosystem.config.cjs that light excludes". That is the right check _now_, and it inverts +later: once sidecars live elsewhere, the catalogue entry becomes the source of truth for how to run one +(command, args, env) and the ecosystem file is generated from what is installed, not the other way +round. **Do not treat that test as a permanent law** — it pins Phase 0's invariant, not the design's. + +**3. Third-party plugins.** Already the reason per-sidecar schema is in scope. It is also why the +`service_connections` ID needs namespacing before the marketplace opens, not after. + +The through-line: **nothing in Phase 0 may assume the catalogue is compiled in, or that a sidecar's code +is in this repository.** + +--- + ## Open questions 1. `user:` in compose, and Podman support for rootless. diff --git a/src/databases/officer_db/src/schema/app-store.ts b/src/databases/officer_db/src/schema/app-store.ts new file mode 100644 index 00000000..7f802909 --- /dev/null +++ b/src/databases/officer_db/src/schema/app-store.ts @@ -0,0 +1,80 @@ +import { pgTable, serial, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core'; + +// What the owner has installed from the app store, and whether it should be running. +// +// ── Why there is no userId ── +// +// A sidecar is ONE process serving the whole machine, so installing one is a server-level act, not a +// per-user one. This is the line that keeps the model coherent once several people share a server: +// +// installed server-level, owner-only — this row +// configured per user — service_connections +// +// Gitea is the worked example. The owner installs it once, and the row here says the process runs; each +// member then holds their own credential in `service_connections`, inheriting the instance URL from the +// owner's row. A member can therefore use a service without being able to install, uninstall or point it +// somewhere else — which is the same split `capabilities/registry.ts` already draws between `app` and +// `admin` kinds. +// +// ── Why `installed` and `enabled` are separate ── +// +// They answer different questions. `installed` means the thing EXISTS: a container was provisioned (or an +// existing instance was named), config was written, schema was applied. `enabled` means the process +// SHOULD BE RUNNING. Disabling is the reversible middle ground the owner asked for — stop the process, +// keep the container, the config, the tables and the data, and start again later at no cost. +// +// Uninstall then has a disposal choice rather than a fixed meaning: keep the data, drop the container, +// or drop both. None of those are this table's business beyond recording that the row is gone. +export const sidecarInstalls = pgTable( + 'sidecar_installs', + { + id: serial('id').primaryKey(), + /** + * The catalogue id — 'gitea', 'photos'. Text rather than an enum for the same reason + * `service_connections.service` is: adding a sidecar must not be a schema change, and a third-party + * one cannot be in an enum we compile. + */ + sidecarId: text('sidecar_id').notNull(), + /** + * How this install was satisfied, which decides what uninstall has to undo: + * + * 'existing' — pointed at an instance the user already runs. We provisioned nothing. + * 'provisioned' — we rendered a compose file and started containers. Ours to offer to remove. + * 'config' — nothing to reach; credentials only (email, wallet). + */ + mode: text('mode').notNull(), + /** + * 'pending' | 'installing' | 'installed' | 'failed'. + * + * `installing` is a real, persisted state rather than a transient one, because install spans a + * container start and a health wait and can be interrupted by a restart in the middle. A row stuck in + * `installing` is the signal to resume, not evidence of a bug. + */ + status: text('status').notNull().default('pending'), + enabled: boolean('enabled').notNull().default(false), + /** + * Which install steps have completed, by name. This is what makes install RESUMABLE rather than + * merely retryable: re-running picks up after the last completed step instead of provisioning a + * second container or re-writing config that is already right. + * + * The failure mode being designed against is a half-installed service that neither works nor + * uninstalls — which is the one users cannot get themselves out of. + */ + completedSteps: jsonb('completed_steps').$type().notNull().default([]), + /** Why the last attempt failed, shown in the UI. Cleared on the next successful step. */ + lastError: text('last_error'), + /** + * Absolute path to the rendered compose directory, for `mode: 'provisioned'` only. + * + * Stored rather than derived because it is the user's directory and he may move it — and because + * uninstall must not guess at a path it is about to run `docker compose down -v` in. + */ + composeDir: text('compose_dir'), + installedAt: timestamp('installed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + // One row per sidecar, machine-wide. There is no second install of the same sidecar to disambiguate, + // which is what lets every other column be read without asking "which one". + (t) => [uniqueIndex('uq_sidecar_installs_sidecar').on(t.sidecarId)], +); diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts new file mode 100644 index 00000000..6f63b9ae --- /dev/null +++ b/src/servers/app-store/catalogue.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'bun:test'; +import { CATALOGUE, byId } from './catalogue'; +import { CAPABILITIES } from '../capabilities/registry'; + +// The catalogue is a hand-written list describing machinery that lives elsewhere, which is the shape of +// thing that rots silently. These tests pin it to the three sources it claims to agree with: +// ecosystem.config.cjs, the light profile, and the capability registry. +// +// The intent is that adding a sidecar to the estate and forgetting the app store FAILS HERE, rather than +// the sidecar being quietly uninstallable and nobody noticing for a release. + +const full = (require('../../../ecosystem.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name); +const light = (require('../../../ecosystem.light.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name); + +describe('the catalogue against the real estate', () => { + it('offers exactly the processes the light profile leaves out', () => { + // This is the definition of the app store: light is the baseline, everything else is installable. + const notInLight = full.filter((name) => !light.includes(name)).sort(); + const offered = CATALOGUE.map((e) => e.process).sort(); + + expect(offered).toEqual(notInLight); + }); + + it('names a process that actually exists in the ecosystem', () => { + // A typo here would install nothing and report success. + for (const entry of CATALOGUE) expect(full).toContain(entry.process); + }); + + it('does not offer to install the baseline', () => { + // "Uninstall chat" is not a thing the store should be able to express. + for (const entry of CATALOGUE) expect(light).not.toContain(entry.process); + }); +}); + +describe('entries are internally coherent', () => { + it('has unique ids', () => { + const ids = CATALOGUE.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('declares a compose template if and only if it can provision one', () => { + // Both directions matter: a provisioning entry with no template fails at install time, and a + // template on a non-provisioning entry is dead weight nobody will notice is unused. + for (const entry of CATALOGUE) { + if (entry.modes.includes('provisioned')) expect(entry.composeTemplate).toBeTruthy(); + else expect(entry.composeTemplate).toBeUndefined(); + } + }); + + it('asks for connection details exactly when it points at something existing', () => { + for (const entry of CATALOGUE) { + if (entry.modes.includes('existing')) expect(entry.existingFields?.length).toBeGreaterThan(0); + else expect(entry.existingFields).toBeUndefined(); + } + }); + + it('requires a url when pointing at an existing instance', () => { + // Without one there is nothing to connect to, and the `existing` mode is meaningless. + for (const entry of CATALOGUE) { + if (!entry.modes.includes('existing')) continue; + const url = entry.existingFields?.find((f) => f.key === 'url'); + expect(url?.required).toBe(true); + } + }); + + it('declares at least one mode', () => { + for (const entry of CATALOGUE) expect(entry.modes.length).toBeGreaterThan(0); + }); +}); + +describe('capabilities it claims to back', () => { + it('names a capability that exists, or explicitly none', () => { + // `null` is a real answer — notify has no surface of its own — but a WRONG key would silently + // detach the store entry from the permission that governs the feature. + const keys = new Set(CAPABILITIES.map((c) => c.key)); + for (const entry of CATALOGUE) { + if (entry.capability === null) continue; + expect(keys).toContain(entry.capability); + } + }); +}); + +describe('byId', () => { + it('finds a known entry and returns undefined for anything else', () => { + expect(byId('photos')?.process).toBe('officer-photos'); + expect(byId('not-a-sidecar')).toBeUndefined(); + }); +}); diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts new file mode 100644 index 00000000..acb43b83 --- /dev/null +++ b/src/servers/app-store/catalogue.ts @@ -0,0 +1,252 @@ +// What the app store can install, and what installing each one actually requires. +// +// This is the catalogue, not the state — `sidecar_installs` records what the owner has done, this +// records what is on offer. Everything the installer branches on lives here so the installer itself has +// no per-sidecar knowledge, the same restraint `create-proxy.ts` keeps on the proxy side. +// +// ── The entries are derived from a fact, not invented ── +// +// The fourteen below are exactly the processes in `ecosystem.config.cjs` that the light profile +// excludes. That is not a coincidence to be maintained by hand: `catalogue.test.ts` asserts it, so a +// sidecar added to the estate and forgotten here fails a test rather than being quietly uninstallable. +// +// Light itself (officer, the agent, the anthropic proxy, opencode, pty, gitea) is not in the catalogue. +// Those are the baseline — chat, terminal, file browser — and there is no meaningful "uninstall chat". + +/** + * How an install can be satisfied. A sidecar may support more than one, and the prompt is the fork: + * "do you already have one, or shall we start one for you?" + */ +export type InstallMode = + /** Point at an instance the user already runs, here or elsewhere. We provision nothing. */ + | 'existing' + /** Render our compose template and start it. The connection is then known without asking. */ + | 'provisioned' + /** Nothing to reach. Credentials or local configuration only. */ + | 'config'; + +/** One field the installer asks for before it can finish. */ +export type ConfigField = { + key: string; + label: string; + /** `secret` is written encrypted and never read back to the client. */ + type: 'url' | 'text' | 'secret'; + required: boolean; + placeholder?: string; + help?: string; +}; + +export type CatalogueEntry = { + /** Stable id. Matches `sidecar_installs.sidecar_id` and `service_connections.service` where both exist. */ + id: string; + /** The PM2 process to start and stop. Must exist in ecosystem.config.cjs. */ + process: string; + label: string; + /** One line, shown in the store listing. */ + summary: string; + /** Which of the three shapes this sidecar supports, in the order the UI should offer them. */ + modes: InstallMode[]; + /** + * The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no + * user-facing surface of its own (notify produces notifications for other features). + */ + capability: string | null; + /** + * Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the + * answers because we wrote the compose file. + */ + existingFields?: ConfigField[]; + /** Asked for `config` installs, which have no instance to point at. */ + configFields?: ConfigField[]; + /** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */ + composeTemplate?: string; + /** + * Why this cannot be installed on some hosts, if so. Shown instead of the install button rather than + * failing halfway through — a check the installer can make before it starts. + */ + requires?: 'docker' | 'linux-display'; +}; + +export const CATALOGUE: CatalogueEntry[] = [ + // ── Point at something you already run, or let us start one ──────────────────────────────────────── + { + id: 'photos', + process: 'officer-photos', + label: 'Photos', + summary: 'Your Immich library — browse, search, upload from the phone', + modes: ['existing', 'provisioned'], + capability: 'photos', + composeTemplate: 'immich', + existingFields: [ + { key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' }, + { key: 'secret', label: 'API key', type: 'secret', required: true, help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.' }, + ], + }, + { + id: 'jellyfin', + process: 'officer-jellyfin', + label: 'Jellyfin', + summary: 'Films and shows, with a player that handles direct, HLS and progressive', + modes: ['existing', 'provisioned'], + capability: 'jellyfin', + composeTemplate: 'jellyfin', + existingFields: [ + { key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'http://localhost:8096' }, + { key: 'secret', label: 'Access token', type: 'secret', required: true }, + ], + }, + { + id: 'memos', + process: 'officer-memos', + label: 'Memos', + summary: 'Quick notes, tagged and searchable', + modes: ['existing', 'provisioned'], + capability: 'memos', + composeTemplate: 'memos', + existingFields: [ + { key: 'url', label: 'Memos URL', type: 'url', required: true }, + { key: 'secret', label: 'Access token', type: 'secret', required: true }, + ], + }, + { + id: 'invoiceshelf', + process: 'officer-invoiceshelf', + label: 'Invoices', + summary: 'InvoiceShelf — clients, estimates and invoices', + modes: ['existing', 'provisioned'], + capability: 'invoices', + composeTemplate: 'invoiceshelf', + existingFields: [ + { key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true }, + { key: 'secret', label: 'API token', type: 'secret', required: true }, + ], + }, + { + id: 'vault', + process: 'officer-vault', + label: 'Vault', + summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps', + modes: ['existing', 'provisioned'], + // No capability entry exists for this one, and the reason is about CREDENTIALS, not routing. + // + // Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the + // officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly. + // + // What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so + // `userMiddleware` would 401 them and a capability lookup would have no account to resolve. Hence + // `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead. + // The install still governs whether the sidecar runs at all. + capability: null, + composeTemplate: 'vaultwarden', + existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }], + }, + { + id: 'transmission', + process: 'officer-transmission', + label: 'Transmission', + summary: 'Torrents, with the daemon Officer talks to over RPC', + modes: ['existing', 'provisioned'], + capability: 'transmission', + composeTemplate: 'transmission', + existingFields: [ + { key: 'url', label: 'Transmission URL', type: 'url', required: true, placeholder: 'http://localhost:9091' }, + { key: 'path', label: 'RPC path', type: 'text', required: false, placeholder: '/transmission/rpc', help: 'Only differs behind a reverse proxy.' }, + { key: 'username', label: 'RPC username', type: 'text', required: false, help: 'Usually blank — Transmission is normally run with no RPC auth.' }, + { key: 'secret', label: 'RPC password', type: 'secret', required: false }, + ], + }, + { + id: 'slskd', + process: 'officer-slskd', + label: 'Soulseek', + summary: 'slskd — search and download from the Soulseek network', + modes: ['existing', 'provisioned'], + capability: 'soulseek', + composeTemplate: 'slskd', + existingFields: [ + { key: 'url', label: 'slskd URL', type: 'url', required: true }, + { key: 'secret', label: 'API key', type: 'secret', required: true }, + ], + }, + { + id: 'caldav', + process: 'officer-caldav', + label: 'Calendar', + summary: 'Radicale — calendars and contacts over CalDAV/CardDAV', + modes: ['existing', 'provisioned'], + capability: 'calendar', + composeTemplate: 'radicale', + existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }], + }, + { + id: 'headscale', + process: 'officer-headscale', + label: 'Headscale', + summary: 'Your own tailnet control plane', + modes: ['existing'], + capability: 'headscale', + existingFields: [ + { key: 'url', label: 'Headscale URL', type: 'url', required: true }, + { key: 'secret', label: 'API key', type: 'secret', required: true }, + ], + }, + + // ── Nothing to reach: configuration only ─────────────────────────────────────────────────────────── + { + id: 'email', + process: 'officer-email', + label: 'Email', + summary: 'Your IMAP accounts, synced and searchable', + modes: ['config'], + capability: 'email', + // Deliberately empty: accounts are added from /email, which already has a working multi-account + // form. Duplicating it here would be a second place to maintain the same credentials. + configFields: [], + }, + { + id: 'music', + process: 'officer-music', + label: 'Music', + summary: 'Index and play the library on this machine', + modes: ['config'], + capability: 'music', + configFields: [ + { key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }, + ], + }, + { + id: 'wallet', + process: 'officer-wallet', + label: 'Wallet', + summary: 'Bitcoin and Lightning, with keys held by the sidecar alone', + modes: ['config'], + capability: 'wallet', + configFields: [], + }, + { + id: 'notify', + process: 'officer-notify', + label: 'Notifications', + summary: 'Push to your phone when a job finishes or a turn needs you', + modes: ['config'], + capability: 'notify', + configFields: [], + }, + { + id: 'vnc', + process: 'officer-vnc', + label: 'Desktop', + summary: 'Mirror this machine’s display in the browser', + modes: ['config'], + capability: 'desktop', + // x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the + // store should say so rather than install something that starts and immediately fails. + requires: 'linux-display', + configFields: [], + }, +]; + +export const byId = (id: string): CatalogueEntry | undefined => CATALOGUE.find((e) => e.id === id); + +/** Every catalogue id, for validating a request before it reaches the installer. */ +export const CATALOGUE_IDS: ReadonlySet = new Set(CATALOGUE.map((e) => e.id));