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.
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
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();
|
|
}
|