diff --git a/db/queries.ts b/db/queries.ts index b9b01f3..499b274 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,6 +1,6 @@ -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, isNotNull } from 'drizzle-orm'; import { db } from 'officerdb/db'; -import { photosConfig } from './schema'; +import { photosAccounts, photosConfig } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from @@ -53,6 +53,44 @@ export async function getActivePhotosCredentials(userId: number): Promise { + const [row] = await db + .select({ + id: photosConfig.id, + label: photosConfig.label, + url: photosConfig.url, + apiKey: photosAccounts.apiKey, + }) + .from(photosAccounts) + .innerJoin(photosConfig, eq(photosAccounts.configId, photosConfig.id)) + .where( + and(eq(photosAccounts.userId, userId), eq(photosConfig.isActive, true), isNotNull(photosAccounts.apiKey)), + ); + + // The isNotNull above already excludes it; this narrows the type and keeps the guarantee local. + if (!row?.apiKey) return null; + return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) }; +} + /** One account's credentials by id — for probing a specific account rather than the active one. */ export async function getPhotosCredentials(userId: number, id: number): Promise { const [row] = await db @@ -175,3 +213,80 @@ export async function recordPhotosProbe(userId: number, id: number, version: str .set({ version, lastSeenAt: new Date() }) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))); } + +// ── The Officer ↔ Immich account mapping ── + +/** + * Links already recorded for one instance, for the reconcile to compare against. + * + * `hasKey` rather than the key itself: callers need to know whether this person can reach their library, + * and none of them needs the credential to answer that. The ciphertext does not leave this file. + */ +export async function listPhotosAccountLinks(configId: number): Promise< + { userId: number; immichUserId: string; immichEmail: string; adopted: boolean; hasKey: boolean }[] +> { + const rows = await db + .select({ + userId: photosAccounts.userId, + immichUserId: photosAccounts.immichUserId, + immichEmail: photosAccounts.immichEmail, + adopted: photosAccounts.adopted, + apiKey: photosAccounts.apiKey, + }) + .from(photosAccounts) + .where(eq(photosAccounts.configId, configId)); + return rows.map(({ apiKey, ...rest }) => ({ ...rest, hasKey: !!apiKey })); +} + +/** + * Record a pairing. + * + * `onConflictDoNothing` on BOTH unique indexes, so a re-run is silent rather than an error — a second + * reconcile finding the same thing already true is the normal case, not a fault. + */ +export async function linkPhotosAccount(params: { + userId: number; + configId: number; + immichUserId: string; + immichEmail: string; + adopted: boolean; +}): Promise { + await db + .insert(photosAccounts) + .values({ + userId: params.userId, + configId: params.configId, + immichUserId: params.immichUserId, + immichEmail: params.immichEmail, + // Mirrors what Immich stores, so matching is a lookup rather than a convention every call site has + // to remember. + normalizedEmail: params.immichEmail.trim().toLowerCase(), + adopted: params.adopted, + }) + .onConflictDoNothing(); +} + +/** + * Store the key minted for one person, encrypted. + * + * Separate from `linkPhotosAccount` rather than a parameter on it, for the reason the Jellyfin side learned + * the hard way: linking is `onConflictDoNothing`, but a key is exactly the thing you re-run provisioning to + * REPLACE. An insert that does nothing on conflict cannot express that. + */ +export async function setPhotosAccountKey(params: { + userId: number; + configId: number; + apiKey: string; +}): Promise { + await db + .update(photosAccounts) + .set({ apiKey: encryptSecret('photos', params.apiKey) }) + .where(and(eq(photosAccounts.userId, params.userId), eq(photosAccounts.configId, params.configId))); +} + +/** Drop a pairing. Removes Officer's RECORD of the account, never the account itself. */ +export async function unlinkPhotosAccount(userId: number, configId: number): Promise { + await db + .delete(photosAccounts) + .where(and(eq(photosAccounts.userId, userId), eq(photosAccounts.configId, configId))); +} diff --git a/db/schema.ts b/db/schema.ts index 47e89d5..ec14c4a 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -47,3 +47,66 @@ export const photosConfig = pgTable( .where(sql`${t.isActive}`), ], ); + +// ── Which Officer account owns which Immich account ── +// +// The same shape as `jellyfin_accounts`, and for the same reason: Officer's users and Immich's are two +// populations that overlap, and this records where they meet. Without it a member has a permission grant, +// a dock tile, and no credential — which reads as a permissions fault and is not one. +// +// ── Matching is on EMAIL, not a username ── +// +// This is the one real difference from the Jellyfin side, and it is a simplification. Immich identifies an +// account by email and enforces uniqueness on it, while Jellyfin has usernames and a case-folded +// `NormalizedUsername` index to match against. Officer's `users.email` is therefore the same key on both +// sides, so pairing is an exact lookup rather than a convention. `normalizedEmail` is lowercased, because +// Immich lowercases what it stores (`ZodEmail` with a transform) and a mixed-case address would otherwise +// fail to match the account it just created. +// +// ── `adopted` earns its column here too ── +// +// False means Officer created the account and may remove it. True means it pre-existed and was merely +// linked — on this server `conceiba@gmail.com` and `fedrasantamaria@gmail.com` both had Immich accounts, +// with photographs in them, before Officer knew who they were. +export const photosAccounts = pgTable( + 'photos_accounts', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** Which registry row this account lives on. Immich users are per-instance. */ + configId: integer('config_id') + .notNull() + .references(() => photosConfig.id, { onDelete: 'cascade' }), + /** Immich's own user id — a UUID. The only handle its admin API accepts. */ + immichUserId: text('immich_user_id').notNull(), + /** As Immich holds it, for display. */ + immichEmail: text('immich_email').notNull(), + /** Lowercased, mirroring what Immich stores. What matching compares. */ + normalizedEmail: text('normalized_email').notNull(), + /** False = Officer created it and may remove it. True = it pre-existed and was linked. */ + adopted: boolean('adopted').notNull().default(false), + /** + * That person's OWN Immich API key, encrypted — the credential their Officer session reads the library + * with. + * + * Nullable, and null is a real state rather than an error: minting requires a session, a session + * requires a password, and a password is not always ours to set. Null resolves to the 503 the UI + * already knows how to explain, never to the owner's key. + * + * An Immich key survives a later password change — `UserAdminService.update` hashes and writes the + * password and touches nothing else, verified against the running 3.1.0 image. That is what makes it + * safe to hand somebody a temporary password for their own mobile app without this going stale. + */ + apiKey: text('api_key'), // encrypted + linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // One Immich account per Officer user per instance. uniqueIndex, not unique().on() — drizzle-kit + // re-creates named composite CONSTRAINTS on every push. See src/databases/CLAUDE.md. + uniqueIndex('uq_photos_accounts_user_config').on(t.userId, t.configId), + // And one Officer user per Immich account: two people cannot share a library. + uniqueIndex('uq_photos_accounts_config_immich_user').on(t.configId, t.immichUserId), + ], +); diff --git a/manifest.ts b/manifest.ts index a8efef9..999c676 100644 --- a/manifest.ts +++ b/manifest.ts @@ -29,6 +29,14 @@ export const manifest: PluginManifest = { icon: 'Images', color: '#10b981', + // Immich accounts are created one per Officer account. Declared here so the owner sees it on the install + // screen beside the permissions — "this plugin will be told who your users are" belongs where a person + // decides, not in a log afterwards. + // + // What it turns on: the sidecar receives `users:list` when it registers and whenever an account is + // created or removed. See src/servers/plugins/user-provisioning.ts and plugins/USER_PROVISIONING.md. + userProvisioning: true, + permissions: [ { key: 'photos', diff --git a/sidecar/config.ts b/sidecar/config.ts index efe8283..e2a20c6 100644 --- a/sidecar/config.ts +++ b/sidecar/config.ts @@ -3,12 +3,15 @@ import { createPhotosAccount, deletePhotosAccount, getPhotosCredentials, + listPhotosAccountLinks, listPhotosAccounts, recordPhotosProbe, setActivePhotosAccount, updatePhotosAccount, } from '../db/queries'; import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream'; +import { backfillAccountKeys, runProvisioning } from './provision-run'; +import { reissuePassword } from './provision'; // `/_config` — the Immich account registry, driven from the app rather than from a shell on the server. // @@ -152,13 +155,68 @@ async function addAccount(req: Request, userId: number): Promise { try { const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate }); invalidateConfig(userId); - return Response.json({ account, user: result.user }); + + // ── Provision, if this connection can ── + // + // The trigger is registering an ADMIN connection, not installing the plugin: at install time there is + // no credential, so there is nothing to provision with. Awaited, so the response says what happened — + // and, importantly, carries the temporary passwords, which exist nowhere else. + const provisioning = await runProvisioning({ cfg: { base: url, key: apiKey }, configId: account.id }); + return Response.json({ account, user: result.user, provisioning }); } catch (err) { if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); throw err; } } +/** + * A fresh one-time password for one person, so they can set up Immich's own app. + * + * Returned here and nowhere else. Not logged, not stored, and not readable back — asking again issues a + * new one and invalidates the last, which is the correct behaviour for a credential whose whole life is + * the walk from this reply to the person it belongs to. + */ +async function handover(req: Request, userId: number, id: number): Promise { + const creds = await getPhotosCredentials(userId, id); + if (!creds) return bad('no such account', 404); + + const body = ((await req.json().catch(() => null)) as { userId?: unknown } | null) ?? {}; + const target = Number(body.userId); + if (!Number.isInteger(target) || target <= 0) return bad('userId is required'); + + const links = await listPhotosAccountLinks(id); + const account = links.find((l) => l.userId === target); + if (!account) return bad('that account is not provisioned on this instance', 404); + + const temporaryPassword = await reissuePassword( + { base: normalizeBase(creds.url), key: creds.apiKey }, + { immichUserId: account.immichUserId, email: account.immichEmail }, + ); + return Response.json({ email: account.immichEmail, temporaryPassword }); +} + +/** + * Re-run provisioning against an instance already registered, and mint whatever keys are missing. + * + * The repair action, and the one that carries hand-over passwords back. `addAccount` does this once, at + * connect; this is the same work without needing the owner to re-enter a credential. + * + * Owner-only WITHOUT a permission rule, and worth saying out loud: every `/_config` route resolves its + * account through `getPhotosCredentials(userId, id)`, scoped to the caller's own rows. A member holding + * `photos` at write reaches this handler and gets a 404, because they have no registry of their own — the + * key they read the library with lives on their link row, not here. + */ +async function reprovision(userId: number, id: number): Promise { + const creds = await getPhotosCredentials(userId, id); + if (!creds) return bad('no such account', 404); + + const cfg = { base: normalizeBase(creds.url), key: creds.apiKey }; + const provisioning = await runProvisioning({ cfg, configId: id }); + const keys = await backfillAccountKeys({ cfg, configId: id }); + + return Response.json({ provisioning, keys }); +} + /** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */ async function editAccount(req: Request, userId: number, id: number): Promise { const body = await readBody(req); @@ -213,6 +271,16 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s const id = Number(rawId); if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404); + if (action === 'provision') { + if (req.method !== 'POST') return bad('method not allowed', 405); + return reprovision(userId, id); + } + + if (action === 'handover') { + if (req.method !== 'POST') return bad('method not allowed', 405); + return handover(req, userId, id); + } + if (action === 'activate') { if (req.method !== 'POST') return bad('method not allowed', 405); const account = await setActivePhotosAccount(userId, id); diff --git a/sidecar/index.ts b/sidecar/index.ts index ada9ac2..59074a6 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -4,6 +4,7 @@ import { handleConfigRoute, noteProbe, probe } from './config'; import { handleLockedRoute } from './locked'; import { handleOfficerRoute } from './routes'; import { getConfig } from './upstream'; +import { setProvisionableUsers } from './user-list'; import { API_URL } from '@@/officer-url.mjs'; // The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key. @@ -27,6 +28,9 @@ import { API_URL } from '@@/officer-url.mjs'; // key and is never stored. Validated against the instance, then stored encrypted // PATCH /_config/:id same fields, all optional — no credential keeps the stored key // POST /_config/:id/activate switch to that account +// POST /_config/:id/provision create/link an Immich account per Officer user and mint each their own +// key. Replies with a one-time password per person for THEIR Immich app — +// returned here and nowhere else, never logged, never stored // DEL /_config/:id remove it; the newest survivor is promoted if it was the active one // * /_officer/ forwarded to /api/, first-segment allow-list (routes.ts) // @@ -146,6 +150,12 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; + // The platform's account list, pushed because this plugin's manifest declares `userProvisioning`. + // Arrives on registration and again on every account create or delete. No reply — it is a statement, + // not a question. + case 'users:list': + setProvisionableUsers(cmd.users); + break; default: reply({ type: 'error', diff --git a/sidecar/provision-run.ts b/sidecar/provision-run.ts new file mode 100644 index 0000000..b4b1666 --- /dev/null +++ b/sidecar/provision-run.ts @@ -0,0 +1,166 @@ +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; +} diff --git a/sidecar/provision.ts b/sidecar/provision.ts new file mode 100644 index 0000000..078f99a --- /dev/null +++ b/sidecar/provision.ts @@ -0,0 +1,243 @@ +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 }; diff --git a/sidecar/reconcile.test.ts b/sidecar/reconcile.test.ts new file mode 100644 index 0000000..0f2ed56 --- /dev/null +++ b/sidecar/reconcile.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'bun:test'; +import { normalize, planReconcile, type ImmichUser } from './reconcile'; +import type { ProvisionableUser } from 'officerdb'; + +const user = (id: number, username: string, email: string): ProvisionableUser => + ({ id, username, email, name: null, role: 'Member' }) as ProvisionableUser; + +// The real populations on alpha as of 2026-08-21, read from `GET /api/admin/users` and from +// `listProvisionableUsers()`. Real names rather than invented ones because every interesting case is +// already present here, and because a test built on the real overlap fails when the real overlap changes. +const PLATFORM = [ + user(24, 'conceiba', 'conceiba@gmail.com'), + user(25, 'guipinto', 'tatudoemcampo@gmail.com'), + user(26, 'fedra', 'fedrasantamaria@gmail.com'), + user(27, 'diogo', 'dingoshf@gmail.com'), +]; + +const ADMIN_ID = '0e0b05c7'; + +const IMMICH: ImmichUser[] = [ + { id: ADMIN_ID, email: 'pastilhas@officer.dev', name: 'André Padez' }, // the connection's own account + { id: 'aaaa1111', email: 'conceiba@gmail.com', name: 'Bruno Conceição' }, + { id: 'bbbb2222', email: 'fedrasantamaria@gmail.com', name: 'Fedra' }, + { id: 'cccc3333', email: 'jrg.conceicao@gmail.com', name: 'Jorge Conceição' }, // no Officer account +]; + +describe('the live instance, first run', () => { + const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing: [], adminImmichUserId: ADMIN_ID }); + + test('links the two whose email already exists', () => { + expect(plan.link.map((l) => l.user.email).sort()).toEqual(['conceiba@gmail.com', 'fedrasantamaria@gmail.com']); + expect(plan.link.find((l) => l.user.id === 24)!.immich.id).toBe('aaaa1111'); + }); + + test('creates the two with no Immich account', () => { + expect(plan.create.map((u) => u.email).sort()).toEqual(['dingoshf@gmail.com', 'tatudoemcampo@gmail.com']); + }); + + test('leaves the account nobody on Officer owns, and never the admin', () => { + expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']); + }); + + // The bug the Jellyfin plugin shipped and had to fix in 591c0da: the owner is excluded from the + // provisionable list on purpose, which made their own account read as belonging to nobody. + test('reports the admin as itself rather than as unclaimed', () => { + expect(plan.self?.email).toBe('pastilhas@officer.dev'); + expect(plan.leave.map((u) => u.email)).not.toContain('pastilhas@officer.dev'); + }); +}); + +describe('a second run', () => { + const existing = [ + { userId: 24, immichUserId: 'aaaa1111' }, + { userId: 26, immichUserId: 'bbbb2222' }, + ]; + const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing, adminImmichUserId: ADMIN_ID }); + + test('re-decides nothing that is already recorded', () => { + expect(plan.link).toHaveLength(0); + expect(plan.unchanged).toHaveLength(2); + }); + + test('still creates the two that are genuinely missing', () => { + expect(plan.create.map((u) => u.id).sort()).toEqual([25, 27]); + }); + + test('does not report a linked account as unclaimed', () => { + expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']); + }); +}); + +describe('matching', () => { + test('is case- and whitespace-insensitive, because Immich lowercases what it stores', () => { + const plan = planReconcile({ + users: [user(30, 'shouty', ' FEDRASANTAMARIA@GMAIL.COM ')], + immichUsers: IMMICH, + existing: [], + adminImmichUserId: ADMIN_ID, + }); + expect(plan.create).toHaveLength(0); + expect(plan.link[0]!.immich.id).toBe('bbbb2222'); + }); + + test('normalize agrees with what Immich does to an address', () => { + expect(normalize(' Fedra@Example.COM ')).toBe('fedra@example.com'); + }); + + test('skips an Officer account with no email rather than inventing one', () => { + const plan = planReconcile({ + users: [{ id: 31, username: 'nomail', email: '', name: null, role: 'Member' } as ProvisionableUser], + immichUsers: IMMICH, + existing: [], + adminImmichUserId: ADMIN_ID, + }); + expect(plan.create).toHaveLength(0); + expect(plan.link).toHaveLength(0); + }); + + test('two Officer accounts cannot both claim one Immich account', () => { + const plan = planReconcile({ + users: [user(40, 'a', 'fedrasantamaria@gmail.com'), user(41, 'b', 'FedraSantamaria@gmail.com')], + immichUsers: IMMICH, + existing: [], + adminImmichUserId: ADMIN_ID, + }); + expect(plan.link).toHaveLength(1); + // The loser is not silently created either — that would duplicate an address Immich would refuse. + expect(plan.create).toHaveLength(0); + }); +}); + +describe('an account deleted in Immich behind Officer’s back', () => { + const plan = planReconcile({ + users: PLATFORM, + immichUsers: IMMICH, + existing: [{ userId: 25, immichUserId: 'gone9999' }], + adminImmichUserId: ADMIN_ID, + }); + + test('is reported as broken rather than quietly recreated', () => { + expect(plan.brokenLinks).toEqual([{ userId: 25, immichUserId: 'gone9999' }]); + expect(plan.create.map((u) => u.id)).not.toContain(25); + }); +}); diff --git a/sidecar/reconcile.ts b/sidecar/reconcile.ts new file mode 100644 index 0000000..92beffb --- /dev/null +++ b/sidecar/reconcile.ts @@ -0,0 +1,134 @@ +import type { ProvisionableUser } from 'officerdb'; + +// Deciding what to do about each account, with no side effects. +// +// Separated from the doing on purpose. The execution talks to a live Immich and cannot be rehearsed; the +// decision is where every mistake that matters lives, and it can be checked against the real addresses on +// the real instance without creating anything. +// +// ── The outcomes, and why none of them is "delete" ── +// +// LINK an Officer user whose email already exists on Immich. Record the pairing; create nothing. +// CREATE an Officer user with no Immich account. Make one. +// LEAVE an Immich account with no Officer user. Do nothing at all, and say so. +// +// Deleting an Immich account because nobody holds a matching Officer login would destroy somebody's +// photographs on the strength of an absence — and an absence is exactly what an incomplete list looks +// like. So they are reported, never touched, and the report is the feature. +// +// ── Matching is on email, lowercased ── +// +// Immich identifies an account by email and stores it lowercased (`ZodEmail` with a transform on both the +// create and update DTOs). Officer's `users.email` is the same key, so pairing is exact rather than +// heuristic — a real simplification over the Jellyfin side, which has to match usernames through Jellyfin's +// uppercased `NormalizedUsername` index and cope with two Officer names colliding into one. +// +// ── The admin's own account is not "not an Officer account" ── +// +// `listProvisionableUsers` excludes the owner deliberately: they hold the admin account this whole flow +// authenticates as, and provisioning them would be a second admin nobody asked for. But that makes the +// owner's own Immich account look like it belongs to nobody. It is identified here from the key's own +// identity rather than matched by name, and reported as itself. The Jellyfin plugin shipped this bug and +// had to fix it in `591c0da`; there is no reason to ship it twice. + +/** An Immich account as `GET /api/admin/users` reports it. Only the fields a decision needs. */ +export type ImmichUser = { id: string; email: string; name: string }; + +/** An existing pairing, so a second run does not re-decide what is already recorded. */ +export type ExistingLink = { userId: number; immichUserId: string }; + +export type ReconcilePlan = { + /** Officer users whose Immich account already exists — record the pairing, create nothing. */ + link: { user: ProvisionableUser; immich: ImmichUser }[]; + /** Officer users with no Immich account. */ + create: ProvisionableUser[]; + /** Immich accounts with no Officer user. Reported, never touched. */ + leave: ImmichUser[]; + /** The account the API key itself belongs to — the owner. Neither provisioned nor unclaimed. */ + self: ImmichUser | null; + /** Already linked, and still valid on both sides. Nothing to do. */ + unchanged: ExistingLink[]; + /** + * Links whose Immich account has vanished — deleted in Immich's own UI, most likely. + * + * Separated from `create` even though the remedy is the same, because they are different events: one is a + * new person, the other is someone's account disappearing behind Officer's back. + */ + brokenLinks: ExistingLink[]; +}; + +/** Immich's own rule, applied on this side so the two agree. */ +export const normalize = (email: string): string => email.trim().toLowerCase(); + +/** + * Work out what should happen. Nothing here talks to anything. + * + * `adminImmichUserId` is who the configured API key authenticates as, read from `/api/users/me` rather + * than assumed — it is what keeps the owner out of `leave`. + */ +export function planReconcile(params: { + users: ProvisionableUser[]; + immichUsers: ImmichUser[]; + existing: ExistingLink[]; + adminImmichUserId: string | null; +}): ReconcilePlan { + const { users, immichUsers, existing, adminImmichUserId } = params; + + const immichByEmail = new Map(immichUsers.map((u) => [normalize(u.email), u])); + const immichById = new Map(immichUsers.map((u) => [u.id, u])); + const linkedUserIds = new Set(existing.map((e) => e.userId)); + /** Immich accounts spoken for — pre-existing links, plus anything claimed during this pass. */ + const claimed = new Set(existing.map((e) => e.immichUserId)); + + const plan: ReconcilePlan = { + link: [], + create: [], + leave: [], + self: adminImmichUserId ? (immichById.get(adminImmichUserId) ?? null) : null, + unchanged: [], + brokenLinks: [], + }; + + for (const link of existing) { + (immichById.has(link.immichUserId) ? plan.unchanged : plan.brokenLinks).push(link); + } + + for (const user of users) { + if (linkedUserIds.has(user.id)) continue; // already recorded — handled above + + // An Officer account with no email cannot be matched or created: Immich has no other identifier and + // requires one on create. Skipped rather than invented from the username, which would claim an address + // the person does not own. + if (!user.email) continue; + + const match = immichByEmail.get(normalize(user.email)); + if (match && !claimed.has(match.id)) { + plan.link.push({ user, immich: match }); + claimed.add(match.id); + } else if (!match) { + plan.create.push(user); + } + // A match already claimed falls through deliberately: it is neither this user's account to adopt nor + // safe to duplicate, and `leave` below reports it for a human to settle. + } + + for (const immich of immichUsers) { + if (claimed.has(immich.id)) continue; + if (immich.id === adminImmichUserId) continue; // the connection's own account, reported as `self` + plan.leave.push(immich); + } + + return plan; +} + +/** One line per outcome, for the log and the panel. Says what will happen before it happens. */ +export function describePlan(plan: ReconcilePlan): string[] { + const lines: string[] = []; + for (const { user, immich } of plan.link) lines.push(`link ${user.email} → existing Immich "${immich.email}"`); + for (const user of plan.create) lines.push(`create ${user.email}`); + for (const immich of plan.leave) lines.push(`leave "${immich.email}" alone — no Officer account`); + for (const broken of plan.brokenLinks) lines.push(`link for user ${broken.userId} is broken — Immich account is gone`); + if (plan.self) lines.push(`"${plan.self.email}" is the account Officer connects as`); + if (plan.unchanged.length) lines.push(`${plan.unchanged.length} already linked`); + return lines; +} diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts index e72c89a..52eb2e8 100644 --- a/sidecar/upstream.ts +++ b/sidecar/upstream.ts @@ -19,7 +19,7 @@ // exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and // nothing below adds them; the platform proxy forwards only content-type, range and if-none-match. -import { getActivePhotosCredentials } from '../db/queries'; +import { getActivePhotosCredentials, getLinkedPhotosCredentials } from '../db/queries'; /** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ export type UpstreamTarget = { base: string; key: string }; @@ -43,14 +43,22 @@ const TTL_MS = 60_000; const cache = new Map(); /** - * The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503, - * and the UI turns that into the setup form rather than a wall of empty grids. + * What THIS account reads Immich with, or null when there is nothing — the sidecar then answers 503, and + * the UI turns that into the setup form rather than a wall of empty grids. + * + * Two sources, in this order: + * + * 1. **An instance this account registered itself.** The owner's normal case, and also a member who + * deliberately connected an Immich of their own. Checked first so that somebody who went to the + * trouble of adding one is not overridden by an inherited link. + * 2. **A provisioned link on the owner's active instance**, carrying that person's OWN API key. This is + * how a member reaches their library without ever holding the owner's credential. */ export async function getConfig(userId: number): Promise { const hit = cache.get(userId); if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; - const creds = await getActivePhotosCredentials(userId); + const creds = (await getActivePhotosCredentials(userId)) ?? (await getLinkedPhotosCredentials(userId)); const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null; cache.set(userId, { cfg, at: Date.now() }); return cfg; diff --git a/sidecar/user-list.ts b/sidecar/user-list.ts new file mode 100644 index 0000000..0d77597 --- /dev/null +++ b/sidecar/user-list.ts @@ -0,0 +1,31 @@ +import type { ProvisionableUser } from 'officerdb'; + +// The account list the platform pushes, held for as long as this process lives. +// +// Pushed rather than fetched — see src/servers/plugins/user-provisioning.ts. It arrives once when this +// sidecar registers, and again whenever an Officer account is created or removed, so it is the current +// truth rather than a cache with an expiry: each message REPLACES what was held, never merges. +// +// In memory on purpose. Persisting it would create a second copy of the platform's own users that could +// disagree with it, and the recovery from disagreement would be re-reading the platform anyway. A restart +// re-registers, which re-sends. + +let users: ProvisionableUser[] = []; +let receivedAt: Date | null = null; + +export function setProvisionableUsers(next: ProvisionableUser[]): void { + users = next; + receivedAt = new Date(); + console.log(`[photos] user list: ${next.length} account(s) the platform can provision`); +} + +export const getProvisionableUsers = (): ProvisionableUser[] => users; + +/** + * Whether the platform has told us anything yet. + * + * The distinction matters: an EMPTY list means "this server has only an owner", while never having been + * told means the plugin is not entitled or the push has not arrived. Provisioning nothing is right in the + * first case and wrong in the second, so the caller has to be able to tell them apart. + */ +export const hasProvisionableUsers = (): boolean => receivedAt !== null;