invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,326 +0,0 @@
|
||||
import type { Company, UpstreamTarget } from './upstream';
|
||||
import {
|
||||
createInvoiceshelfAccount,
|
||||
deleteInvoiceshelfAccount,
|
||||
getInvoiceshelfCredentials,
|
||||
listInvoiceshelfAccounts,
|
||||
recordInvoiceshelfProbe,
|
||||
setActiveInvoiceshelfAccount,
|
||||
updateInvoiceshelfAccount,
|
||||
} from 'officerdb';
|
||||
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);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../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'));
|
||||
@@ -1,322 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// 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 'officerdb';
|
||||
|
||||
/** 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