invoiceshelf: accounts are configured from the ui, not the environment
The same registry photos got: any number of labelled instances stored encrypted in invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across the sidecar boundary — the list has no field that could carry it back — and nothing reads INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a credential only the sidecar can use. The company is pinned on the account row rather than resolved per request. InvoiceShelf's `company` header does not error on a wrong or missing value; it silently returns another company's books. So the choice is made once, at add time, and a token that can act for several answers 409 with the list instead of guessing. Both apps also take an email and password now, because neither service makes a key easy to get: InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way), and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again — and stores only what comes back. The password is never persisted. Pasting a key still works. Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read from the running containers. The two sign-in paths are untested end to end — no second login to try them with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
setActivePhotosAccount,
|
||||
updatePhotosAccount,
|
||||
} from 'officerdb';
|
||||
import { callUpstream, invalidateConfig, normalizeBase } from './upstream';
|
||||
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.
|
||||
//
|
||||
@@ -16,6 +16,10 @@ import { callUpstream, invalidateConfig, normalizeBase } from './upstream';
|
||||
// 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 headscale'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
|
||||
@@ -80,7 +84,7 @@ export async function noteProbe(userId: number, id: number, version: string | nu
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown };
|
||||
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) ?? {};
|
||||
@@ -88,21 +92,52 @@ const readBody = async (req: Request): Promise<AccountBody> =>
|
||||
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);
|
||||
const apiKey = readKey(body);
|
||||
let label = readLabel(body);
|
||||
|
||||
if (!url || !apiKey) return bad('url and apiKey are required');
|
||||
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 });
|
||||
|
||||
@@ -129,12 +164,18 @@ async function editAccount(req: Request, userId: number, id: number): Promise<Re
|
||||
const body = await readBody(req);
|
||||
const label = readLabel(body);
|
||||
const url = readUrl(body);
|
||||
const apiKey = readKey(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) {
|
||||
|
||||
@@ -21,8 +21,9 @@ import { getConfig } from './upstream';
|
||||
//
|
||||
// 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, apiKey } — validated against the instance, then stored encrypted
|
||||
// PATCH /_config/:id { label?, url?, apiKey? } — a blank key keeps the stored one
|
||||
// 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)
|
||||
|
||||
@@ -61,6 +61,80 @@ 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 an API key, so connecting Officer does not mean a trip to Immich's own
|
||||
* settings screen.
|
||||
*
|
||||
* Three calls, and the shape of them is the point:
|
||||
*
|
||||
* 1. `/auth/login` returns a SESSION token, not an API key. Storing that would be wrong — 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.
|
||||
* 2. `/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.
|
||||
* 3. `/auth/logout` ends the session we only needed to hold the key. Skipping it would leave a phantom
|
||||
* "device" in the owner's Immich account for every connection made from Officer.
|
||||
*
|
||||
* 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 login = 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 (!login.ok) throw new UpstreamError(await upstreamMessage(login, 'sign-in failed'), login.status);
|
||||
|
||||
const session = (await login.json()) as { accessToken?: unknown };
|
||||
if (typeof session.accessToken !== 'string' || !session.accessToken) {
|
||||
throw new UpstreamError('sign-in returned no access token', login.status);
|
||||
}
|
||||
const bearer = { Authorization: `Bearer ${session.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 {
|
||||
// Best effort: a session we cannot close is untidy, not a failure of the thing the owner asked for.
|
||||
await fetch(`${base}/api/auth/logout`, { method: 'POST', headers: bearer, redirect: 'manual' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
type CallOptions = {
|
||||
/** Absolute path on the Immich host, e.g. `/api/albums`. */
|
||||
path: string;
|
||||
|
||||
Reference in New Issue
Block a user