Files
platform/src/servers/app-store/members.ts
T
pastilhasandClaude Opus 5 ee38257856 app store: make member provisioning a mechanism, not an install-time loop
The owner installs, but a server may already have members, and a member added next month needs the same
work. So the unit is (service × member) reachable from two triggers — install a service, provision
existing members; add a member, provision installed services — rather than a loop inside the installer.
Only handling the first works on day one and rots.

No new table. A member is provisioned exactly when they hold a service_connections row: their own
credential, url NULL, inheriting the instance from the owner's. That schema anticipated this before this
existed, and a second record of the same fact would only be able to disagree with the first.

Three outcomes, declared per catalogue entry so the installer never special-cases a service. `accounts`
is fully transparent. `none` is a single-tenant daemon with nothing to do — filtered before the
provisioning loop so callers can tell "nothing to do" from "did nothing", which look identical at a call
site and matter when someone is asking why a member cannot see a feature.

`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 where being transparent would be a defect.

The per-service work is an interface implemented beside each sidecar rather than a switch in core — a
central function growing a case per service is what would stop any of this shipping from its own
repository. Implementations must be idempotent, since both triggers can fire for the same pair and a
duplicate account upstream is not ours to undo. Deprovision is optional and defaults to leaving the
upstream account alone: deleting an Immich user deletes their photos.

Written assuming the vault's multi-user adaptation has landed. Today /api/vault is owner-only by an
explicit ownerGate, so a member is refused before Vaultwarden is reached — verified, and out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:44:30 +00:00

100 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MemberProvisionOutcome>;
/**
* 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<void>;
};