diff --git a/docs/sidecar-app-store.md b/docs/sidecar-app-store.md index 8a6097dc..3a98cdec 100644 --- a/docs/sidecar-app-store.md +++ b/docs/sidecar-app-store.md @@ -246,6 +246,49 @@ finishes the job — which is what `completedSteps` was for. --- +## Members get their own accounts + +The owner installs, but a server may already have members — and a member added next month needs the same +work done. So the unit is **(service × member)**, reachable from two triggers: + +``` +install a service -> provision every member who already exists +add a member -> provision every service already installed +``` + +Only handling the first is the classic thing that works on day one and rots quietly. There is no new +table: a member is provisioned for a service exactly when they hold a `service_connections` row for it — +their own credential, `url` NULL, inheriting the instance from the owner's. That schema was built for +this before this existed. + +Three outcomes, declared per catalogue entry as `members`, so the installer never special-cases a +service: + +| | Meaning | Services | +| ---------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `accounts` | Admin API creates the user **and** mints a credential. Fully transparent — the member just finds it working. | Immich, Jellyfin, Memos, InvoiceShelf, CalDAV | +| `invite` | The account can be created; a usable credential cannot. The member sets their own password. | Vaultwarden | +| `none` | Single-tenant daemon, no user concept. Access is mediated by Officer alone. | Transmission, slskd, headscale, email, music, wallet, notify, vnc | + +**`invite` is not a weaker `accounts`** — it is the correct outcome. Vaultwarden derives its encryption +key from the master password, so a credential we could mint would mean a vault we could read. Transparent +right up to the point where being transparent would be a defect. + +The per-service work is an **interface implemented beside each sidecar**, never a switch in core: a +central function growing one case per service is exactly what would stop any of this shipping from its +own repository. Implementations must be idempotent — both triggers can fire for the same pair, and +creating a second account upstream is not something we can undo. + +Deprovision is deliberately optional and defaults to doing nothing upstream. Deleting a user in Immich +deletes their photos; an app store that destroys data as a side effect of an unrelated action is worse +than one that leaves a stale account behind. + +**Assumed working:** the vault's own multi-user adaptation is being done separately. Today `/api/vault` +is owner-only by an explicit `ownerGate`, so a member is refused before Vaultwarden is reached — this +design is written as though that has landed. + +--- + ## What Phase 0 must not foreclose Three things are coming, and each one constrains a decision that looks free today. diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 6f63b9ae..239dd6ee 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -86,3 +86,23 @@ describe('byId', () => { expect(byId('not-a-sidecar')).toBeUndefined(); }); }); + +describe('member provisioning is declared for every entry', () => { + it('declares how members get access', () => { + // Undeclared would silently mean "no account for anyone", which is invisible until a member + // reports that a feature the owner can see does nothing for them. + for (const entry of CATALOGUE) expect(['accounts', 'invite', 'none']).toContain(entry.members); + }); + + it('never promises accounts for a single-tenant daemon', () => { + // Transmission and slskd have no user concept; claiming otherwise would make the installer try to + // create accounts against an API that does not exist. + for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none'); + }); + + it('marks the vault as invite-only, because it cannot be otherwise', () => { + // Vaultwarden derives its encryption key from the master password. A credential we could mint is a + // vault we could read, so 'accounts' here would be a security defect rather than a feature. + expect(byId('vault')!.members).toBe('invite'); + }); +}); diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 4e082f3a..30da888e 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -60,6 +60,24 @@ export type CatalogueEntry = { configFields?: ConfigField[]; /** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */ composeTemplate?: string; + /** + * Whether members get their own account on this service, and how far that can be automated. + * + * 'accounts' — an admin API can create the user AND mint a credential, so provisioning is fully + * transparent: the member simply finds the feature working. Immich, Jellyfin, Gitea, + * Memos. + * 'invite' — an account can be created but a usable credential cannot, and that is a property of + * the service rather than a gap in ours. Vaultwarden is end-to-end encrypted: the + * master password derives the encryption key, so a credential we could mint would mean + * a vault we could read. The member is invited and sets their own password. + * 'none' — a single-tenant daemon with no user concept. Transmission, slskd. Access is mediated + * entirely by Officer, which is already how it works. + * + * Read at two moments, not one: when the service is installed (for every member who already exists) + * and when a member is added (for every service already installed). Only handling the first is the + * classic thing that works on day one and silently rots. + */ + members: 'accounts' | 'invite' | 'none'; /** * A host requirement that is NOT derivable from `modes`. Shown instead of the install button rather * than failing halfway through. @@ -77,6 +95,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-photos', label: 'Photos', summary: 'Your Immich library — browse, search, upload from the phone', + members: 'accounts', modes: ['existing', 'provisioned'], capability: 'photos', composeTemplate: 'immich', @@ -96,6 +115,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-jellyfin', label: 'Jellyfin', summary: 'Films and shows, with a player that handles direct, HLS and progressive', + members: 'accounts', modes: ['existing', 'provisioned'], capability: 'jellyfin', composeTemplate: 'jellyfin', @@ -109,6 +129,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-memos', label: 'Memos', summary: 'Quick notes, tagged and searchable', + members: 'accounts', modes: ['existing', 'provisioned'], capability: 'memos', composeTemplate: 'memos', @@ -122,6 +143,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-invoiceshelf', label: 'Invoices', summary: 'InvoiceShelf — clients, estimates and invoices', + members: 'accounts', modes: ['existing', 'provisioned'], capability: 'invoices', composeTemplate: 'invoiceshelf', @@ -135,6 +157,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-vault', label: 'Vault', summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps', + members: 'invite', modes: ['existing', 'provisioned'], // No capability entry exists for this one, and the reason is about CREDENTIALS, not routing. // @@ -154,6 +177,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-transmission', label: 'Transmission', summary: 'Torrents, with the daemon Officer talks to over RPC', + members: 'none', modes: ['existing', 'provisioned'], capability: 'transmission', composeTemplate: 'transmission', @@ -182,6 +206,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-slskd', label: 'Soulseek', summary: 'slskd — search and download from the Soulseek network', + members: 'none', modes: ['existing', 'provisioned'], capability: 'soulseek', composeTemplate: 'slskd', @@ -195,6 +220,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-caldav', label: 'Calendar', summary: 'Radicale — calendars and contacts over CalDAV/CardDAV', + members: 'accounts', modes: ['existing', 'provisioned'], capability: 'calendar', composeTemplate: 'radicale', @@ -205,6 +231,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-headscale', label: 'Headscale', summary: 'Your own tailnet control plane', + members: 'none', modes: ['existing'], capability: 'headscale', existingFields: [ @@ -219,6 +246,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-email', label: 'Email', summary: 'Your IMAP accounts, synced and searchable', + members: 'none', modes: ['config'], capability: 'email', // Deliberately empty: accounts are added from /email, which already has a working multi-account @@ -230,6 +258,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-music', label: 'Music', summary: 'Index and play the library on this machine', + members: 'none', modes: ['config'], capability: 'music', configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }], @@ -239,6 +268,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-wallet', label: 'Wallet', summary: 'Bitcoin and Lightning, with keys held by the sidecar alone', + members: 'none', modes: ['config'], capability: 'wallet', configFields: [], @@ -248,6 +278,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-notify', label: 'Notifications', summary: 'Push to your phone when a job finishes or a turn needs you', + members: 'none', modes: ['config'], capability: 'notify', configFields: [], @@ -257,6 +288,7 @@ export const CATALOGUE: CatalogueEntry[] = [ process: 'officer-vnc', label: 'Desktop', summary: 'Mirror this machine’s display in the browser', + members: 'none', modes: ['config'], capability: 'desktop', // x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts new file mode 100644 index 00000000..e7abad9e --- /dev/null +++ b/src/servers/app-store/members.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test'; +import { plannedOutcome, servicesNeedingMemberProvision } from './members'; +import { byId } from './catalogue'; + +describe('what adding a member would do', () => { + it('predicts the outcome per service without contacting anything', () => { + expect(plannedOutcome(byId('photos')!)).toBe('provisioned'); + expect(plannedOutcome(byId('vault')!)).toBe('invited'); + expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable'); + }); +}); + +describe('which installed services have work to do', () => { + it('skips single-tenant daemons entirely', () => { + const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'vault']).map((e) => e.id); + expect(ids.sort()).toEqual(['photos', 'vault']); + }); + + it('ignores services that are not installed', () => { + // The catalogue is what COULD be installed; only what IS installed has a member to provision. + expect(servicesNeedingMemberProvision([])).toEqual([]); + expect(servicesNeedingMemberProvision(['jellyfin']).map((e) => e.id)).toEqual(['jellyfin']); + }); +}); diff --git a/src/servers/app-store/members.ts b/src/servers/app-store/members.ts new file mode 100644 index 00000000..c775ee87 --- /dev/null +++ b/src/servers/app-store/members.ts @@ -0,0 +1,99 @@ +import type { CatalogueEntry } from './catalogue'; +import { CATALOGUE } from './catalogue'; + +// Giving every member their own account on the services the owner installed. +// +// ── Why this is not part of install ── +// +// It has two triggers, and only ever handling the first is how this rots: +// +// install a service → provision every member who already exists +// add a member → provision every service already installed +// +// A member added next month to an Immich installed today needs exactly the same work to happen, and +// nobody will remember to do it by hand. So the unit is (service × member), reachable from both sides, +// rather than a loop inside the installer. +// +// ── What "provisioned" means, and where it is recorded ── +// +// There is no new table. A member is provisioned for a service exactly when they have a +// `service_connections` row for it — which already carries their own credential and a NULL `url`, +// inheriting the instance from the owner's row. That schema was built for this before this existed, and +// adding a second record of the same fact would only create the chance for the two to disagree. +// +// ── The ceiling ── +// +// `members: 'invite'` is not a weaker version of `'accounts'`; it is a different outcome. Vaultwarden +// derives its encryption key from the master password, so a credential we could mint would mean a vault +// we could read. The account is created and the member sets their own password. Transparent right up to +// the point where being transparent would be a defect. + +export type MemberProvisionOutcome = + /** Account exists and a credential was written. The member finds the feature working. */ + | { status: 'provisioned' } + /** Account exists; the member must complete it themselves. Carries where to send them. */ + | { status: 'invited'; completeAt: string } + /** Nothing to do — a single-tenant daemon. Not a failure. */ + | { status: 'not-applicable' } + /** Upstream refused. The caller records it; the member simply has no access yet. */ + | { status: 'failed'; error: string }; + +/** + * What provisioning this member for this service would do — without doing it. + * + * Split out so the UI can say "adding Ana will give her Photos and Jellyfin, and invite her to the + * vault" BEFORE anyone commits, and so the decision is testable without an Immich to talk to. + */ +export function plannedOutcome(entry: CatalogueEntry): MemberProvisionOutcome['status'] { + switch (entry.members) { + case 'accounts': + return 'provisioned'; + case 'invite': + return 'invited'; + case 'none': + return 'not-applicable'; + } +} + +/** + * Every installed service that has anything to do for a new member. + * + * `'none'` entries are filtered out here rather than inside the loop that provisions, so the caller can + * distinguish "nothing to do" from "did nothing" — the two look identical at a call site and mean very + * different things when someone is debugging why a member cannot see a feature. + */ +export function servicesNeedingMemberProvision(installedIds: readonly string[]): CatalogueEntry[] { + const installed = new Set(installedIds); + return CATALOGUE.filter((e) => installed.has(e.id) && e.members !== 'none'); +} + +/** + * The per-service work, to be implemented alongside each sidecar rather than centrally. + * + * Deliberately an interface and not a switch: the whole point of the app store is that a sidecar carries + * everything it needs, and a central function that grows a case per service is the thing that stops any + * of this shipping from its own repository. A third-party plugin implements this; nothing in core knows + * its name. + * + * Implementations MUST be idempotent. Both triggers can fire for the same pair — a member added while an + * install is still running is not a rare race, it is a Tuesday — and creating a second account upstream + * is not recoverable from our side. Treat "already exists" as success. + */ +export type MemberProvisioner = { + sidecarId: string; + /** + * Create (or confirm) the member's account upstream and return their credential. + * + * Returning `invited` is a normal outcome, not an error path: it means the account is real and the + * member must finish it. `completeAt` is where to send them. + */ + provision(params: { userId: number; email: string; username: string }): Promise; + /** + * Undone when a member is removed, or when the service is uninstalled with data disposal. + * + * Separate from provision because the honest default is to leave the upstream account alone: deleting + * a user in Immich deletes their photos, and an app store that silently destroys data on an unrelated + * action is worse than one that leaves a stale account behind. + */ + deprovision?(params: { userId: number }): Promise; +};