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>
This commit is contained in:
2026-08-21 15:46:13 +00:00
co-authored by Claude Fable 5
parent 0b9e4f32db
commit a83da42a1f
11 changed files with 977 additions and 7 deletions
+12 -4
View File
@@ -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<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* 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<UpstreamConfig | null> {
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;