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.
203 lines
9.5 KiB
TypeScript
203 lines
9.5 KiB
TypeScript
// 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;
|
|
}
|