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:
2026-08-03 17:44:28 +00:00
co-authored by Claude Opus 5
parent 632d5a1c1f
commit f20d4a300e
21 changed files with 1785 additions and 144 deletions
+46 -5
View File
@@ -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) {