Files
invoices/sidecar/config.ts
T
officer 6c70eb8eb3 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.
2026-08-19 03:47:44 +00:00

327 lines
13 KiB
TypeScript

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);
}