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:
@@ -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
|
## What Phase 0 must not foreclose
|
||||||
|
|
||||||
Three things are coming, and each one constrains a decision that looks free today.
|
Three things are coming, and each one constrains a decision that looks free today.
|
||||||
|
|||||||
@@ -86,3 +86,23 @@ describe('byId', () => {
|
|||||||
expect(byId('not-a-sidecar')).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ export type CatalogueEntry = {
|
|||||||
configFields?: ConfigField[];
|
configFields?: ConfigField[];
|
||||||
/** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */
|
/** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */
|
||||||
composeTemplate?: string;
|
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
|
* A host requirement that is NOT derivable from `modes`. Shown instead of the install button rather
|
||||||
* than failing halfway through.
|
* than failing halfway through.
|
||||||
@@ -77,6 +95,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-photos',
|
process: 'officer-photos',
|
||||||
label: 'Photos',
|
label: 'Photos',
|
||||||
summary: 'Your Immich library — browse, search, upload from the phone',
|
summary: 'Your Immich library — browse, search, upload from the phone',
|
||||||
|
members: 'accounts',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'photos',
|
capability: 'photos',
|
||||||
composeTemplate: 'immich',
|
composeTemplate: 'immich',
|
||||||
@@ -96,6 +115,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-jellyfin',
|
process: 'officer-jellyfin',
|
||||||
label: 'Jellyfin',
|
label: 'Jellyfin',
|
||||||
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
|
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
|
||||||
|
members: 'accounts',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'jellyfin',
|
capability: 'jellyfin',
|
||||||
composeTemplate: 'jellyfin',
|
composeTemplate: 'jellyfin',
|
||||||
@@ -109,6 +129,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-memos',
|
process: 'officer-memos',
|
||||||
label: 'Memos',
|
label: 'Memos',
|
||||||
summary: 'Quick notes, tagged and searchable',
|
summary: 'Quick notes, tagged and searchable',
|
||||||
|
members: 'accounts',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'memos',
|
capability: 'memos',
|
||||||
composeTemplate: 'memos',
|
composeTemplate: 'memos',
|
||||||
@@ -122,6 +143,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-invoiceshelf',
|
process: 'officer-invoiceshelf',
|
||||||
label: 'Invoices',
|
label: 'Invoices',
|
||||||
summary: 'InvoiceShelf — clients, estimates and invoices',
|
summary: 'InvoiceShelf — clients, estimates and invoices',
|
||||||
|
members: 'accounts',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'invoices',
|
capability: 'invoices',
|
||||||
composeTemplate: 'invoiceshelf',
|
composeTemplate: 'invoiceshelf',
|
||||||
@@ -135,6 +157,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-vault',
|
process: 'officer-vault',
|
||||||
label: 'Vault',
|
label: 'Vault',
|
||||||
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
|
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
|
||||||
|
members: 'invite',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
// No capability entry exists for this one, and the reason is about CREDENTIALS, not routing.
|
// 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',
|
process: 'officer-transmission',
|
||||||
label: 'Transmission',
|
label: 'Transmission',
|
||||||
summary: 'Torrents, with the daemon Officer talks to over RPC',
|
summary: 'Torrents, with the daemon Officer talks to over RPC',
|
||||||
|
members: 'none',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'transmission',
|
capability: 'transmission',
|
||||||
composeTemplate: 'transmission',
|
composeTemplate: 'transmission',
|
||||||
@@ -182,6 +206,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-slskd',
|
process: 'officer-slskd',
|
||||||
label: 'Soulseek',
|
label: 'Soulseek',
|
||||||
summary: 'slskd — search and download from the Soulseek network',
|
summary: 'slskd — search and download from the Soulseek network',
|
||||||
|
members: 'none',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'soulseek',
|
capability: 'soulseek',
|
||||||
composeTemplate: 'slskd',
|
composeTemplate: 'slskd',
|
||||||
@@ -195,6 +220,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-caldav',
|
process: 'officer-caldav',
|
||||||
label: 'Calendar',
|
label: 'Calendar',
|
||||||
summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
|
summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
|
||||||
|
members: 'accounts',
|
||||||
modes: ['existing', 'provisioned'],
|
modes: ['existing', 'provisioned'],
|
||||||
capability: 'calendar',
|
capability: 'calendar',
|
||||||
composeTemplate: 'radicale',
|
composeTemplate: 'radicale',
|
||||||
@@ -205,6 +231,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-headscale',
|
process: 'officer-headscale',
|
||||||
label: 'Headscale',
|
label: 'Headscale',
|
||||||
summary: 'Your own tailnet control plane',
|
summary: 'Your own tailnet control plane',
|
||||||
|
members: 'none',
|
||||||
modes: ['existing'],
|
modes: ['existing'],
|
||||||
capability: 'headscale',
|
capability: 'headscale',
|
||||||
existingFields: [
|
existingFields: [
|
||||||
@@ -219,6 +246,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-email',
|
process: 'officer-email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
summary: 'Your IMAP accounts, synced and searchable',
|
summary: 'Your IMAP accounts, synced and searchable',
|
||||||
|
members: 'none',
|
||||||
modes: ['config'],
|
modes: ['config'],
|
||||||
capability: 'email',
|
capability: 'email',
|
||||||
// Deliberately empty: accounts are added from /email, which already has a working multi-account
|
// 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',
|
process: 'officer-music',
|
||||||
label: 'Music',
|
label: 'Music',
|
||||||
summary: 'Index and play the library on this machine',
|
summary: 'Index and play the library on this machine',
|
||||||
|
members: 'none',
|
||||||
modes: ['config'],
|
modes: ['config'],
|
||||||
capability: 'music',
|
capability: 'music',
|
||||||
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }],
|
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }],
|
||||||
@@ -239,6 +268,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-wallet',
|
process: 'officer-wallet',
|
||||||
label: 'Wallet',
|
label: 'Wallet',
|
||||||
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
|
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
|
||||||
|
members: 'none',
|
||||||
modes: ['config'],
|
modes: ['config'],
|
||||||
capability: 'wallet',
|
capability: 'wallet',
|
||||||
configFields: [],
|
configFields: [],
|
||||||
@@ -248,6 +278,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-notify',
|
process: 'officer-notify',
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
summary: 'Push to your phone when a job finishes or a turn needs you',
|
summary: 'Push to your phone when a job finishes or a turn needs you',
|
||||||
|
members: 'none',
|
||||||
modes: ['config'],
|
modes: ['config'],
|
||||||
capability: 'notify',
|
capability: 'notify',
|
||||||
configFields: [],
|
configFields: [],
|
||||||
@@ -257,6 +288,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
process: 'officer-vnc',
|
process: 'officer-vnc',
|
||||||
label: 'Desktop',
|
label: 'Desktop',
|
||||||
summary: 'Mirror this machine’s display in the browser',
|
summary: 'Mirror this machine’s display in the browser',
|
||||||
|
members: 'none',
|
||||||
modes: ['config'],
|
modes: ['config'],
|
||||||
capability: 'desktop',
|
capability: 'desktop',
|
||||||
// x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the
|
// x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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