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:
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user