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
+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;