Files
pastilhasandClaude Fable 5 a83da42a1f give each member their own immich account and key
Photos had no user provisioning at all: one registry row, the owner's, and
every other account resolving to nothing. Same shape as the Jellyfin gap, but
this one was never built rather than built and dropped.

photos_accounts records who on Officer is who on Immich, and carries that
person's own API key. getConfig falls back to it when the caller has no
registry of their own, and there is no path from there to the owner's key —
an Immich key with permissions:['all'] can read and delete the whole library,
so a member resolving to it would be administering everyone's photographs
rather than reading their own.

Matching is on email, which Immich enforces uniqueness on and Officer already
holds. That is exact where the Jellyfin side has to go through an uppercased
username index and cope with two Officer names colliding into one.

Two deliberate differences from the Jellyfin flow:

The admin's own account is reported as itself, not as "no Officer account".
That bug shipped there and was fixed in 591c0da; there was no reason to write
it twice, so reconcile takes the key's identity from /api/users/me and the
test pins it.

And the temporary password is HANDED OVER rather than discarded. Jellyfin
discards because it has no must-change-password flag, so anything handed over
stays valid forever. Immich has one, and Immich's own app is where phone
backup runs — discarding would leave a photo account that can never back a
phone up again. So: set a password, spend it on a key, raise
shouldChangePassword, return it once. POST /_config/:id/handover re-issues on
demand, because "setting up my phone" happens long after provisioning and more
than once. Verified against the running 3.1.0 image that an API key survives a
password change, so re-issuing never costs Officer its access.

Applied on alpha: conceiba and fedra linked, guipinto and diogo created, all
four keyed and each resolving to their own Immich identity. jrg.conceicao left
alone. Members still need the photos grant on the Member role before the
permission gate lets them through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:46:13 +00:00

135 lines
6.3 KiB
TypeScript

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;
}