import { linkPhotosAccount, listPhotosAccountLinks, setPhotosAccountKey } from '../db/queries'; import { establishKey, provision, type ProvisionOutcome } from './provision'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; import { invalidateConfig, type UpstreamTarget } from './upstream'; // The one place that turns "an admin just connected" into Immich accounts and stored keys. // // Sits between `addAccount` and `provision()` so neither has to know about the other: `provision` talks to // Immich and returns what it did, this writes it down, and the caller only has to say when. // // ── Why it never throws ── // // A failure here must not fail the connection. Registering the instance is the thing the owner asked for // and it has already succeeded by the time this runs — losing it because provisioning went wrong would // leave them with no library AND no accounts, which is strictly worse than the accounts alone. // // ── Temporary passwords come back to the caller ── // // `handover` is the half that has to reach a person. It is returned, never logged, and never stored: see // the header of provision.ts for why Immich gets a hand-over password where Jellyfin gets none. export type Handover = { userId: number; email: string; temporaryPassword: string }; export type ProvisioningReport = | { ran: false; reason: string } | { ran: true; linked: number; created: number; untouched: number; failed: { email: string; error: string }[]; log: string[]; /** One entry per person who now has a key. Carries a live credential — do not log it. */ handover: Handover[]; }; /** * Record one person's pairing and give them a key. * * Written one at a time rather than in a batch: a key minted upstream whose row fails to write is the bad * case, because it leaves an Immich API key nothing in Officer knows about. Recording each as soon as it is * known keeps that window as small as it can be. */ async function linkAndKey(params: { cfg: UpstreamTarget; configId: number; userId: number; immichUserId: string; email: string; adopted: boolean; }): Promise { const { cfg, configId, userId, immichUserId, email, adopted } = params; await linkPhotosAccount({ userId, configId, immichUserId, immichEmail: email, adopted }); const { apiKey, temporaryPassword } = await establishKey(cfg, { immichUserId, email }); await setPhotosAccountKey({ userId, configId, apiKey }); // Their cached config says "nothing configured" and would hold that for up to a minute. invalidateConfig(userId); return { userId, email, temporaryPassword }; } export async function runProvisioning(params: { cfg: UpstreamTarget; configId: number; }): Promise { const { cfg, configId } = params; // Never told, versus told there is nobody. The first means the push has not arrived or this plugin is // not entitled to it; the second is a server with only an owner. if (!hasProvisionableUsers()) { return { ran: false, reason: 'the platform has not sent the account list yet' }; } const users = getProvisionableUsers(); if (users.length === 0) return { ran: false, reason: 'no accounts to provision — this server has only an owner' }; try { const existing = await listPhotosAccountLinks(configId); const outcome: ProvisionOutcome = await provision({ cfg, users, existing: existing.map((e) => ({ userId: e.userId, immichUserId: e.immichUserId })), }); const handover: Handover[] = []; const failed = [...outcome.failed]; for (const entry of [...outcome.linked, ...outcome.created]) { const adopted = outcome.created.every((c) => c.userId !== entry.userId); try { handover.push(await linkAndKey({ cfg, configId, adopted, ...entry })); } catch (error) { failed.push({ email: entry.email, error: error instanceof Error ? error.message : String(error) }); } } for (const line of outcome.log) console.log(`[photos:provision] ${line}`); for (const f of failed) console.error(`[photos:provision] ${f.email}: ${f.error}`); return { ran: true, linked: outcome.linked.length, created: outcome.created.length, untouched: outcome.untouched.length, failed, log: outcome.log, handover, }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error('[photos:provision] failed:', reason); return { ran: false, reason }; } } export type BackfillReport = { minted: Handover[]; /** Already had a key. Reported rather than silent, so a re-run visibly does nothing. */ alreadyKeyed: string[]; failed: { email: string; error: string }[]; }; /** * Give a key to every linked account that has none. * * The repair path, for rows recorded before the key column existed or whose minting failed. Cheap when * there is nothing to do: an account that already has a key costs no upstream call, so this is safe to run * on every re-provision. * * Unlike the Jellyfin equivalent there is no `includeAdopted` switch, and the difference is the * must-change-password flag. There, minting for an adopted account destroys the person's own access, * because the new password is discarded and Jellyfin cannot demand a change. Here they get the password * back and are made to replace it, so an adopted account ends the run with MORE access than it started — * their library, through Officer, plus their own app once they sign in. There is nothing to opt into. */ export async function backfillAccountKeys(params: { cfg: UpstreamTarget; configId: number; }): Promise { const { cfg, configId } = params; const report: BackfillReport = { minted: [], alreadyKeyed: [], failed: [] }; for (const account of await listPhotosAccountLinks(configId)) { if (account.hasKey) { report.alreadyKeyed.push(account.immichEmail); continue; } try { const { apiKey, temporaryPassword } = await establishKey(cfg, { immichUserId: account.immichUserId, email: account.immichEmail, }); await setPhotosAccountKey({ userId: account.userId, configId, apiKey }); invalidateConfig(account.userId); report.minted.push({ userId: account.userId, email: account.immichEmail, temporaryPassword }); console.log(`[photos:provision] minted a key for ${account.immichEmail}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); report.failed.push({ email: account.immichEmail, error: message }); console.error(`[photos:provision] ${account.immichEmail}: ${message}`); } } return report; }