Ninth plugin out, third resurrection: switched off since 2026-08-13 with the permission reduced to api: [], so the server booted clean and every panel request 404'd. invoiceshelf/schema was also commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the frontend route, the table prefix and the install key all at once, and before extraction those had drifted: the API was /api/invoiceshelf while the screen was /invoices. The URL is what a person sees and should name what the plugin shows rather than the daemon behind it — the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts to satisfy the table-prefix rule. That rename was free ONLY because the table existed on no server, which was checked before deciding. With rows in it, keeping the vendor name would have been the better trade: db:push sees a rename as a drop plus a create and this project has no migration mechanism. The sidecar keeps its Invoiceshelf-named functions deliberately — they name the upstream API it actually speaks. 91 palette classes converted, 52 of them in STATUS_TONE, which was already the single source of status colour but held raw palette strings. SENT and VIEWED now share `info`: the closed six-tone set has no second shade of "in flight, not yet actioned". Recorded in PLUGIN.md as a real loss.
178 lines
8.3 KiB
TypeScript
178 lines
8.3 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { handleConfigRoute, noteProbe, probe } from './config';
|
|
import { handleOfficerRoute } from './routes';
|
|
import { getConfig } from './upstream';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
|
|
// 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.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** 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',
|
|
handles: ['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'));
|