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
+326
View File
@@ -0,0 +1,326 @@
import type { Company, UpstreamTarget } from './upstream';
import {
createInvoiceshelfAccount,
deleteInvoiceshelfAccount,
getInvoiceshelfCredentials,
listInvoiceshelfAccounts,
recordInvoiceshelfProbe,
setActiveInvoiceshelfAccount,
updateInvoiceshelfAccount,
} from 'officerdb';
import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken, normalizeBase } from './upstream';
// `/_config` — the InvoiceShelf account registry, driven from the app rather than from a shell on the server.
//
// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned
// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The
// only way to change one is to send a new one, which is the same shape photos and headscale use.
//
// A save is validated before it is stored, and the validation does something the photos one does not: it
// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this
// API — a wrong or missing value silently returns another company's books instead of erroring — so the
// choice is made once, here, and stored, rather than falling back per request.
//
// Two accounts on the same URL with the same token but different companies is therefore an ordinary thing
// to have, and is exactly why the label is what has to be unique.
export type ProbeResult =
| { ok: true; version: string | null; companies: Company[] }
| { ok: false; version: string | null; error: string };
/** 401/419, or a 3xx that is Laravel redirecting to its HTML login — all three mean the token was refused. */
const rejected = (status: number): boolean => status === 401 || status === 419 || (status >= 300 && status < 400);
const REJECTED_MESSAGE =
'the API token was rejected — mint a new one and paste the whole value, including the leading "1|"';
/**
* Ask an instance whether it is really there and whether the token works.
*
* Two calls, because they answer different questions, but NOT the two you would expect: `/app/version` is an
* UNAUTHENTICATED route on 2.4.2, so it answers 200 to a revoked, truncated or empty token. It proves only
* that the URL points at an InvoiceShelf. The companies lookup is the first call that exercises the token at
* all, which is why its 401 has to be reported as a credential problem and not as "companies lookup failed".
*/
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
let version: string | null = null;
try {
const res = await callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false });
if (!res.ok) {
return { ok: false, version: null, error: `that URL does not look like an InvoiceShelf (${res.status})` };
}
const payload = (await res.json()) as { version?: string };
version = payload.version ?? null;
} catch (err) {
return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
}
try {
const companies = await listCompanies(cfg);
if (!companies.length) return { ok: false, version, error: 'no company is visible to this token' };
return { ok: true, version, companies };
} catch (err) {
const status = err instanceof UpstreamError ? err.status : 0;
if (rejected(status)) return { ok: false, version, error: REJECTED_MESSAGE };
return { ok: false, version, error: String(err) };
}
}
/** What the browser is allowed to know about the registry. Never includes a token. */
async function accountList(userId: number): Promise<Response> {
const accounts = await listInvoiceshelfAccounts(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 recordInvoiceshelfProbe(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;
token?: unknown;
companyId?: 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 readToken = (body: AccountBody): string => (typeof body.token === 'string' ? body.token.trim() : '');
const readCompanyId = (body: AccountBody): number | null => {
const n = Number(body.companyId);
return Number.isInteger(n) && n > 0 ? n : null;
};
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 token to store, however the owner chose to supply it.
*
* A pasted token 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 resolveToken(body: AccountBody, base: string): Promise<string> {
const token = readToken(body);
if (token) return token;
const email = readEmail(body);
const password = readPassword(body);
if (!email || !password) return '';
return mintToken(base, email, password);
}
/** Sign-in failures, in the words of what the owner actually did. 422 is Laravel's validation refusal. */
function signInError(err: unknown): string {
if (!(err instanceof UpstreamError)) return `could not reach the instance (${String(err)})`;
if (err.status === 422 || err.status === 401) return 'that email and password were rejected by InvoiceShelf';
if (err.status === 429) return 'too many sign-in attempts — wait a moment and try again';
return err.message;
}
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_invoiceshelf_accounts_user_label');
/**
* Pick the company to pin.
*
* An explicit choice is honoured only if the token can actually see it — pinning an invisible company is the
* silent-wrong-books failure this whole mechanism exists to prevent. Otherwise the sole company is taken
* without asking, and with several the caller must choose: `needsChoice` is the 409 that makes the UI show
* the picker instead of guessing.
*/
function pickCompany(companies: Company[], requested: number | null): Company | null {
if (requested != null) return companies.find((c) => c.id === requested) ?? null;
return companies.length === 1 ? companies[0]! : null;
}
/** Add an account: validated against the live instance, company pinned, then stored encrypted. */
async function addAccount(req: Request, userId: number): Promise<Response> {
const body = await readBody(req);
const url = readUrl(body);
const requested = readCompanyId(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 token: string;
try {
token = await resolveToken(body, url);
} catch (err) {
return bad(signInError(err));
}
if (!token) return bad('an API token, or an email and password to mint one, is required');
const result = await probe({ base: url, token, companyId: null });
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
const company = pickCompany(result.companies, requested);
if (!company) {
// 409, not 400: nothing is wrong with what was sent, it is just not enough to decide. The UI answers
// this by showing the returned companies and asking, then posting again with companyId.
return Response.json(
{
error: requested ? 'that company is not visible to this token' : 'choose which company to use',
needsChoice: true,
companies: result.companies,
version: result.version,
},
{ status: 409 },
);
}
// An unlabelled account takes the company's own name, which is nearly always what the owner would type.
// Falling back to the host keeps the switcher readable when the company is unnamed.
if (!label) label = company.name ?? new URL(url).host;
// The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
const existing = await listInvoiceshelfAccounts(userId);
const activate = existing.length === 0;
try {
const account = await createInvoiceshelfAccount({
userId,
label,
url,
token,
companyId: company.id,
companyName: company.name,
version: result.version,
activate,
});
invalidateConfig(userId);
return Response.json({ account });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/** Edit one account. No credential means "keep the stored one", so a rename never needs one 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 requested = readCompanyId(body);
const current = await getInvoiceshelfCredentials(userId, id);
if (!current) return bad('no such account', 404);
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
let token: string;
try {
token = await resolveToken(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;
let companyId: number | undefined;
let companyName: string | null | undefined;
if ((url && url !== current.url) || token || requested != null) {
const target = { base: url || current.url, token: token || current.token, companyId: null };
const result = await probe(target);
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
version = result.version;
// Keep the pinned company across a token rotation, but only if the new token can still see it.
const company = pickCompany(result.companies, requested ?? current.companyId);
if (!company) {
return Response.json(
{ error: 'choose which company to use', needsChoice: true, companies: result.companies },
{ status: 409 },
);
}
companyId = company.id;
companyName = company.name;
}
try {
const account = await updateInvoiceshelfAccount(userId, id, {
label: label || undefined,
url: url || undefined,
token: token || undefined,
companyId,
companyName,
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;
}
}
/** The companies a stored account's token can act for — what the company picker is populated from. */
async function accountCompanies(userId: number, id: number): Promise<Response> {
const creds = await getInvoiceshelfCredentials(userId, id);
if (!creds) return bad('no such account', 404);
try {
const companies = await listCompanies({ base: normalizeBase(creds.url), token: creds.token, companyId: null });
return Response.json({ companies, companyId: creds.companyId });
} catch (err) {
const status = err instanceof UpstreamError ? err.status : 0;
return Response.json({ error: rejected(status) ? REJECTED_MESSAGE : String(err) }, { status: 502 });
}
}
/** `subpath` is '' for /_config, or '/<id>', '/<id>/activate', '/<id>/companies'. */
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 setActiveInvoiceshelfAccount(userId, id);
if (!account) return bad('no such account', 404);
invalidateConfig(userId);
return accountList(userId);
}
if (action === 'companies') {
if (req.method !== 'GET') return bad('method not allowed', 405);
return accountCompanies(userId, id);
}
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 deleteInvoiceshelfAccount(userId, id);
if (!removed) return bad('no such account', 404);
invalidateConfig(userId);
return accountList(userId);
}
return bad('method not allowed', 405);
}
+54 -17
View File
@@ -1,12 +1,16 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute, noteProbe, probe } from './config';
import { handleOfficerRoute } from './routes';
import { callUpstream, getConfig, resolveCompanyId } from './upstream';
import { getConfig } from './upstream';
// The officer-invoiceshelf sidecar. Owns the whole InvoiceShelf contract for Officer: the instance URL, the
// Sanctum API token, and the `company` header that scopes every request. The platform API is a thin
// auth-gated forwarder (src/servers/api/invoiceshelf/router.ts) holding no InvoiceShelf credentials.
//
// The connection is the OWNER'S to set, from the UI — accounts are stored encrypted in
// `invoiceshelf_accounts` and no longer read from the environment. See upstream.ts for why that mattered.
//
// Built and verified against the LIVE instance, which runs 2.4.2 — NOT against the 3.0.0-alpha.1 checkout in
// _references/InvoiceShelf. The two differ in ways that matter (2.4.2 has no invoices/{id}/convert-to-estimate
// and no installation/is-installed). The route allow-list in routes.ts came from `artisan route:list` on the
@@ -16,6 +20,14 @@ import { callUpstream, getConfig, resolveCompanyId } from './upstream';
// HTTP CONTRACT — the platform strips its /api/invoiceshelf mount prefix before forwarding.
//
// GET /_health ours. Confirms the token is live and reports the pinned company.
// GET /_config the account registry MINUS every token
// POST /_config { label?, url, companyId?, and EITHER token OR email+password }
// — the password mints a token and is never stored.
// 409 { needsChoice, companies } when the company is ambiguous.
// PATCH /_config/:id same fields, all optional — no credential keeps the stored token
// GET /_config/:id/companies the companies that account's token can act for
// POST /_config/:id/activate switch to that account
// DEL /_config/:id remove it; the newest survivor is promoted if it was active
// GET /_officer/summary me + current company + dashboard totals, one call
// GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies
// GET /_officer/bootstrap the upstream's own bootstrap blob
@@ -59,28 +71,51 @@ const server = Bun.serve({
maxRequestBodySize: 64 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const cfg = getConfig();
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, error: 'INVOICESHELF_URL/TOKEN not configured' }, { status: 503 });
const started = Date.now();
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 {
const [versionRes, company] = await Promise.all([
callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false }),
resolveCompanyId(cfg),
]);
if (!versionRes.ok) {
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
}
const version = (await versionRes.json()) as { version?: string };
return Response.json({ ok: true, version: version.version ?? null, company, ms: Date.now() - started });
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
console.error(`[invoiceshelf] ${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,
company: cfg.companyId,
ms,
});
}
if (url.pathname.startsWith('/_officer/')) {
if (!cfg) return Response.json({ error: 'invoiceshelf not configured' }, { status: 503 });
if (!cfg) return Response.json({ error: 'invoiceshelf not connected', configured: false }, { status: 503 });
try {
const res = await handleOfficerRoute(cfg, req, url);
if (res) return res;
@@ -95,7 +130,9 @@ const server = Bun.serve({
},
});
console.log(`[invoiceshelf] listening on 127.0.0.1:${port} -> ${getConfig()?.base ?? '(INVOICESHELF_URL unset)'}`);
console.log(
`[invoiceshelf] listening on 127.0.0.1:${port} (instance configured from the UI, stored in invoiceshelf_accounts)`,
);
type ReplyFn = (msg: SidecarEvent) => void;
+116 -54
View File
@@ -4,6 +4,12 @@
// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin
// auth+forward proxy and holds NO InvoiceShelf credentials.
//
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `invoiceshelf_accounts` (see
// databases/officer_db/src/queries/invoiceshelf.ts). It is deliberately no longer read from the environment:
// Bun auto-loads `.env` into every process started in the platform directory, so an `INVOICESHELF_TOKEN`
// 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.
//
// Three things about InvoiceShelf's API are load-bearing and easy to get wrong:
//
// 1. `Accept: application/json` is MANDATORY. Without it Laravel's Authenticate middleware answers an
@@ -14,77 +20,131 @@
// 419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts
// deliberately forwards neither. Don't add them.
// 3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or
// missing value does NOT error. It silently falls back to another company's data. That is the reason
// resolveCompanyId() pins one explicitly and logs it, rather than relying on the fallback.
// missing value does NOT error. It silently falls back to another company's data. That is why the
// company is PINNED ON THE ACCOUNT ROW and chosen once when the account is added, rather than resolved
// per request from whatever the token happens to see first.
const { INVOICESHELF_URL, INVOICESHELF_TOKEN, INVOICESHELF_COMPANY_ID } = process.env;
import { getActiveInvoiceshelfCredentials } from 'officerdb';
export type UpstreamConfig = { base: string; token: string };
let warnedUnset = false;
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = { base: string; token: string; companyId: number | null };
/**
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
* 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 books answered
* is the difference between "invoices is broken" and "you are looking at the other company".
*/
export function getConfig(): UpstreamConfig | null {
const base = INVOICESHELF_URL?.trim().replace(/\/+$/, '');
const token = INVOICESHELF_TOKEN?.trim();
if (!base || !token) {
if (!warnedUnset) {
const missing = [!base && 'INVOICESHELF_URL', !token && 'INVOICESHELF_TOKEN'].filter(Boolean).join(' and ');
console.warn(`[invoiceshelf] ${missing} unset — the sidecar will respond 503 until set`);
warnedUnset = true;
}
return null;
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
// A list view is a burst of requests and each one needs the token, 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 /invoices has no account yet — the sidecar then answers 503,
* and the UI turns that into the setup form rather than a screen of empty tables.
*/
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 getActiveInvoiceshelfCredentials(userId);
const cfg = creds
? {
id: creds.id,
label: creds.label,
base: normalizeBase(creds.url),
token: creds.token,
companyId: creds.companyId,
}
: 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);
hashCache.clear();
}
export type Company = { id: number; name: string | null; unique_hash?: string | null };
/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
export class UpstreamError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
return { base, token };
}
let companyPromise: Promise<number> | null = null;
/**
* The company id every scoped request is pinned to.
* Trade an email and password for a Sanctum token.
*
* `INVOICESHELF_COMPANY_ID` wins when set. Otherwise we ask the instance once and take the first company the
* token can see — which is right for a single-company install and, crucially, is LOGGED. The silent-fallback
* behaviour is the sharpest edge in this API; making the choice explicit and visible is the whole point.
* InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the
* controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer
* required a curl command. Doing it here instead is the whole point: the password is used for this one call
* and never stored, and only the token it returns is persisted.
*
* The resolved promise is cached, so concurrent first requests share one lookup. A failure clears the cache
* so the next request retries instead of latching a transient network error forever.
* Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot
* know whether the owner also uses it somewhere else. Old ones are revoked from InvoiceShelf, not from here.
*/
export async function resolveCompanyId(cfg: UpstreamConfig): Promise<number> {
const pinned = Number(INVOICESHELF_COMPANY_ID?.trim());
if (Number.isInteger(pinned) && pinned > 0) return pinned;
companyPromise ??= (async () => {
const res = await fetch(`${cfg.base}/api/v1/companies`, {
headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' },
});
if (!res.ok) throw new Error(`companies lookup failed with ${res.status}`);
const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> };
const first = payload.data?.[0];
if (typeof first?.id !== 'number') throw new Error('no company visible to this token');
console.log(
`[invoiceshelf] pinned to company ${first.id} (${first.name ?? 'unnamed'}) — set INVOICESHELF_COMPANY_ID to override`,
);
return first.id;
})().catch((err) => {
companyPromise = null;
throw err;
export async function mintToken(base: string, email: string, password: string): Promise<string> {
const res = await fetch(`${base}/api/v1/auth/login`, {
method: 'POST',
// No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list.
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password, device_name: 'officer' }),
redirect: 'manual',
});
return companyPromise;
if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status);
const payload = (await res.json()) as { token?: unknown };
if (typeof payload.token !== 'string' || !payload.token) {
throw new UpstreamError('sign-in returned no token', res.status);
}
return payload.token;
}
let hashPromise: Promise<string> | null = null;
/**
* Every company this token can act for. Asked once when an account is added, so the owner picks explicitly
* instead of inheriting whichever one the API happens to list first.
*
* This is also the FIRST call that actually exercises the token: `/app/version` is an unauthenticated route,
* so it answers 200 to a wrong token, an empty one or a revoked one. A 401 here means the token, not the URL.
*/
export async function listCompanies(cfg: UpstreamTarget): Promise<Company[]> {
const res = await fetch(`${cfg.base}/api/v1/companies`, {
headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' },
redirect: 'manual',
});
if (!res.ok) throw new UpstreamError(`companies lookup failed with ${res.status}`, res.status);
const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> };
return (payload.data ?? [])
.filter((c): c is { id: number; name?: string } => typeof c.id === 'number')
.map((c) => ({ id: c.id, name: c.name ?? null }));
}
// Keyed by account id, because two accounts can be two companies on one instance and the hash is per company.
const hashCache = new Map<number, Promise<string>>();
/**
* The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it
* (`/reports/sales/customers/{hash}`), not by company id, so it has to be looked up and is worth caching.
*/
export async function resolveCompanyHash(cfg: UpstreamConfig): Promise<string> {
hashPromise ??= (async () => {
const cached = hashCache.get(cfg.id);
if (cached) return cached;
const pending = (async () => {
const res = await callUpstream(cfg, { path: '/api/v1/current-company' });
if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`);
const payload = (await res.json()) as { data?: { unique_hash?: string } };
@@ -92,11 +152,13 @@ export async function resolveCompanyHash(cfg: UpstreamConfig): Promise<string> {
if (!hash) throw new Error('current-company returned no unique_hash');
return hash;
})().catch((err) => {
hashPromise = null;
// Clear on failure so the next request retries instead of latching a transient network error forever.
hashCache.delete(cfg.id);
throw err;
});
return hashPromise;
hashCache.set(cfg.id, pending);
return pending;
}
type CallOptions = {
@@ -114,13 +176,13 @@ type CallOptions = {
};
/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = {
Authorization: `Bearer ${cfg.token}`,
Accept: opts.accept ?? 'application/json',
};
if (opts.withCompany !== false) headers.company = String(await resolveCompanyId(cfg));
if (opts.withCompany !== false && cfg.companyId != null) headers.company = String(cfg.companyId);
if (opts.contentType) headers['Content-Type'] = opts.contentType;
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
+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) {
+3 -2
View File
@@ -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)
+74
View File
@@ -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;