Files
pastilhasandClaude Fable 5 9b9b084734 provision when the account list changes, not only when you connect
Receiving users:list updated an in-memory array and did nothing else, so a
person added to Officer next month reached Photos only if somebody remembered
to re-run provisioning by hand. The platform already broadcasts on every create
and delete, and re-sends on every sidecar registration — nothing was listening.

Now it acts on it. A restart is therefore also a reconciliation, which is the
cheap way to recover from any push that was missed while the sidecar was down.

An automatic run is NOT allowed to key an adopted account. Keying one means
resetting its password, because that is the only way Immich issues a key, and
for an account that pre-dated Officer that takes away a login somebody is using
— most likely in Immich's own app. Doing that unattended, to a person who did
not know it was at stake, is not the same act as the owner choosing it: so the
account is linked, left keyless, and named in `pending`. The deliberate
/provision route still does the whole job.

Tested against the case this is for: jrg.conceicao has an Immich library and no
Officer account today. When that account exists, the plan adopts the library
rather than building a second one beside it.

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

305 lines
13 KiB
TypeScript

import type { UpstreamTarget } from './upstream';
import {
createPhotosAccount,
deletePhotosAccount,
getPhotosCredentials,
listPhotosAccountLinks,
listPhotosAccounts,
recordPhotosProbe,
setActivePhotosAccount,
updatePhotosAccount,
} from '../db/queries';
import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream';
import { backfillAccountKeys, runProvisioning } from './provision-run';
import { reissuePassword } from './provision';
// `/_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);
// ── Provision, if this connection can ──
//
// The trigger is registering an ADMIN connection, not installing the plugin: at install time there is
// no credential, so there is nothing to provision with. Awaited, so the response says what happened —
// and, importantly, carries the temporary passwords, which exist nowhere else.
const provisioning = await runProvisioning({ cfg: { base: url, key: apiKey }, configId: account.id, keyAdopted: true });
return Response.json({ account, user: result.user, provisioning });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/**
* A fresh one-time password for one person, so they can set up Immich's own app.
*
* Returned here and nowhere else. Not logged, not stored, and not readable back — asking again issues a
* new one and invalidates the last, which is the correct behaviour for a credential whose whole life is
* the walk from this reply to the person it belongs to.
*/
async function handover(req: Request, userId: number, id: number): Promise<Response> {
const creds = await getPhotosCredentials(userId, id);
if (!creds) return bad('no such account', 404);
const body = ((await req.json().catch(() => null)) as { userId?: unknown } | null) ?? {};
const target = Number(body.userId);
if (!Number.isInteger(target) || target <= 0) return bad('userId is required');
const links = await listPhotosAccountLinks(id);
const account = links.find((l) => l.userId === target);
if (!account) return bad('that account is not provisioned on this instance', 404);
const temporaryPassword = await reissuePassword(
{ base: normalizeBase(creds.url), key: creds.apiKey },
{ immichUserId: account.immichUserId, email: account.immichEmail },
);
return Response.json({ email: account.immichEmail, temporaryPassword });
}
/**
* Re-run provisioning against an instance already registered, and mint whatever keys are missing.
*
* The repair action, and the one that carries hand-over passwords back. `addAccount` does this once, at
* connect; this is the same work without needing the owner to re-enter a credential.
*
* Owner-only WITHOUT a permission rule, and worth saying out loud: every `/_config` route resolves its
* account through `getPhotosCredentials(userId, id)`, scoped to the caller's own rows. A member holding
* `photos` at write reaches this handler and gets a 404, because they have no registry of their own — the
* key they read the library with lives on their link row, not here.
*/
async function reprovision(userId: number, id: number): Promise<Response> {
const creds = await getPhotosCredentials(userId, id);
if (!creds) return bad('no such account', 404);
const cfg = { base: normalizeBase(creds.url), key: creds.apiKey };
const provisioning = await runProvisioning({ cfg, configId: id, keyAdopted: true });
const keys = await backfillAccountKeys({ cfg, configId: id });
return Response.json({ provisioning, keys });
}
/** 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 === 'provision') {
if (req.method !== 'POST') return bad('method not allowed', 405);
return reprovision(userId, id);
}
if (action === 'handover') {
if (req.method !== 'POST') return bad('method not allowed', 405);
return handover(req, userId, id);
}
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);
}