add officer-invoiceshelf sidecar

owns the invoiceshelf contract: instance url, sanctum token and the company
header that scopes every request. the platform side is the usual thin
auth+forward proxy at /api/invoiceshelf and holds no credentials.

built against the live 2.4.2 instance rather than the 3.0.0-alpha.1 checkout
in _references — the route allow-list came from artisan route:list on the
running container. they differ: 2.4.2 has estimates/{id}/convert-to-invoice
but no invoices/{id}/convert-to-estimate.

three upstream quirks absorbed here:
- accept: application/json is mandatory, or an unauthenticated request 302s
  to an html login instead of returning 401
- origin/referer must never be sent, or statefulapi() switches to session+csrf
  and every request 419s. the proxy forwards neither.
- a wrong company header does not error, it silently returns another company's
  data. the pinned company is explicit and logged.

document pdfs are repaired: 2.4.2 prefixes them with a literal serialised http
response (201 bytes) inside a body already typed application/pdf. we slice to
the %PDF- magic. the report routes don't have the bug.

resources are an allow-list. backups, disks, modules, update/*, installation/*,
mail config, settings writes and ownership transfer stay unreachable, and the
per-resource action list keeps `send` — which really emails the customer —
from being reachable by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 20:34:55 +00:00
co-authored by Claude Opus 5
parent f98ebe509f
commit b6dc73915d
10 changed files with 699 additions and 1 deletions
+11
View File
@@ -28,6 +28,17 @@ TRANSMISSION_USER=
TRANSMISSION_PASS=
# TRANSMISSION_RPC_PATH=/transmission/rpc
# InvoiceShelf (officer-invoiceshelf). The token is a Sanctum personal access token — mint one with
# POST /api/v1/auth/login {username: <email>, password, device_name} and copy the `token` field. It has
# full abilities and never expires, so treat it as a password.
# INVOICESHELF_COMPANY_ID pins which company every request is scoped to. Leave it unset on a
# single-company install and the sidecar resolves it once at boot and LOGS the choice — worth setting
# explicitly if you have more than one, because InvoiceShelf does not error on a wrong company header,
# it silently returns the other company's data.
INVOICESHELF_URL=https://invoice.example.com
INVOICESHELF_TOKEN="<sanctum api token, e.g. 1|xxxxxxxx>"
# INVOICESHELF_COMPANY_ID=1
# slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request.
SLSKD_URL=http://127.0.0.1:5030
SLSKD_API_KEY="<slskd api key>"
+1 -1
View File
@@ -25,7 +25,7 @@ Long-running and privileged work lives in **sidecars**: separate processes that
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`.
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`.
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
+6
View File
@@ -82,5 +82,11 @@ module.exports = {
args: 'run src/servers/sidecar/transmission/index.ts',
watch: false,
},
{
name: 'officer-invoiceshelf',
script: 'bun',
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
watch: false,
},
],
};
+54
View File
@@ -0,0 +1,54 @@
import { createRouter } from '../../create-router';
import { getInvoiceshelfServerUrl } from './sidecar-server';
// Thin reverse-proxy for /api/invoiceshelf/*. The platform's ONLY job here is AUTH + FORWARDING: this router
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
// subpath + query + body to the officer-invoiceshelf sidecar, which OWNS the InvoiceShelf contract and holds
// the API token.
//
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
// against an allow-list of resources — the administrative half of InvoiceShelf's API is unreachable by
// design. The full contract is documented at the top of src/servers/sidecar/invoiceshelf/index.ts. It is
// opaque from here: this file must never grow InvoiceShelf logic.
//
// Note which headers are forwarded, and which are NOT. Origin and Referer are deliberately dropped: if either
// reaches InvoiceShelf, its statefulApi() middleware switches from token auth to session+CSRF and every
// request 419s. The browser sets them on same-origin XHR, so passing them through would break the sidecar in
// a way that looks like an auth bug.
export const invoiceshelfRouter = createRouter();
const PREFIX = '/api/invoiceshelf';
invoiceshelfRouter.all('/*', async (ctx) => {
const baseUrl = getInvoiceshelfServerUrl();
if (!baseUrl) return ctx.text('invoiceshelf sidecar not available', 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(PREFIX.length) || '/';
const target = `${baseUrl}${subpath}${url.search}`;
const method = ctx.req.method;
const headers: Record<string, string> = {};
const contentType = ctx.req.header('content-type');
if (contentType) headers['Content-Type'] = contentType;
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
// loopback only, so this header is trusted.
headers['X-Officer-User'] = String(ctx.get('user').id);
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
console.error('[invoiceshelf] proxy fetch failed', { target, error: String(err) });
return ctx.text('invoiceshelf sidecar unreachable', 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});
@@ -0,0 +1,19 @@
import * as sidecar from '@@/sidecar-registry';
// The officer-invoiceshelf sidecar starts its HTTP server on a random loopback port and reports it here on
// connect. We remember it so `/api/invoiceshelf/*` always forwards to the current sidecar. The platform holds
// NO knowledge of InvoiceShelf itself — not its URL, not its API token, and not which company it acts as.
let serverPort: number | null = null;
sidecar.on('invoiceshelf:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
console.log(`[invoiceshelf] sidecar registered on port ${port}`);
});
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
export function getInvoiceshelfServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
+3
View File
@@ -24,6 +24,7 @@ import { vaultRouter } from './api/vault/router';
import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router';
import { transmissionRouter } from './api/transmission/router';
import { invoiceshelfRouter } from './api/invoiceshelf/router';
import { vpnRouter } from './api/vpn/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router';
@@ -32,6 +33,7 @@ import './api/vault/sidecar-server'; // side-effect: capture the officer-vault r
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
@@ -116,6 +118,7 @@ protectedRouter.route('/music', musicRouter);
protectedRouter.route('/slskd', slskdRouter);
protectedRouter.route('/headscale', headscaleRouter);
protectedRouter.route('/transmission', transmissionRouter);
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
protectedRouter.route('/vpn', vpnRouter);
protectedRouter.route('/system-monitor', systemMonitorRouter);
protectedRouter.route('/activity', activityRouter);
+141
View File
@@ -0,0 +1,141 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleOfficerRoute } from './routes';
import { callUpstream, getConfig, resolveCompanyId } 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.
//
// 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
// running container.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// 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 /_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
// GET /_officer/next-number?key=invoice the number the next document would take
// GET /_officer/search?search= cross-entity search
// GET /_officer/reports/:kind sales-customers|sales-items|tax-summary|profit-loss|expenses → PDF
//
// GET|POST /_officer/:resource list / create
// GET|PUT|DELETE /_officer/:resource/:id read / update / delete
// POST /_officer/:resource/delete bulk delete {ids:[…]}
// GET /_officer/:resource/templates invoices|estimates only
// GET /_officer/:resource/:id/pdf invoices|estimates|payments — magic-byte repaired
// GET /_officer/:resource/:id/preview rendered email HTML, sends nothing.
// Requires ?subject=&body=&from=&to= — the controller
// validates them and 422s otherwise.
// GET /_officer/customers/:id/stats
// POST /_officer/:resource/:id/:action status|clone|send|convert-to-invoice|duplicate
// anything else 404
//
// `:resource` is an allow-list (routes.ts). The administrative half of the API — backups, disks, modules,
// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Expense receipts and company logos are uploaded as multipart bodies through this proxy.
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();
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 });
} catch (err) {
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
}
}
if (url.pathname.startsWith('/_officer/')) {
if (!cfg) return Response.json({ error: 'invoiceshelf not configured' }, { status: 503 });
try {
const res = await handleOfficerRoute(cfg, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[invoiceshelf] listening on 127.0.0.1:${port} -> ${getConfig()?.base ?? '(INVOICESHELF_URL unset)'}`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'invoiceshelf',
capabilities: ['invoiceshelf'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'invoiceshelf:server', port });
console.log(`[invoiceshelf] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[invoiceshelf] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+322
View File
@@ -0,0 +1,322 @@
import type { UpstreamConfig } from './upstream';
import { callUpstream, resolveCompanyHash } from './upstream';
// The Officer-owned route surface. Every path here is deliberate: this is an allow-list, not a passthrough.
//
// InvoiceShelf's API is already REST, so most of these are shaped proxies rather than translations — the
// value the sidecar adds is (a) holding the credential, (b) pinning the company, (c) repairing the PDF
// responses, and (d) refusing to expose the administrative half of the API at all.
//
// DELIBERATELY NOT EXPOSED: backups, disks, modules, update/*, installation/*, mail config and test-send,
// settings writes, and transfer/ownership. Those either mutate the deployment or can lock the owner out of
// it, and none of them belong behind a dashboard proxy. Adding one should be a decision, not an accident.
type Capabilities = { write: boolean; bulkDelete: boolean };
const RESOURCES: Record<string, Capabilities> = {
invoices: { write: true, bulkDelete: true },
estimates: { write: true, bulkDelete: true },
'recurring-invoices': { write: true, bulkDelete: true },
payments: { write: true, bulkDelete: true },
expenses: { write: true, bulkDelete: true },
customers: { write: true, bulkDelete: true },
items: { write: true, bulkDelete: true },
units: { write: true, bulkDelete: false },
categories: { write: true, bulkDelete: false },
'tax-types': { write: true, bulkDelete: false },
'payment-methods': { write: true, bulkDelete: false },
notes: { write: true, bulkDelete: false },
'custom-fields': { write: true, bulkDelete: false },
currencies: { write: false, bulkDelete: false },
countries: { write: false, bulkDelete: false },
users: { write: false, bulkDelete: false },
roles: { write: false, bulkDelete: false },
};
// Non-REST sub-actions, per resource. Verified against `artisan route:list` on the LIVE 2.4.2 instance
// rather than the 3.0.0-alpha checkout in _references — they differ. Notably 2.4.2 has
// estimates/{id}/convert-to-invoice but NO invoices/{id}/convert-to-estimate, which 3.0 added.
const ACTIONS: Record<string, readonly string[]> = {
invoices: ['status', 'clone', 'send'],
estimates: ['status', 'clone', 'send', 'convert-to-invoice'],
payments: ['send'],
expenses: ['duplicate'],
};
// Resources whose PDF lives on a web route keyed by `unique_hash`.
const PDF_RESOURCES = new Set(['invoices', 'estimates', 'payments']);
const REPORTS: Record<string, string> = {
'sales-customers': 'reports/sales/customers',
'sales-items': 'reports/sales/items',
'tax-summary': 'reports/tax-summary',
'profit-loss': 'reports/profit-loss',
expenses: 'reports/expenses',
};
const notFound = () => Response.json({ error: 'not found' }, { status: 404 });
const badRequest = (error: string) => Response.json({ error }, { status: 400 });
/** Pass an upstream JSON response through untouched — including Laravel's 422 `errors{}` envelope. */
async function passthrough(res: Response): Promise<Response> {
const body = await res.arrayBuffer();
const headers = new Headers();
const type = res.headers.get('content-type');
if (type) headers.set('Content-Type', type);
return new Response(body, { status: res.status, headers });
}
/**
* InvoiceShelf 2.4.2 prefixes the document PDFs with a literal serialised HTTP response — the body of
* `/invoices/pdf/{hash}` starts `HTTP/1.0 200 OK\r\nCache-Control: …\r\n\r\n%PDF-1.7`, 201 bytes of it,
* inside a response whose own Content-Type is already application/pdf. Every PDF reader rejects that.
*
* So: find the `%PDF-` magic and slice from there. Verified on this instance — the document routes carry the
* prefix, the report routes do not, and slicing is a no-op when the magic is already at offset 0.
*/
function repairPdf(bytes: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> | null {
const magic = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
const limit = Math.min(bytes.length - magic.length, 4096);
for (let i = 0; i <= limit; i++) {
let hit = true;
for (let j = 0; j < magic.length; j++) {
if (bytes[i + j] !== magic[j]) {
hit = false;
break;
}
}
if (hit) return i === 0 ? bytes : bytes.subarray(i);
}
return null;
}
type PdfParams = { cfg: UpstreamConfig; path: string; query: string; filename: string };
async function servePdf({ cfg, path, query, filename }: PdfParams): Promise<Response> {
const res = await callUpstream(cfg, { path, query, accept: 'application/pdf' });
if (!res.ok) return passthrough(res);
const repaired = repairPdf(new Uint8Array(await res.arrayBuffer()));
if (!repaired) return Response.json({ error: 'upstream did not return a PDF' }, { status: 502 });
return new Response(new Blob([repaired]), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
}
/** Look up a document's `unique_hash`, which its PDF web route is keyed by. */
async function fetchUniqueHash(cfg: UpstreamConfig, resource: string, id: string): Promise<string | null> {
const res = await callUpstream(cfg, { path: `/api/v1/${resource}/${id}` });
if (!res.ok) return null;
const payload = (await res.json()) as { data?: { unique_hash?: string } };
return payload.data?.unique_hash ?? null;
}
async function readJson(req: Request): Promise<unknown> {
try {
return await req.json();
} catch {
return null;
}
}
type Bootstrap = {
current_user?: unknown;
current_company?: unknown;
current_company_currency?: unknown;
current_company_settings?: unknown;
};
/**
* Everything a dashboard needs on first paint, in one round trip: who we are, which company we are pinned to,
* how to format its money, and the totals.
*
* The currency matters more than it looks. Every amount in this API is an integer in minor units, and
* InvoiceShelf's own frontend just divides by 100 — but `current_company_currency` carries the real
* precision, separators and `swap_currency_symbol` (this instance is EUR: "1.234,56 €"). A client that
* formats from these fields is right for currencies the upstream's own UI gets wrong.
*/
async function summary(cfg: UpstreamConfig): Promise<Response> {
const [bootRes, dashRes] = await Promise.all([
callUpstream(cfg, { path: '/api/v1/bootstrap' }),
callUpstream(cfg, { path: '/api/v1/dashboard' }),
]);
if (!dashRes.ok) return passthrough(dashRes);
const boot = bootRes.ok ? ((await bootRes.json()) as Bootstrap) : null;
const dashboard = await dashRes.json();
return Response.json({
me: boot?.current_user ?? null,
company: boot?.current_company ?? null,
currency: boot?.current_company_currency ?? null,
settings: boot?.current_company_settings ?? null,
dashboard,
});
}
/**
* The reference-data a form needs, fetched in parallel. All of these are small and change rarely, so one
* round trip beats seven — and `limit=all` keeps customers and items out of pagination.
*/
async function lookups(cfg: UpstreamConfig): Promise<Response> {
const wanted = [
['customers', '/api/v1/customers?limit=all'],
['items', '/api/v1/items?limit=all'],
['units', '/api/v1/units?limit=all'],
['taxTypes', '/api/v1/tax-types?limit=all'],
['categories', '/api/v1/categories?limit=all'],
['paymentMethods', '/api/v1/payment-methods?limit=all'],
['currencies', '/api/v1/currencies'],
] as const;
const entries = await Promise.all(
wanted.map(async ([key, path]) => {
const res = await callUpstream(cfg, { path });
if (!res.ok) return [key, []] as const;
const payload = (await res.json()) as { data?: unknown };
return [key, payload.data ?? []] as const;
}),
);
return Response.json(Object.fromEntries(entries));
}
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
const segments = url.pathname
.replace(/^\/_officer\/?/, '')
.split('/')
.filter(Boolean);
const query = url.search;
const method = req.method;
const head = segments[0];
if (!head) return notFound();
const second = segments[1];
const third = segments[2];
// ── Officer-composed reads ────────────────────────────────────────────────────────────────────
if (head === 'summary' && method === 'GET') return summary(cfg);
if (head === 'lookups' && method === 'GET') return lookups(cfg);
if (head === 'bootstrap' && method === 'GET') {
return passthrough(await callUpstream(cfg, { path: '/api/v1/bootstrap', query }));
}
// `?key=invoice|estimate|payment` — the number the next document would get.
if (head === 'next-number' && method === 'GET') {
return passthrough(await callUpstream(cfg, { path: '/api/v1/next-number', query }));
}
if (head === 'search' && method === 'GET') {
return passthrough(await callUpstream(cfg, { path: '/api/v1/search', query }));
}
// ── Reports: PDFs on web routes keyed by the company hash ─────────────────────────────────────
if (head === 'reports') {
if (method !== 'GET' || !second) return notFound();
const route = REPORTS[second];
if (!route) return notFound();
const hash = await resolveCompanyHash(cfg);
return servePdf({ cfg, path: `/${route}/${hash}`, query, filename: `${second}.pdf` });
}
// ── Allow-listed resources ────────────────────────────────────────────────────────────────────
const caps = RESOURCES[head];
if (!caps) return notFound();
const base = `/api/v1/${head}`;
const contentType = req.headers.get('content-type') ?? 'application/json';
// Collection: list and create.
if (!second) {
if (method === 'GET') return passthrough(await callUpstream(cfg, { path: base, query }));
if (method === 'POST') {
if (!caps.write) return badRequest(`${head} is read-only`);
return passthrough(
await callUpstream(cfg, { path: base, method: 'POST', body: await req.arrayBuffer(), contentType }),
);
}
return notFound();
}
// Bulk delete — InvoiceShelf models it as POST /{resource}/delete with {"ids":[…]}, not DELETE.
if (second === 'delete' && method === 'POST') {
if (!caps.bulkDelete) return badRequest(`${head} has no bulk delete`);
const body = await readJson(req);
const ids = (body as { ids?: unknown })?.ids;
if (!Array.isArray(ids) || ids.length === 0) return badRequest('ids must be a non-empty array');
return passthrough(
await callUpstream(cfg, {
path: `${base}/delete`,
method: 'POST',
body: JSON.stringify({ ids }),
contentType: 'application/json',
}),
);
}
// The template galleries, which sit alongside the collection rather than under an id.
if (second === 'templates' && method === 'GET' && (head === 'invoices' || head === 'estimates')) {
return passthrough(await callUpstream(cfg, { path: `${base}/templates` }));
}
const id = second;
// Single resource.
if (!third) {
if (method === 'GET') return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, query }));
if (method === 'PUT' || method === 'PATCH') {
if (!caps.write) return badRequest(`${head} is read-only`);
return passthrough(
await callUpstream(cfg, { path: `${base}/${id}`, method: 'PUT', body: await req.arrayBuffer(), contentType }),
);
}
if (method === 'DELETE') {
if (!caps.write) return badRequest(`${head} is read-only`);
return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, method: 'DELETE' }));
}
return notFound();
}
// ── Sub-resources ─────────────────────────────────────────────────────────────────────────────
if (third === 'pdf' && method === 'GET') {
if (!PDF_RESOURCES.has(head)) return notFound();
const hash = await fetchUniqueHash(cfg, head, id);
if (!hash) return Response.json({ error: `${head}/${id} not found` }, { status: 404 });
// Built from OUR base rather than the upstream's `*_pdf_url`, which is rendered from the instance's
// APP_URL and can point somewhere this process cannot reach.
return servePdf({ cfg, path: `/${head}/pdf/${hash}`, query, filename: `${head.slice(0, -1)}-${id}.pdf` });
}
// The rendered email body, as HTML, without sending anything.
if (third === 'preview' && method === 'GET') {
if (!ACTIONS[head]?.includes('send')) return notFound();
return passthrough(await callUpstream(cfg, { path: `${base}/${id}/send/preview`, query }));
}
if (head === 'customers' && third === 'stats' && method === 'GET') {
return passthrough(await callUpstream(cfg, { path: `${base}/${id}/stats`, query }));
}
// Named actions. `send` really does email the customer, which is why actions are an explicit allow-list
// per resource rather than a wildcard under the id.
if (method === 'POST' && ACTIONS[head]?.includes(third)) {
return passthrough(
await callUpstream(cfg, {
path: `${base}/${id}/${third}`,
method: 'POST',
body: await req.arrayBuffer(),
contentType,
}),
);
}
return notFound();
}
@@ -0,0 +1,140 @@
// 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.
//
// 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 the reason
// resolveCompanyId() pins one explicitly and logs it, rather than relying on the fallback.
const { INVOICESHELF_URL, INVOICESHELF_TOKEN, INVOICESHELF_COMPANY_ID } = process.env;
export type UpstreamConfig = { base: string; token: string };
let warnedUnset = false;
/**
* 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.
*/
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;
}
return { base, token };
}
let companyPromise: Promise<number> | null = null;
/**
* The company id every scoped request is pinned to.
*
* `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.
*
* 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.
*/
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;
});
return companyPromise;
}
let hashPromise: Promise<string> | null = null;
/**
* 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 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) => {
hashPromise = null;
throw err;
});
return hashPromise;
}
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: UpstreamConfig, 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.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;
}
+2
View File
@@ -72,6 +72,8 @@ export type SidecarEvent =
| { type: 'headscale:server'; port: number }
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'transmission:server'; port: number }
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'invoiceshelf:server'; port: number }
// Generic
| { type: 'error'; id?: string; error: string };