photos, extracted from the platform into its own repository

The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:10:39 +00:00
co-authored by Claude Opus 5
commit 2dad1907f8
39 changed files with 5321 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
import type { UpstreamTarget } from './upstream';
import {
createPhotosAccount,
deletePhotosAccount,
getPhotosCredentials,
listPhotosAccounts,
recordPhotosProbe,
setActivePhotosAccount,
updatePhotosAccount,
} from '../db/queries';
import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream';
// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server.
//
// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it
// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to
// send a new one, which is the same shape the offscale plugin's server registry uses.
//
// "A new one" has two forms: a key the owner pasted from Immich's own settings screen, or an email and
// password the sidecar trades for one (see mintApiKey). The password is used for that exchange and never
// stored — only the key it yields is.
//
// A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400
// with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here
// than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on
// individual routes while looking perfectly valid on the ones it does cover.
//
// Adding, editing, switching and removing all invalidate the upstream cache. Forgetting one would leave the
// sidecar serving the previous account's photos for up to a minute, which reads as a caching bug in the grid
// rather than as what it is.
export type ProbeResult =
| { ok: true; version: string | null; user: string | null }
| { ok: false; version: string | null; error: string };
/**
* Ask an instance whether it is really there and whether the key works.
*
* Two calls, because they answer different questions: `/server/version` is unauthenticated, so a failure
* there means the URL is wrong or Immich is down, while `/users/me` failing after it succeeded means the
* key is the problem. Collapsing them would report "instance unreachable" for a mistyped key.
*/
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
let version: string | null = null;
try {
const versionRes = await callUpstream(cfg, { path: '/api/server/version', withKey: false });
if (!versionRes.ok) return { ok: false, version: null, error: `instance returned ${versionRes.status}` };
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
if ([v.major, v.minor, v.patch].every((n) => typeof n === 'number')) {
version = `${v.major}.${v.minor}.${v.patch}`;
}
} catch (err) {
return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
}
try {
const meRes = await callUpstream(cfg, { path: '/api/users/me' });
if (!meRes.ok) {
const reason = meRes.status === 403 ? 'API key is under-scoped for this instance' : 'API key was rejected';
return { ok: false, version, error: `${reason} (${meRes.status})` };
}
const me = (await meRes.json()) as { email?: string; name?: string };
return { ok: true, version, user: me.email ?? me.name ?? null };
} catch (err) {
return { ok: false, version, error: String(err) };
}
}
/** What the browser is allowed to know about the registry. Never includes a key. */
async function accountList(userId: number): Promise<Response> {
const accounts = await listPhotosAccounts(userId);
const active = accounts.find((account) => account.isActive) ?? null;
return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts });
}
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
await recordPhotosProbe(userId, id, version).catch(() => {
/* a stale lastSeenAt is not worth failing a request over */
});
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown; email?: unknown; password?: unknown };
const readBody = async (req: Request): Promise<AccountBody> =>
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : '');
const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : '');
const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : '');
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
/**
* The key to store, however the owner chose to supply it.
*
* A pasted key wins; otherwise an email and password are traded for a fresh one and the password is discarded
* with this function's stack frame. Returns '' when neither was given, which for an edit means "keep what is
* stored" and for an add is an error.
*/
async function resolveKey(body: AccountBody, base: string): Promise<string> {
const apiKey = readKey(body);
if (apiKey) return apiKey;
const email = readEmail(body);
const password = readPassword(body);
if (!email || !password) return '';
return mintApiKey(base, email, password);
}
/** A refused sign-in already carries Immich's own words; anything else never reached Immich at all. */
const signInError = (err: unknown): string =>
err instanceof UpstreamError ? err.message : `could not reach the instance (${String(err)})`;
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label');
/** Add an account: validated against the live instance, then stored encrypted. */
async function addAccount(req: Request, userId: number): Promise<Response> {
const body = await readBody(req);
const url = readUrl(body);
let label = readLabel(body);
if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
let apiKey: string;
try {
apiKey = await resolveKey(body, url);
} catch (err) {
return bad(signInError(err));
}
if (!apiKey) return bad('an API key, or an email and password to mint one, is required');
const result = await probe({ base: url, key: apiKey });
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
// An unlabelled account takes the name Immich itself knows it by, which is nearly always what the owner
// would have typed. Falling back to the host keeps the switcher readable even for an anonymous key.
if (!label) label = result.user ?? new URL(url).host;
// The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
const existing = await listPhotosAccounts(userId);
const activate = existing.length === 0;
try {
const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate });
invalidateConfig(userId);
return Response.json({ account, user: result.user });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/** 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<Response> {
const body = await readBody(req);
const label = readLabel(body);
const url = readUrl(body);
const current = await getPhotosCredentials(userId, id);
if (!current) return bad('no such account', 404);
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
let apiKey: string;
try {
apiKey = await resolveKey(body, url || current.url);
} catch (err) {
return bad(signInError(err));
}
// Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
let version: string | null | undefined;
if ((url && url !== current.url) || apiKey) {
const result = await probe({ base: url || current.url, key: apiKey || current.apiKey });
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
version = result.version;
}
try {
const account = await updatePhotosAccount(userId, id, {
label: label || undefined,
url: url || undefined,
apiKey: apiKey || undefined,
version,
});
if (!account) return bad('no such account', 404);
invalidateConfig(userId);
return Response.json({ account });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/** `subpath` is '' for /_config, or '/<id>' / '/<id>/activate'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
const [, rawId, action] = subpath.split('/');
if (!rawId) {
if (req.method === 'GET') return accountList(userId);
if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId);
return bad('method not allowed', 405);
}
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
if (action === 'activate') {
if (req.method !== 'POST') return bad('method not allowed', 405);
const account = await setActivePhotosAccount(userId, id);
if (!account) return bad('no such account', 404);
invalidateConfig(userId);
return accountList(userId);
}
if (action) return bad('not found', 404);
if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id);
if (req.method === 'DELETE') {
const removed = await deletePhotosAccount(userId, id);
if (!removed) return bad('no such account', 404);
invalidateConfig(userId);
return accountList(userId);
}
return bad('method not allowed', 405);
}
+183
View File
@@ -0,0 +1,183 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleConfigRoute, noteProbe, probe } from './config';
import { handleLockedRoute } from './locked';
import { handleOfficerRoute } from './routes';
import { getConfig } from './upstream';
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.
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
// credentials.
//
// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no
// longer read from the environment. See upstream.ts for why that move mattered.
//
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
//
// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated).
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
//
// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is.
// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] }
// POST /_config { label?, url, and EITHER apiKey OR email+password } — the password mints a
// 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
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
//
// GET /_locked/status { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state
// POST /_locked/pin { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough)
// POST /_locked/unlock { password, pinCode } — signs in, elevates, holds the session IN MEMORY
// POST /_locked/lock drops elevation and closes the session
// * /_locked/api/<path> forwarded on that session — assets, search and timeline only (locked.ts)
//
// anything else 404
//
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
// loopback only, so its presence is the trust signal — a request without it did not come through the
// platform, and the connection is per-owner data.
//
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Uploads go through this proxy as multipart bodies, and a phone's video is not small.
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
try {
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
const cfg = await getConfig(userId);
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-broken instance is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(cfg);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json(
{ ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms },
{ status: 502 },
);
}
await noteProbe(userId, cfg.id, result.version);
return Response.json({
ok: true,
configured: true,
account: cfg.label,
version: result.version,
user: result.user,
ms,
});
}
if (url.pathname.startsWith('/_officer/')) {
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
try {
const res = await handleOfficerRoute(cfg, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) {
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
try {
return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length));
} catch (err) {
// Never let the error text out: this surface handles a password and a PIN, and an upstream failure
// carrying either into a response body or a log line is the one leak worth being paranoid about.
console.error(`[photos] ${req.method} ${url.pathname} failed`);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'photos',
handles: ['photos'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'photos:server', port });
console.log(`[photos] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[photos] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+332
View File
@@ -0,0 +1,332 @@
import type { UpstreamConfig } from './upstream';
import { relayResponse } from './routes';
import { UpstreamError, callUpstream, login, logout } from './upstream';
// `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach.
//
// WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts
//
// Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running
// 3.1.0 image rather than inferred:
//
// utils/access.js requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw`
// services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'`
// services/session.service.js create → the same guard, so a key cannot even mint a session to escape this
// repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)`
//
// An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key
// permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape
// of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and
// `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings.
//
// So reading the private folder needs a session, a session needs a password, and Officer deliberately does not
// keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The
// resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed
// on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still
// does not open the private folder — the PIN and the password are never at rest here.
//
// The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the
// alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a
// standing security-posture change and this is not: everything below is forgotten when the process restarts.
//
// The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it
// reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the
// request asked for rather than a door left open for anything under `auth/`.
/** What the API key alone can do, which is more than expected and worth being precise about. */
const PIN_PATH = '/api/auth/pin-code';
/**
* Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror
* that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used
* for longer than the window can never be elevated again, so it is closed rather than left open indefinitely.
*/
const IDLE_MS = 20 * 60 * 1000;
/** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */
const PIN_RE = /^\d{6}$/;
type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number };
// Keyed by Officer user. The value never leaves this module and is never persisted.
const elevations = new Map<number, Elevation>();
/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */
async function drop(userId: number): Promise<void> {
const current = elevations.get(userId);
if (!current) return;
elevations.delete(userId);
await logout(current.base, current.token);
}
/**
* The live session for this user, or null.
*
* Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was
* minted against, and serving the previous library's private folder after a switch would be the worst possible
* version of the caching bug the config routes already guard against.
*/
function current(userId: number, cfg: UpstreamConfig): Elevation | null {
const found = elevations.get(userId);
if (!found) return null;
if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) {
void drop(userId);
return null;
}
return found;
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
/** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */
const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 });
type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown };
const readBody = async (req: Request): Promise<Body> => ((await req.json().catch(() => null)) as Body | null) ?? {};
const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
/** Immich answers `{ message }`; anything else means we never really reached it. */
async function message(res: Response, fallback: string): Promise<string> {
const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
return typeof body?.message === 'string' ? body.message : fallback;
}
/**
* The email to sign in as, taken from the key rather than from the caller.
*
* The client knows the password but has no reason to know which Immich account the stored key belongs to, and
* asking it to send an email would let a wrong one turn a valid password into "sign-in failed".
*/
async function ownerEmail(cfg: UpstreamConfig): Promise<string> {
const res = await callUpstream(cfg, { path: '/api/users/me' });
if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status);
const me = (await res.json()) as { email?: unknown };
if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500);
return me.email;
}
/**
* `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked.
*
* Answered through the session when there is one, because only then does Immich report `isElevated` and
* `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session.
* `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than
* being handed a form that cannot work.
*/
async function status(cfg: UpstreamConfig, userId: number): Promise<Response> {
const session = current(userId, cfg);
const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token });
if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status);
const info = (await res.json()) as {
pinCode?: boolean;
password?: boolean;
isElevated?: boolean;
pinExpiresAt?: string;
};
// Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold.
if (session && !info.isElevated) await drop(userId);
return Response.json({
hasPin: !!info.pinCode,
hasPassword: !!info.password,
unlocked: !!info.isElevated,
expiresAt: info.pinExpiresAt ?? null,
});
}
/**
* `POST /_locked/unlock` — sign in, elevate, keep the session.
*
* A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one
* per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account.
*/
async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
const body = await readBody(req);
const password = typeof body.password === 'string' ? body.password : '';
const pinCode = str(body.pinCode);
if (!password) return bad('the Immich account password is required to unlock');
if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
let token: string;
try {
token = await login(cfg.base, await ownerEmail(cfg), password);
} catch (err) {
if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400);
return bad(`could not reach the instance (${String(err)})`, 502);
}
const res = await callUpstream(cfg, {
path: '/api/auth/session/unlock',
method: 'POST',
bearer: token,
body: JSON.stringify({ pinCode }),
contentType: 'application/json',
});
if (!res.ok) {
await logout(cfg.base, token);
return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status);
}
// Replace rather than accumulate: unlocking twice should not leave the first session open.
await drop(userId);
elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() });
return Response.json({ unlocked: true });
}
/** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */
async function lock(cfg: UpstreamConfig, userId: number): Promise<Response> {
const session = current(userId, cfg);
if (session) {
await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token });
await drop(userId);
}
return Response.json({ unlocked: false });
}
/**
* `/_locked/pin` — set up, change and remove the PIN, all on the API key.
*
* These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never
* touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder
* something Officer can offer end to end instead of sending the owner to Immich's own settings screen first.
*/
async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
const body = await readBody(req);
const pinCode = str(body.pinCode);
const newPinCode = str(body.newPinCode);
const password = typeof body.password === 'string' ? body.password : '';
const call = (method: string, payload: Record<string, string>) =>
callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' });
if (req.method === 'POST') {
if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
const res = await call('POST', { pinCode });
return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status);
}
// Change and reset both prove intent with the OLD pin or the account password, so the two are read together.
const proof: Record<string, string> | null = password ? { password } : pinCode ? { pinCode } : null;
if (!proof) return bad('the current PIN, or the account password, is required');
if (req.method === 'PUT') {
if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits');
const res = await call('PUT', { ...proof, newPinCode });
return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status);
}
const res = await call('DELETE', proof);
if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status);
// Immich drops elevation from every session when the PIN is reset; our copy has to go with it.
await drop(userId);
return Response.json({ hasPin: false, unlocked: false });
}
/**
* The elevated forward, deliberately a fraction of the main allow-list.
*
* Three resources, because three are enough to see the folder and move things in and out of it: read the
* assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the
* trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an
* irreversible route is not worth putting behind a credential the owner cannot see.
*/
const ELEVATED: Record<string, readonly string[]> = {
assets: ['GET', 'PUT'],
search: ['POST'],
timeline: ['GET'],
};
/** `/_locked/api/<path>` — the same shape as `/_officer/<path>`, but on the session and far narrower. */
async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise<Response> {
// The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will
// never carry has to say so whether or not the folder happens to be open — answering 423 would invite a
// client to prompt for a password and a PIN to reach something that stays a 404 afterwards.
const [resource] = rest.split('/');
const allowed = resource ? ELEVATED[resource] : undefined;
if (!allowed) return Response.json({ error: 'not found' }, { status: 404 });
if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405);
const session = current(userId, cfg);
if (!session) return locked();
// Officer's own JWT arrives in `?token=` because an <img> cannot send a header. Forwarding it would write the
// owner's session credential into Immich's access log for every locked thumbnail.
const query = new URLSearchParams(url.search);
query.delete('token');
const search = query.toString();
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const res = await callUpstream(cfg, {
path: `/api/${rest}`,
method: req.method,
query: search ? `?${search}` : '',
// Streamed, not buffered — same reason as the unlocked forwarder in routes.ts.
body: hasBody ? req.body : null,
contentType: req.headers.get('content-type'),
range: req.headers.get('range'),
ifNoneMatch: req.headers.get('if-none-match'),
bearer: session.token,
});
// The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error.
if (res.status === 401) {
await drop(userId);
return locked('the private folder locked itself again');
}
session.lastUsedAt = Date.now();
return relayResponse(res);
}
/** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets/<id>/thumbnail'. */
export async function handleLockedRoute(
req: Request,
cfg: UpstreamConfig,
userId: number,
subpath: string,
): Promise<Response> {
const url = new URL(req.url);
const [, head, ...tail] = subpath.split('/');
if (head === 'api') return forward(req, url, cfg, userId, tail.join('/'));
if (head === 'status') {
return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405);
}
if (head === 'unlock') {
return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405);
}
if (head === 'lock') {
return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405);
}
if (head === 'pin') {
const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE';
return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405);
}
return Response.json({ error: 'not found' }, { status: 404 });
}
/**
* Close sessions nobody came back for.
*
* Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account
* until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there.
*/
setInterval(() => {
const now = Date.now();
for (const [userId, session] of elevations) {
if (now - session.lastUsedAt > IDLE_MS) void drop(userId);
}
}, 60_000).unref();
+207
View File
@@ -0,0 +1,207 @@
import type { UpstreamConfig } from './upstream';
import { callUpstream } from './upstream';
// The Officer-owned route surface. This is an allow-list, not a passthrough.
//
// Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar
// adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and
// (c) refusing to expose the administrative half of the API at all.
//
// The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a
// 404 before a request is made, so the deny side is closed by construction rather than by enumeration.
//
// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and
// password machinery — the key is the credential here, and nothing should be minting sessions through a
// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/*
//
// `auth/*` is STILL refused here, and locked.ts does not change that. Immich's private folder needs four auth
// routes (`auth/status`, `auth/pin-code`, `auth/session/unlock`, `auth/session/lock`), and they are reached
// through named endpoints on a separate `/_locked` surface that each do one thing — not by making `auth` a
// resource anything may address. The distinction is the point: the decision that was taken was "the sidecar
// may unlock the private folder", not "the sidecar may reach session and password machinery".
// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external
// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it.
// Adding one should be a decision, not an accident.
const RESOURCES: Record<string, readonly string[]> = {
// The library itself.
assets: ['GET', 'POST', 'PUT', 'DELETE'],
albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
timeline: ['GET'],
memories: ['GET', 'POST', 'PUT', 'DELETE'],
people: ['GET', 'POST', 'PUT'],
faces: ['GET', 'PUT'],
tags: ['GET', 'POST', 'PUT', 'DELETE'],
stacks: ['GET', 'POST', 'PUT', 'DELETE'],
// Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body.
search: ['GET', 'POST'],
duplicates: ['GET'],
map: ['GET'],
view: ['GET'],
// Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip.
download: ['POST'],
// Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is
// the counterpart of a delete the UI can already perform, not because it is safe.
trash: ['GET', 'POST'],
// Sharing and social. Read-mostly, but a shared link is the point of them.
'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'],
activities: ['GET', 'POST', 'DELETE'],
partners: ['GET'],
notifications: ['GET', 'PUT', 'DELETE'],
// Read-only context: who the key acts as, and what the server is.
users: ['GET'],
server: ['GET'],
};
// Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a
// thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either.
const PASSTHROUGH_HEADERS = [
'content-type',
'content-length',
'content-disposition',
'content-range',
'accept-ranges',
'etag',
'last-modified',
'cache-control',
] as const;
/**
* Hand one Immich response back to the caller, keeping only the headers above.
*
* Shared with the locked-folder surface so both forwarders stream rather than buffer and agree on what a
* thumbnail is allowed to carry back. 204 and 304 must not have a body attached — Bun throws if one is.
*/
export function relayResponse(res: Response): Response {
const headers = new Headers();
for (const name of PASSTHROUGH_HEADERS) {
const value = res.headers.get(name);
if (value) headers.set(name, value);
}
const body = res.status === 204 || res.status === 304 ? null : res.body;
return new Response(body, { status: res.status, headers });
}
/**
* The largest request body held in memory. Above this the body is STREAMED instead — never refused.
*
* The cap exists to bound heap, not to bound uploads: a 4 GB video must still go through. What is given
* up above the cap is only the guarantee that a REJECTION carries its message, and an upload that big
* which Immich rejects was going to fail either way — see `readBodyCapped`.
*/
const MAX_BUFFERED_BODY = 512 * 1024 * 1024;
/**
* Read the whole request body into memory, refusing above `MAX_BUFFERED_BODY`.
*
* ── Why this is buffered rather than streamed ──
*
* It used to stream, which is the obvious thing for an upload and was wrong for one specific reason:
* when the body is still being written and Immich answers early, the response is lost. Immich rejects
* an asset as soon as it has enough of the file to judge it, then closes; our writer is still going;
* Node's HTTP server calls the leftover bytes a protocol violation and replaces the app's answer with a
* bodyless `400 Bad Request` + `Connection: close`. The app's real message never reaches the wire and
* Immich logs nothing, so the client sees an unexplained 400 and the server has no record of it.
*
* Measured on this machine (COMMS/UPLOAD_EMPTY_400*.md): streamed lost the response in 2 of 4 attempts
* at 8 MB, 32 MB and 64 MB alike; buffered lost it in 0 of 4 at every size. It is a race, not a size
* limit — its probability just approaches certainty as the body grows, which is why a phone's video
* failed every time while a small photo usually got through.
*
* A size threshold was considered and rejected: buffering only the small bodies would leave exactly the
* large uploads that always fail on the broken path. Cancelling the write once the response arrives was
* tried and does not help — by the time `fetch` resolves, the teardown has already happened.
*
* The cost is real and was accepted deliberately: a 300 MB video is 300 MB of this process's heap for
* the duration of the upload. That is the price of the upload working at all.
*
* ── And why there is a ceiling on it ──
*
* Buffering everything would mean a 4 GB video sitting in this heap (and another copy in officer's), so
* above MAX_BUFFERED_BODY the body is streamed as it always was. That is not a refusal and not a
* degradation of uploads: a file Immich ACCEPTS never triggers the race, because it is read to the end
* before anything is answered. The only thing lost above the cap is the error message on a file Immich
* rejects — and an upload that size which Immich rejects fails either way. Refusing it outright would
* break the legitimate case to improve the diagnosis of the doomed one.
*/
async function readBodyCapped(
req: Request,
): Promise<{ ok: true; bytes: Uint8Array<ArrayBuffer> } | { ok: false; seen: number }> {
// Decided from `content-length` alone. Officer sends one whenever IT buffered, so the two hops agree
// without coordinating: a body small enough for officer to hold is small enough to hold here. When the
// header is absent officer streamed, which means it was over the cap there too.
const declared = Number(req.headers.get('content-length') ?? '');
if (!Number.isFinite(declared) || declared <= 0 || declared > MAX_BUFFERED_BODY) return { ok: false, seen: declared };
const reader = req.body!.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > MAX_BUFFERED_BODY) {
await reader.cancel().catch(() => {});
return { ok: false, seen: total };
}
chunks.push(value);
}
const bytes = new Uint8Array(new ArrayBuffer(total));
let at = 0;
for (const chunk of chunks) {
bytes.set(chunk, at);
at += chunk.length;
}
return { ok: true, bytes };
}
/**
* Forward one request to Immich and stream the answer back.
*
* The RESPONSE is still a stream, never an ArrayBuffer: a full-resolution original or a
* `download/archive` zip is hundreds of megabytes and buffering it would hold all of it in the
* sidecar's heap for no reason. Only the request body is held, and only for the reason above.
*/
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
const rest = url.pathname.slice('/_officer/'.length);
if (!rest) return null;
const [resource] = rest.split('/');
const allowedMethods = resource ? RESOURCES[resource] : undefined;
if (!allowedMethods) return null;
if (!allowedMethods.includes(req.method)) {
return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 });
}
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
// An <img>/<video> cannot send an Authorization header, so the browser puts Officer's JWT in `?token=`
// (userMiddleware accepts it there). That token is Officer's, not Immich's: forwarding it would write the
// owner's session credential into Immich's access log for every thumbnail. Drop it at the boundary.
const query = new URLSearchParams(url.search);
query.delete('token');
const search = query.toString();
// Buffered, not streamed — see readBodyCapped. A body that is still being written when Immich answers
// loses the answer, which is what made every large upload fail with an unexplained empty 400.
let body: BodyInit | null = null;
if (hasBody && req.body) {
const read = await readBodyCapped(req);
// Over the cap (or no declared length): stream it, exactly as this always did. Not an error.
body = read.ok ? read.bytes : req.body;
}
const res = await callUpstream(cfg, {
path: `/api/${rest}`,
method: req.method,
query: search ? `?${search}` : '',
body,
contentType: req.headers.get('content-type'),
range: req.headers.get('range'),
ifNoneMatch: req.headers.get('if-none-match'),
});
return relayResponse(res);
}
+227
View File
@@ -0,0 +1,227 @@
// 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 } 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 }>();
/**
* 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.
*/
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 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;
}