invoices, extracted from the platform as a plugin
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.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import type { Company, UpstreamTarget } from './upstream';
|
||||
import {
|
||||
createInvoiceshelfAccount,
|
||||
deleteInvoiceshelfAccount,
|
||||
getInvoiceshelfCredentials,
|
||||
listInvoiceshelfAccounts,
|
||||
recordInvoiceshelfProbe,
|
||||
setActiveInvoiceshelfAccount,
|
||||
updateInvoiceshelfAccount,
|
||||
} from '../db';
|
||||
import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken, normalizeBase } from './upstream';
|
||||
|
||||
// `/_config` — the InvoiceShelf account registry, driven from the app rather than from a shell on the server.
|
||||
//
|
||||
// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned
|
||||
// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The
|
||||
// only way to change one is to send a new one, which is the same shape photos and offscale use.
|
||||
//
|
||||
// A save is validated before it is stored, and the validation does something the photos one does not: it
|
||||
// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this
|
||||
// API — a wrong or missing value silently returns another company's books instead of erroring — so the
|
||||
// choice is made once, here, and stored, rather than falling back per request.
|
||||
//
|
||||
// Two accounts on the same URL with the same token but different companies is therefore an ordinary thing
|
||||
// to have, and is exactly why the label is what has to be unique.
|
||||
|
||||
export type ProbeResult =
|
||||
| { ok: true; version: string | null; companies: Company[] }
|
||||
| { ok: false; version: string | null; error: string };
|
||||
|
||||
/** 401/419, or a 3xx that is Laravel redirecting to its HTML login — all three mean the token was refused. */
|
||||
const rejected = (status: number): boolean => status === 401 || status === 419 || (status >= 300 && status < 400);
|
||||
|
||||
const REJECTED_MESSAGE =
|
||||
'the API token was rejected — mint a new one and paste the whole value, including the leading "1|"';
|
||||
|
||||
/**
|
||||
* Ask an instance whether it is really there and whether the token works.
|
||||
*
|
||||
* Two calls, because they answer different questions, but NOT the two you would expect: `/app/version` is an
|
||||
* UNAUTHENTICATED route on 2.4.2, so it answers 200 to a revoked, truncated or empty token. It proves only
|
||||
* that the URL points at an InvoiceShelf. The companies lookup is the first call that exercises the token at
|
||||
* all, which is why its 401 has to be reported as a credential problem and not as "companies lookup failed".
|
||||
*/
|
||||
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
|
||||
let version: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false });
|
||||
if (!res.ok) {
|
||||
return { ok: false, version: null, error: `that URL does not look like an InvoiceShelf (${res.status})` };
|
||||
}
|
||||
const payload = (await res.json()) as { version?: string };
|
||||
version = payload.version ?? null;
|
||||
} catch (err) {
|
||||
return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
|
||||
}
|
||||
|
||||
try {
|
||||
const companies = await listCompanies(cfg);
|
||||
if (!companies.length) return { ok: false, version, error: 'no company is visible to this token' };
|
||||
return { ok: true, version, companies };
|
||||
} catch (err) {
|
||||
const status = err instanceof UpstreamError ? err.status : 0;
|
||||
if (rejected(status)) return { ok: false, version, error: REJECTED_MESSAGE };
|
||||
return { ok: false, version, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** What the browser is allowed to know about the registry. Never includes a token. */
|
||||
async function accountList(userId: number): Promise<Response> {
|
||||
const accounts = await listInvoiceshelfAccounts(userId);
|
||||
const active = accounts.find((account) => account.isActive) ?? null;
|
||||
return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts });
|
||||
}
|
||||
|
||||
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
|
||||
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||
await recordInvoiceshelfProbe(userId, id, version).catch(() => {
|
||||
/* a stale lastSeenAt is not worth failing a request over */
|
||||
});
|
||||
}
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
type AccountBody = {
|
||||
label?: unknown;
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
companyId?: unknown;
|
||||
email?: unknown;
|
||||
password?: unknown;
|
||||
};
|
||||
|
||||
const readBody = async (req: Request): Promise<AccountBody> =>
|
||||
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
|
||||
|
||||
const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
|
||||
const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
|
||||
const readToken = (body: AccountBody): string => (typeof body.token === 'string' ? body.token.trim() : '');
|
||||
|
||||
const readCompanyId = (body: AccountBody): number | null => {
|
||||
const n = Number(body.companyId);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : '');
|
||||
const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : '');
|
||||
|
||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||
|
||||
/**
|
||||
* The token to store, however the owner chose to supply it.
|
||||
*
|
||||
* A pasted token wins; otherwise an email and password are traded for a fresh one and the password is
|
||||
* discarded with this function's stack frame. Returns '' when neither was given, which for an edit means
|
||||
* "keep what is stored" and for an add is an error.
|
||||
*/
|
||||
async function resolveToken(body: AccountBody, base: string): Promise<string> {
|
||||
const token = readToken(body);
|
||||
if (token) return token;
|
||||
|
||||
const email = readEmail(body);
|
||||
const password = readPassword(body);
|
||||
if (!email || !password) return '';
|
||||
|
||||
return mintToken(base, email, password);
|
||||
}
|
||||
|
||||
/** Sign-in failures, in the words of what the owner actually did. 422 is Laravel's validation refusal. */
|
||||
function signInError(err: unknown): string {
|
||||
if (!(err instanceof UpstreamError)) return `could not reach the instance (${String(err)})`;
|
||||
if (err.status === 422 || err.status === 401) return 'that email and password were rejected by InvoiceShelf';
|
||||
if (err.status === 429) return 'too many sign-in attempts — wait a moment and try again';
|
||||
return err.message;
|
||||
}
|
||||
|
||||
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_invoiceshelf_accounts_user_label');
|
||||
|
||||
/**
|
||||
* Pick the company to pin.
|
||||
*
|
||||
* An explicit choice is honoured only if the token can actually see it — pinning an invisible company is the
|
||||
* silent-wrong-books failure this whole mechanism exists to prevent. Otherwise the sole company is taken
|
||||
* without asking, and with several the caller must choose: `needsChoice` is the 409 that makes the UI show
|
||||
* the picker instead of guessing.
|
||||
*/
|
||||
function pickCompany(companies: Company[], requested: number | null): Company | null {
|
||||
if (requested != null) return companies.find((c) => c.id === requested) ?? null;
|
||||
return companies.length === 1 ? companies[0]! : null;
|
||||
}
|
||||
|
||||
/** Add an account: validated against the live instance, company pinned, then stored encrypted. */
|
||||
async function addAccount(req: Request, userId: number): Promise<Response> {
|
||||
const body = await readBody(req);
|
||||
const url = readUrl(body);
|
||||
const requested = readCompanyId(body);
|
||||
let label = readLabel(body);
|
||||
|
||||
if (!url) return bad('url is required');
|
||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = await resolveToken(body, url);
|
||||
} catch (err) {
|
||||
return bad(signInError(err));
|
||||
}
|
||||
if (!token) return bad('an API token, or an email and password to mint one, is required');
|
||||
|
||||
const result = await probe({ base: url, token, companyId: null });
|
||||
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
||||
|
||||
const company = pickCompany(result.companies, requested);
|
||||
if (!company) {
|
||||
// 409, not 400: nothing is wrong with what was sent, it is just not enough to decide. The UI answers
|
||||
// this by showing the returned companies and asking, then posting again with companyId.
|
||||
return Response.json(
|
||||
{
|
||||
error: requested ? 'that company is not visible to this token' : 'choose which company to use',
|
||||
needsChoice: true,
|
||||
companies: result.companies,
|
||||
version: result.version,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// An unlabelled account takes the company's own name, which is nearly always what the owner would type.
|
||||
// Falling back to the host keeps the switcher readable when the company is unnamed.
|
||||
if (!label) label = company.name ?? new URL(url).host;
|
||||
|
||||
// The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
|
||||
const existing = await listInvoiceshelfAccounts(userId);
|
||||
const activate = existing.length === 0;
|
||||
|
||||
try {
|
||||
const account = await createInvoiceshelfAccount({
|
||||
userId,
|
||||
label,
|
||||
url,
|
||||
token,
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
version: result.version,
|
||||
activate,
|
||||
});
|
||||
invalidateConfig(userId);
|
||||
return Response.json({ account });
|
||||
} catch (err) {
|
||||
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Edit one account. No credential means "keep the stored one", so a rename never needs one re-typed. */
|
||||
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
|
||||
const body = await readBody(req);
|
||||
const label = readLabel(body);
|
||||
const url = readUrl(body);
|
||||
const requested = readCompanyId(body);
|
||||
|
||||
const current = await getInvoiceshelfCredentials(userId, id);
|
||||
if (!current) return bad('no such account', 404);
|
||||
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = await resolveToken(body, url || current.url);
|
||||
} catch (err) {
|
||||
return bad(signInError(err));
|
||||
}
|
||||
|
||||
// Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
|
||||
let version: string | null | undefined;
|
||||
let companyId: number | undefined;
|
||||
let companyName: string | null | undefined;
|
||||
|
||||
if ((url && url !== current.url) || token || requested != null) {
|
||||
const target = { base: url || current.url, token: token || current.token, companyId: null };
|
||||
const result = await probe(target);
|
||||
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
||||
version = result.version;
|
||||
|
||||
// Keep the pinned company across a token rotation, but only if the new token can still see it.
|
||||
const company = pickCompany(result.companies, requested ?? current.companyId);
|
||||
if (!company) {
|
||||
return Response.json(
|
||||
{ error: 'choose which company to use', needsChoice: true, companies: result.companies },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
companyId = company.id;
|
||||
companyName = company.name;
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await updateInvoiceshelfAccount(userId, id, {
|
||||
label: label || undefined,
|
||||
url: url || undefined,
|
||||
token: token || undefined,
|
||||
companyId,
|
||||
companyName,
|
||||
version,
|
||||
});
|
||||
if (!account) return bad('no such account', 404);
|
||||
invalidateConfig(userId);
|
||||
return Response.json({ account });
|
||||
} catch (err) {
|
||||
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The companies a stored account's token can act for — what the company picker is populated from. */
|
||||
async function accountCompanies(userId: number, id: number): Promise<Response> {
|
||||
const creds = await getInvoiceshelfCredentials(userId, id);
|
||||
if (!creds) return bad('no such account', 404);
|
||||
try {
|
||||
const companies = await listCompanies({ base: normalizeBase(creds.url), token: creds.token, companyId: null });
|
||||
return Response.json({ companies, companyId: creds.companyId });
|
||||
} catch (err) {
|
||||
const status = err instanceof UpstreamError ? err.status : 0;
|
||||
return Response.json({ error: rejected(status) ? REJECTED_MESSAGE : String(err) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
/** `subpath` is '' for /_config, or '/<id>', '/<id>/activate', '/<id>/companies'. */
|
||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||
const [, rawId, action] = subpath.split('/');
|
||||
|
||||
if (!rawId) {
|
||||
if (req.method === 'GET') return accountList(userId);
|
||||
if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId);
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
|
||||
|
||||
if (action === 'activate') {
|
||||
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||
const account = await setActiveInvoiceshelfAccount(userId, id);
|
||||
if (!account) return bad('no such account', 404);
|
||||
invalidateConfig(userId);
|
||||
return accountList(userId);
|
||||
}
|
||||
|
||||
if (action === 'companies') {
|
||||
if (req.method !== 'GET') return bad('method not allowed', 405);
|
||||
return accountCompanies(userId, id);
|
||||
}
|
||||
|
||||
if (action) return bad('not found', 404);
|
||||
|
||||
if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id);
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const removed = await deleteInvoiceshelfAccount(userId, id);
|
||||
if (!removed) return bad('no such account', 404);
|
||||
invalidateConfig(userId);
|
||||
return accountList(userId);
|
||||
}
|
||||
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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'));
|
||||
@@ -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,202 @@
|
||||
// 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.
|
||||
//
|
||||
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `invoiceshelf_accounts` (see
|
||||
// databases/officer_db/src/invoiceshelf/queries.ts). It is deliberately no longer read from the environment:
|
||||
// Bun auto-loads `.env` into every process started in the platform directory, so an `INVOICESHELF_TOKEN`
|
||||
// there was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no
|
||||
// code to use it and the largest attack surface in the system. Nothing in this file reads process.env.
|
||||
//
|
||||
// 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 why the
|
||||
// company is PINNED ON THE ACCOUNT ROW and chosen once when the account is added, rather than resolved
|
||||
// per request from whatever the token happens to see first.
|
||||
|
||||
import { getActiveInvoiceshelfCredentials } from '../db';
|
||||
|
||||
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||
export type UpstreamTarget = { base: string; token: string; companyId: number | null };
|
||||
|
||||
/**
|
||||
* A stored account, which is where every real call goes.
|
||||
*
|
||||
* `id` and `label` ride along because the owner can have several accounts registered and only one selected:
|
||||
* a probe has to be recorded against the row it actually reached, and a log line saying which books answered
|
||||
* is the difference between "invoices is broken" and "you are looking at the other company".
|
||||
*/
|
||||
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
|
||||
|
||||
/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */
|
||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
// A list view is a burst of requests and each one needs the token, so the row is cached rather than re-read
|
||||
// per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql, which
|
||||
// then takes effect within a minute instead of needing a restart.
|
||||
const TTL_MS = 60_000;
|
||||
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||
|
||||
/**
|
||||
* The owner's SELECTED account, or null when /invoices has no account yet — the sidecar then answers 503,
|
||||
* and the UI turns that into the setup form rather than a screen of empty tables.
|
||||
*/
|
||||
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||
const hit = cache.get(userId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||
|
||||
const creds = await getActiveInvoiceshelfCredentials(userId);
|
||||
const cfg = creds
|
||||
? {
|
||||
id: creds.id,
|
||||
label: creds.label,
|
||||
base: normalizeBase(creds.url),
|
||||
token: creds.token,
|
||||
companyId: creds.companyId,
|
||||
}
|
||||
: null;
|
||||
cache.set(userId, { cfg, at: Date.now() });
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
|
||||
export function invalidateConfig(userId: number): void {
|
||||
cache.delete(userId);
|
||||
hashCache.clear();
|
||||
}
|
||||
|
||||
export type Company = { id: number; name: string | null; unique_hash?: string | null };
|
||||
|
||||
/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
|
||||
export class UpstreamError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade an email and password for a Sanctum token.
|
||||
*
|
||||
* InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the
|
||||
* controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer
|
||||
* required a curl command. Doing it here instead is the whole point: the password is used for this one call
|
||||
* and never stored, and only the token it returns is persisted.
|
||||
*
|
||||
* Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot
|
||||
* know whether the owner also uses it somewhere else. Old ones are revoked from InvoiceShelf, not from here.
|
||||
*/
|
||||
export async function mintToken(base: string, email: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${base}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
// No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list.
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: email, password, device_name: 'officer' }),
|
||||
redirect: 'manual',
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status);
|
||||
const payload = (await res.json()) as { token?: unknown };
|
||||
if (typeof payload.token !== 'string' || !payload.token) {
|
||||
throw new UpstreamError('sign-in returned no token', res.status);
|
||||
}
|
||||
return payload.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every company this token can act for. Asked once when an account is added, so the owner picks explicitly
|
||||
* instead of inheriting whichever one the API happens to list first.
|
||||
*
|
||||
* This is also the FIRST call that actually exercises the token: `/app/version` is an unauthenticated route,
|
||||
* so it answers 200 to a wrong token, an empty one or a revoked one. A 401 here means the token, not the URL.
|
||||
*/
|
||||
export async function listCompanies(cfg: UpstreamTarget): Promise<Company[]> {
|
||||
const res = await fetch(`${cfg.base}/api/v1/companies`, {
|
||||
headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`companies lookup failed with ${res.status}`, res.status);
|
||||
const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> };
|
||||
return (payload.data ?? [])
|
||||
.filter((c): c is { id: number; name?: string } => typeof c.id === 'number')
|
||||
.map((c) => ({ id: c.id, name: c.name ?? null }));
|
||||
}
|
||||
|
||||
// Keyed by account id, because two accounts can be two companies on one instance and the hash is per company.
|
||||
const hashCache = new Map<number, Promise<string>>();
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const cached = hashCache.get(cfg.id);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = (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) => {
|
||||
// Clear on failure so the next request retries instead of latching a transient network error forever.
|
||||
hashCache.delete(cfg.id);
|
||||
throw err;
|
||||
});
|
||||
|
||||
hashCache.set(cfg.id, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
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: UpstreamTarget, opts: CallOptions): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${cfg.token}`,
|
||||
Accept: opts.accept ?? 'application/json',
|
||||
};
|
||||
|
||||
if (opts.withCompany !== false && cfg.companyId != null) headers.company = String(cfg.companyId);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user