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>
This commit is contained in:
@@ -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<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>;
|
||||
};
|
||||
Reference in New Issue
Block a user