app store: the catalogue, the install-state table, and what phase 0 must not foreclose

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:44:30 +00:00
co-authored by Claude Opus 5
parent 977d14922f
commit fc48a572d2
4 changed files with 459 additions and 4 deletions
+39 -4
View File
@@ -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/<service>/`), 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=<id>`), 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.
@@ -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<string[]>().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)],
);
+88
View File
@@ -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();
});
});
+252
View File
@@ -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 machines 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<string> = new Set(CATALOGUE.map((e) => e.id));