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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user