Files
photos/sidecar/upstream.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

236 lines
12 KiB
TypeScript

// Immich upstream config for the officer-photos sidecar.
//
// All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin
// auth+forward proxy and holds NO Immich credentials.
//
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see
// databases/officer_db/src/photos/queries.ts). It is deliberately no longer read from the environment:
// Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there
// was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code
// to use it and the largest attack surface in the system. Nothing in this file reads process.env.
//
// Two things about Immich's API are load-bearing:
//
// 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
// 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
// credential. Create the key with all permissions unless there is a reason not to.
// 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
// credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
// 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, 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 };
/**
* A stored account, which is where every real call goes.
*
* `id` and `label` ride along because the owner can have several accounts registered and only one selected:
* a probe has to be recorded against the row it actually reached, and a log line saying which library
* answered is the difference between "photos is broken" and "you are looking at the other account".
*/
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
/** Trailing slashes off, so `${base}/api/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
// A thumbnail grid is a hundred requests in a second and each one needs the key, so the row is cached rather
// than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in
// psql, which then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* 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)) ?? (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;
}
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
export function invalidateConfig(userId: number): void {
cache.delete(userId);
}
/** Carries the upstream status so callers can tell a rejected credential from an unreachable instance. */
export class UpstreamError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
/** Immich errors as `{ message }`; fall back to the status when the body is not that (a proxy's HTML, say). */
async function upstreamMessage(res: Response, fallback: string): Promise<string> {
const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
const message = typeof body?.message === 'string' ? body.message : '';
return message ? `${message} (${res.status})` : `${fallback} with ${res.status}`;
}
/**
* Trade an email and password for a SESSION token.
*
* A session is not an API key and the difference is load-bearing in both directions: sessions are listed as
* devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the
* `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate.
* `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against
* the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to
* get there. That is why this is exported rather than inlined into mintApiKey.
*
* The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it.
*/
export async function login(base: string, email: string, password: string): Promise<string> {
const res = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
redirect: 'manual',
});
// Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus
// "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them.
if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status);
const session = (await res.json()) as { accessToken?: unknown };
if (typeof session.accessToken !== 'string' || !session.accessToken) {
throw new UpstreamError('sign-in returned no access token', res.status);
}
return session.accessToken;
}
/**
* End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the
* caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich
* account for every sign-in Officer makes.
*/
export async function logout(base: string, token: string): Promise<void> {
await fetch(`${base}/api/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
redirect: 'manual',
}).catch(() => {});
}
/**
* Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own
* settings screen.
*
* `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only
* grantable because a session has every permission — an API key can never mint a key stronger than itself
* (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped,
* and an under-scoped one returns 403 per route, which reads as a broken feature.
*
* The session exists only to hold the key and is closed in the `finally`. The password is used here and
* nowhere else; only the key survives this function.
*/
export async function mintApiKey(base: string, email: string, password: string): Promise<string> {
const accessToken = await login(base, email, password);
const bearer = { Authorization: `Bearer ${accessToken}` };
try {
const created = await fetch(`${base}/api/api-keys`, {
method: 'POST',
headers: { ...bearer, Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Officer', permissions: ['all'] }),
redirect: 'manual',
});
if (!created.ok) {
throw new UpstreamError(await upstreamMessage(created, 'could not create an API key'), created.status);
}
const payload = (await created.json()) as { secret?: unknown };
if (typeof payload.secret !== 'string' || !payload.secret) {
throw new UpstreamError('Immich created a key but returned no secret', created.status);
}
return payload.secret;
} finally {
await logout(base, accessToken);
}
}
type CallOptions = {
/** Absolute path on the Immich host, e.g. `/api/albums`. */
path: string;
method?: string;
/** Raw search string including the leading `?`, or empty. */
query?: string;
/**
* A string for the JSON callers, or the forwarder's already-buffered request body.
*
* This used to be a stream, for the obvious reason that a phone's video should not sit in this
* process's heap. It was changed back deliberately: a request body still being written when Immich
* answers loses the answer entirely — Node replaces the app's response with a bodyless 400 and closes
* — which is what made every large upload fail inexplicably. `readBodyCapped` in routes.ts has the
* measurements and the alternatives that were tried; the heap cost is the accepted price.
*
* A buffer also carries a Content-Length, so the request to Immich is no longer chunked.
*/
body?: BodyInit | null;
contentType?: string | null;
/** Byte range for thumbnail/original/video reads, forwarded verbatim. */
range?: string | null;
ifNoneMatch?: string | null;
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
withKey?: boolean;
/**
* A session token to authenticate as, INSTEAD of the stored API key.
*
* Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that
* must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are
* mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its
* header precedence rather than to this file.
*/
bearer?: string;
};
/** The single door to Immich. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`;
else if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
// `duplex: 'half'` is required by the fetch spec whenever the body is a stream, and is absent from
// TypeScript's RequestInit — hence the widened type, same as createSidecarProxy does. Harmless for the
// string bodies.
const init: RequestInit & { duplex?: 'half' } = {
method: opts.method ?? 'GET',
headers,
body: opts.body ?? undefined,
duplex: 'half',
// A redirect from an API route means something has gone wrong with auth; surface it rather than
// following it into an HTML page.
redirect: 'manual',
};
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, init);
if (res.status === 401 || res.status === 403) {
// 403 is the interesting one: the key is valid but lacks the permission this route needs.
console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (key rejected or under-scoped)`);
}
return res;
}