import { randomBytes } from 'node:crypto'; import type { ProvisionableUser } from 'officerdb'; import { callUpstream, mintApiKey, type UpstreamTarget } from './upstream'; import { describePlan, normalize, planReconcile, type ImmichUser, type ReconcilePlan } from './reconcile'; // Carrying out the plan. `reconcile.ts` decides; this does, and does nothing else. // // ── Where this deliberately differs from the Jellyfin plugin ── // // Jellyfin's provisioning generates a password, spends it once on a sign-in, and discards it so that // nobody ever learns it. That is right THERE for a reason it states: Jellyfin has no must-change-password // flag, so a password handed to somebody stays valid indefinitely, and the deployment plan is that // Jellyfin's own web UI is never exposed at all. // // Neither half of that holds for Immich: // // 1. **Immich HAS the flag.** `shouldChangePassword` is on both the create and the update DTO. A handed // -over password can be made single-use in the only sense that matters — the account forces a change // on first sign-in. // 2. **Immich's own app is the point.** Background phone backup runs in Immich's mobile app, not in // Officer, and it needs a password. An account whose password nobody knows can be browsed through // Officer and can never back a phone up again. For a photo library that is most of the value. // // So this mints the API key AND reports the temporary password, once, in the response. The owner passes it // on; the person signs into the Immich app and is immediately forced to set their own. Officer's key is // unaffected by that change — `UserAdminService.update` hashes the new password and touches nothing else, // so API keys and sessions both survive it (read from the running 3.1.0 image, not assumed). // // The password is returned and never logged. It is in exactly one place, the reply to the request that // caused it, and it stops being useful the moment its owner signs in. /** Long enough to be worthless to guess in the window before it is changed. */ const generatePassword = (): string => randomBytes(24).toString('base64url'); /** * The storage quota given to an account Officer creates. `null` is Immich's "unlimited". * * Unlimited matches every account already on this instance, so a provisioned member is indistinguishable * from one made by hand — a cap only on Officer-made accounts would be a rule to remember rather than a * policy. Change this one constant to enforce one; Immich applies it server-side and the owner can still * raise it per person from Immich's own admin screen. */ const QUOTA_BYTES: number | null = null; export type ProvisionOutcome = { linked: { userId: number; immichUserId: string; email: string }[]; created: { userId: number; immichUserId: string; email: string }[]; /** * Per person: the key to store, and the temporary password to hand over. Keyed by Officer user id. * * Separate from `linked`/`created` because it is the half that must not be logged, and keeping it in its * own structure makes that visible at every call site rather than depending on nobody dumping the outcome. */ credentials: { userId: number; email: string; apiKey: string; temporaryPassword: string }[]; /** Left alone, reported so the owner can see accounts Officer does not manage. */ untouched: ImmichUser[]; /** The account the API key belongs to — the owner's own. */ self: ImmichUser | null; failed: { email: string; error: string }[]; /** One line per decision, for the log. Never contains a credential. */ log: string[]; }; /** Everything Immich knows about, as `GET /api/admin/users` reports it. */ export async function listImmichUsers(cfg: UpstreamTarget): Promise { const res = await callUpstream(cfg, { path: '/api/admin/users' }); if (!res.ok) throw new Error(`GET /api/admin/users → ${res.status}`); const body = (await res.json()) as { id: string; email: string; name?: string }[]; return body.map((u) => ({ id: u.id, email: u.email, name: u.name ?? '' })); } /** * Who the configured API key authenticates as. * * Read rather than assumed, and the whole reason the owner's own Immich account does not end up reported * as belonging to nobody. */ export async function whoAmI(cfg: UpstreamTarget): Promise { const res = await callUpstream(cfg, { path: '/api/users/me' }); if (!res.ok) return null; const body = (await res.json()) as { id?: unknown }; return typeof body.id === 'string' ? body.id : null; } /** Admin-set a password on an account. Empty-bodied 200/204 either way; only the status matters. */ async function setPassword(cfg: UpstreamTarget, immichUserId: string, password: string): Promise { const res = await callUpstream(cfg, { method: 'PUT', path: `/api/admin/users/${immichUserId}`, contentType: 'application/json', // `shouldChangePassword: false` for THIS write: the sign-in that mints the key happens next, and the // flag is raised afterwards. Setting it here would be asking the account to change a password in the // same breath as using it. body: JSON.stringify({ password, shouldChangePassword: false }), }); if (!res.ok) { throw new Error(`PUT /api/admin/users/${immichUserId} → ${res.status}: ${(await res.text()).slice(0, 200)}`); } } /** Raise the must-change flag, once the key is safely in hand. */ async function requirePasswordChange(cfg: UpstreamTarget, immichUserId: string): Promise { const res = await callUpstream(cfg, { method: 'PUT', path: `/api/admin/users/${immichUserId}`, contentType: 'application/json', body: JSON.stringify({ shouldChangePassword: true }), }); // Not fatal. The key exists and the library is reachable; all that is lost is the prompt, and failing the // whole provisioning over it would throw away a credential that already works. if (!res.ok) { console.error(`[photos:provision] could not set shouldChangePassword on ${immichUserId} → ${res.status}`); } } /** * Give one account a fresh password, spend it on an API key, then require it to be changed. * * Shared by the create and the adopt paths, which differ only in whether the account existed a moment ago. * Both end in the same place: Officer holds a durable key, and one temporary password is on its way to a * person who will be made to replace it. */ async function establishKey( cfg: UpstreamTarget, account: { immichUserId: string; email: string }, ): Promise<{ apiKey: string; temporaryPassword: string }> { const temporaryPassword = generatePassword(); await setPassword(cfg, account.immichUserId, temporaryPassword); const apiKey = await mintApiKey(cfg.base, account.email, temporaryPassword); await requirePasswordChange(cfg, account.immichUserId); return { apiKey, temporaryPassword }; } /** * Issue a fresh temporary password for one account, without touching its API key. * * "This person is setting their phone up" — which is a thing that happens long after provisioning, and * more than once. The alternative would be storing the password from the provisioning run so it could be * read back later, and a stored password is exactly what this design exists to avoid: it would sit in * Postgres indefinitely, still valid, for an account whose owner may already have chosen their own. * * Safe to call at any time. An Immich API key is independent of the password, so Officer's access is * unaffected — the person simply gets a new way in, and is made to replace it on arrival. * * One call rather than two, unlike `establishKey`: nothing signs in with this password here, so the flag * can go up in the same write that sets it. */ export async function reissuePassword( cfg: UpstreamTarget, account: { immichUserId: string; email: string }, ): Promise { const temporaryPassword = generatePassword(); const res = await callUpstream(cfg, { method: 'PUT', path: `/api/admin/users/${account.immichUserId}`, contentType: 'application/json', body: JSON.stringify({ password: temporaryPassword, shouldChangePassword: true }), }); if (!res.ok) { throw new Error(`PUT /api/admin/users/${account.immichUserId} → ${res.status}: ${(await res.text()).slice(0, 200)}`); } return temporaryPassword; } /** Create one account, then give it a key. */ async function createOne(cfg: UpstreamTarget, user: ProvisionableUser): Promise<{ immichUserId: string; email: string }> { const res = await callUpstream(cfg, { method: 'POST', path: '/api/admin/users', contentType: 'application/json', body: JSON.stringify({ email: user.email, // Set here and replaced moments later by `establishKey`. Immich requires one on create and this // value never leaves the function, so there is nothing to hand over or to store. password: generatePassword(), name: user.name || user.username || user.email, quotaSizeInBytes: QUOTA_BYTES, shouldChangePassword: false, // No email. Immich would try SMTP it may not have, and what it sends is a password — which is // precisely the thing this flow keeps out of a mailbox. notify: false, }), }); if (!res.ok) { throw new Error(`POST /api/admin/users → ${res.status}: ${(await res.text()).slice(0, 200)}`); } const created = (await res.json()) as { id?: string; email?: string }; if (!created.id) throw new Error('POST /api/admin/users returned no id'); return { immichUserId: created.id, email: created.email ?? user.email }; } /** * Reconcile Officer's accounts against Immich's, and carry out what is missing. * * Idempotent: run it twice and the second does nothing, because `existing` grows and the plan shrinks. * Never deletes anything. */ export async function provision(params: { cfg: UpstreamTarget; users: ProvisionableUser[]; existing: { userId: number; immichUserId: string }[]; }): Promise { const { cfg, users, existing } = params; const [immichUsers, adminImmichUserId] = await Promise.all([listImmichUsers(cfg), whoAmI(cfg)]); const plan: ReconcilePlan = planReconcile({ users, immichUsers, existing, adminImmichUserId }); const outcome: ProvisionOutcome = { linked: [], created: [], credentials: [], untouched: plan.leave, self: plan.self, failed: [], log: describePlan(plan), }; // Adoptions first: recording a pairing touches nothing upstream, so it cannot fail halfway and leave // Immich ahead of the mapping. for (const { user, immich } of plan.link) { outcome.linked.push({ userId: user.id, immichUserId: immich.id, email: immich.email }); } for (const user of plan.create) { try { const made = await createOne(cfg, user); outcome.created.push({ userId: user.id, immichUserId: made.immichUserId, email: made.email }); } catch (error) { // One person failing must not stop the rest. A partially provisioned instance is recoverable by // running again; a run that aborts on the first error leaves everyone after them unprovisioned. outcome.failed.push({ email: user.email ?? String(user.id), error: error instanceof Error ? error.message : String(error), }); } } return outcome; } export { establishKey, normalize };