From b6dc73915dfdc9e3dd08c7f4b074ca533622d6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 20:34:55 +0000 Subject: [PATCH] add officer-invoiceshelf sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 11 + CLAUDE.md | 2 +- ecosystem.config.cjs | 6 + src/servers/api/invoiceshelf/router.ts | 54 +++ .../api/invoiceshelf/sidecar-server.ts | 19 ++ src/servers/hono.ts | 3 + src/servers/sidecar/invoiceshelf/index.ts | 141 ++++++++ src/servers/sidecar/invoiceshelf/routes.ts | 322 ++++++++++++++++++ src/servers/sidecar/invoiceshelf/upstream.ts | 140 ++++++++ src/servers/sidecar/protocol.ts | 2 + 10 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 src/servers/api/invoiceshelf/router.ts create mode 100644 src/servers/api/invoiceshelf/sidecar-server.ts create mode 100644 src/servers/sidecar/invoiceshelf/index.ts create mode 100644 src/servers/sidecar/invoiceshelf/routes.ts create mode 100644 src/servers/sidecar/invoiceshelf/upstream.ts diff --git a/.env.example b/.env.example index 62e3e52a..d76d3dee 100644 --- a/.env.example +++ b/.env.example @@ -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: , 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="" +# 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="" diff --git a/CLAUDE.md b/CLAUDE.md index a788bdbe..64a7d165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 70a8cd91..e3affe5b 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -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, + }, ], }; diff --git a/src/servers/api/invoiceshelf/router.ts b/src/servers/api/invoiceshelf/router.ts new file mode 100644 index 00000000..237ded65 --- /dev/null +++ b/src/servers/api/invoiceshelf/router.ts @@ -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 = {}; + 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) }); +}); diff --git a/src/servers/api/invoiceshelf/sidecar-server.ts b/src/servers/api/invoiceshelf/sidecar-server.ts new file mode 100644 index 00000000..9ec8fa4e --- /dev/null +++ b/src/servers/api/invoiceshelf/sidecar-server.ts @@ -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; +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 20199f77..e80c9382 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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); diff --git a/src/servers/sidecar/invoiceshelf/index.ts b/src/servers/sidecar/invoiceshelf/index.ts new file mode 100644 index 00000000..f00d05d8 --- /dev/null +++ b/src/servers/sidecar/invoiceshelf/index.ts @@ -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).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')); diff --git a/src/servers/sidecar/invoiceshelf/routes.ts b/src/servers/sidecar/invoiceshelf/routes.ts new file mode 100644 index 00000000..9a52dc4b --- /dev/null +++ b/src/servers/sidecar/invoiceshelf/routes.ts @@ -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 = { + 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 = { + 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 = { + '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 { + 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): Uint8Array | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src/servers/sidecar/invoiceshelf/upstream.ts b/src/servers/sidecar/invoiceshelf/upstream.ts new file mode 100644 index 00000000..af9f2f34 --- /dev/null +++ b/src/servers/sidecar/invoiceshelf/upstream.ts @@ -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 | 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 { + 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 | 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 { + 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 { + const headers: Record = { + 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; +} diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 8b020316..a3705e18 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -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 };