import { OWNER_USER_ID } from 'officerdb'; import { getActivePhotosCredentials, linkPhotosAccount, listPhotosAccountLinks, setPhotosAccountKey, } from '../db/queries'; import { establishKey, provision, type ProvisionOutcome } from './provision'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; import { invalidateConfig, normalizeBase, 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[]; /** * Linked, but deliberately left without a key: an account that pre-dated Officer, found by a run * that was not allowed to touch its password. They reach Photos once somebody runs the deliberate * provision. Named rather than counted, because the owner has to know WHO to go and do. */ pending: string[]; }; /** * 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 }; } /** * `keyAdopted` decides what happens to an account that existed before Officer did. * * Keying one means resetting its password, because that is the only way Immich will issue a key. For an * account Officer just created that costs nothing — nobody has ever held that password. For an adopted one * it takes away a login somebody is using, most likely in Immich's own app, and hands back a replacement * that only reaches them if the owner passes it on. * * So it is TRUE when a person asked for it (the `/provision` route) and FALSE when the trigger was a * background event (a new account appearing). An automatic action must not reset a password nobody * knew was at stake; the adopted account is linked and named in `pending` instead. */ export async function runProvisioning(params: { cfg: UpstreamTarget; configId: number; keyAdopted: boolean; }): Promise { const { cfg, configId, keyAdopted } = 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 pending: string[] = []; const failed = [...outcome.failed]; // Created accounts always get a key: their password was set moments ago by this process and has never // been anybody's, so spending it costs nothing. for (const entry of outcome.created) { try { handover.push(await linkAndKey({ cfg, configId, adopted: false, ...entry })); } catch (error) { failed.push({ email: entry.email, error: error instanceof Error ? error.message : String(error) }); } } // Adopted ones are recorded either way — the pairing is a fact and costs nothing to write. Whether they // also get a key is the caller's decision, for the reason on `keyAdopted` above. for (const entry of outcome.linked) { try { if (keyAdopted) { handover.push(await linkAndKey({ cfg, configId, adopted: true, ...entry })); } else { await linkPhotosAccount({ userId: entry.userId, configId, immichUserId: entry.immichUserId, immichEmail: entry.email, adopted: true, }); pending.push(entry.email); } } 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 email of pending) { console.log(`[photos:provision] ${email} is linked but has no key — it had an Immich account already.`); console.log(`[photos:provision] run POST /_config/${configId}/provision to give them one.`); } 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, pending, }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error('[photos:provision] failed:', reason); return { ran: false, reason }; } } /** * Provision because the ACCOUNT LIST changed, rather than because somebody asked. * * This is what makes an Officer account created next month reach Photos without anyone remembering that a * plugin needed telling. `users:list` arrives on every create and delete — and on every sidecar * registration, so a restart is also a reconciliation. * * Three properties it has to have, because nobody is watching it run: * * - **Idempotent.** The plan shrinks as links are recorded, so the common case does no upstream writes * at all and the run costs two GETs. * - **Never destructive.** `keyAdopted: false` — see `runProvisioning`. An account that pre-dated * Officer keeps its password; the owner is told it is waiting. * - **Quiet about the outcome, not about the plan.** `runProvisioning` logs its plan either way, so a * restart prints a few lines describing the world as this plugin sees it. Only the "something * changed" summary below is suppressed when nothing did, which is what stops a run that mattered from * reading like the dozen that did not. * * Never throws. It runs detached from the command handler, and a plugin failing to provision must not take * down the socket carrying the message. */ export async function provisionForAccountChange(): Promise { try { // The instance members inherit is the owner's selected one. A member who registered an Immich of their // own is not a provisioning target — that is their connection, not a shared library. const creds = await getActivePhotosCredentials(OWNER_USER_ID); if (!creds) return; // nothing connected yet; connecting is itself a provisioning trigger const report = await runProvisioning({ cfg: { base: normalizeBase(creds.url), key: creds.apiKey }, configId: creds.id, keyAdopted: false, }); if (!report.ran) { console.log(`[photos:provision] not run — ${report.reason}`); return; } if (report.created === 0 && report.linked === 0 && report.failed.length === 0) return; // nothing changed console.log( `[photos:provision] account list changed: ${report.created} created, ${report.linked} linked, ` + `${report.pending.length} awaiting a key`, ); } catch (error) { console.error('[photos:provision] automatic run failed:', error instanceof Error ? error.message : error); } } 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; }