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(); }