The same registry photos got: any number of labelled instances stored encrypted in invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across the sidecar boundary — the list has no field that could carry it back — and nothing reads INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a credential only the sidecar can use. The company is pinned on the account row rather than resolved per request. InvoiceShelf's `company` header does not error on a wrong or missing value; it silently returns another company's books. So the choice is made once, at add time, and a token that can act for several answers 409 with the list instead of guessing. Both apps also take an email and password now, because neither service makes a key easy to get: InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way), and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again — and stores only what comes back. The password is never persisted. Pasting a key still works. Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read from the running containers. The two sign-in paths are untested end to end — no second login to try them with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
8.4 KiB
TypeScript
179 lines
8.4 KiB
TypeScript
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<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'));
|