Files
photos/sidecar/provision-run.ts
T
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

167 lines
6.6 KiB
TypeScript

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<Handover> {
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<ProvisioningReport> {
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<BackfillReport> {
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;
}