// InvoiceShelf upstream config + the one function that talks to it. // // All knowledge of the InvoiceShelf instance — its URL, its API token and which company the token acts on // 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/invoiceshelf/queries.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 // unauthenticated request with a 302 to an HTML login page instead of a 401, so every error turns into // an unparseable redirect. // 2. NEVER send Origin or Referer. InvoiceShelf's `statefulApi()` middleware treats a request carrying // either as a first-party browser call and switches from token auth to session+CSRF, which then fails // 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 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. import { getActiveInvoiceshelfCredentials } from '../db'; /** 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 }; /** * 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 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(); /** * 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 { 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); } } /** * Trade an email and password for a Sanctum token. * * 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. * * 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 mintToken(base: string, email: string, password: string): Promise { 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', }); 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; } /** * 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 { 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>(); /** * 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 { 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 } }; const hash = payload.data?.unique_hash; if (!hash) throw new Error('current-company returned no unique_hash'); return hash; })().catch((err) => { // Clear on failure so the next request retries instead of latching a transient network error forever. hashCache.delete(cfg.id); throw err; }); hashCache.set(cfg.id, pending); return pending; } type CallOptions = { /** Absolute path on the InvoiceShelf host, e.g. `/api/v1/invoices`. */ path: string; method?: string; /** Raw search string including the leading `?`, or empty. */ query?: string; body?: BodyInit | null; contentType?: string | null; /** Defaults to `application/json`; PDF routes ask for the bytes instead. */ accept?: string; /** Set false for the handful of routes scoped to the user rather than a company. */ withCompany?: boolean; }; /** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */ export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { const headers: Record = { Authorization: `Bearer ${cfg.token}`, Accept: opts.accept ?? 'application/json', }; 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 ?? ''}`, { method: opts.method ?? 'GET', headers, body: opts.body ?? undefined, // A 302 here means the token was rejected and Laravel is redirecting to the HTML login. Following it // would turn a clean 401 into a 200 full of HTML, so we surface the redirect instead. redirect: 'manual', }); if (res.status === 401 || res.status === 419 || (res.status >= 300 && res.status < 400)) { console.warn(`[invoiceshelf] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (token rejected or stateful)`); } return res; }