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; };