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
@@ -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));