import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { handleConfigRoute, noteProbe, probe } from './config'; import { handleOfficerRoute } from './routes'; 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 // 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 /_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 // 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 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 { return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); } catch (err) { 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 connected', configured: false }, { 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} (instance configured from the UI, stored in invoiceshelf_accounts)`, ); 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'));