hazards.ts
Language TypeScript7.8 KB216 lines
/*** The three ways Plinth will silently give you wrong data.** Each is verified against production probes and handled here rather than at* call sites, because each one fails silently: you get a plausible response with* wrong contents, not an error.*/// --- Hazard 0: read-only cannot be enforced by HTTP verb ---------------------/*** Plinth dispatches every operation through one URL with a `method` query* parameter. `orders.setStatus` is not a POST route; it is the same endpoint with* a different parameter value.** So "we only issue GET requests" enforces nothing. A plain** GET .../dispatch?api_key=...&method=orders.setStatus¬ify=1** writes to live production and can fire customer notification emails. There is* no sandbox; there is no undo.** The only enforcement that works at this layer is a method allowlist. Anything* not on this list is refused before a request is built, so a typo, a bad merge,* or a string that reached a call site from untrusted data cannot reach a write.** This is deliberately a hardcoded constant and not configurable. There is no* env var to flip.*/export const READ_METHODS = ['orders.fetch','statuses.list','customers.fetch','orders.splitShipments','products.fetch','inventory.fetch',] as const/*** Deliberately not on the allowlist: `lines.fetch`.** It is a plausible name for the API behind the Plinth report "Get order products* details", but plausible is not verified, and an allowlist containing guesses* is not an allowlist. Nothing goes on this list until a documented or observed* response proves it exists and is a getter.*/export type ReadMethod = (typeof READ_METHODS)[number]/*** Known write methods, listed so the error message can be specific about why a* refusal happened. Not approved for any integration; per the Plinth reference,* enabling them requires explicit sign-off from Meridian and the Plinth team.*/export const FORBIDDEN_METHODS = ['orders.setStatus', 'inventory.adjust'] as constexport class PlinthForbiddenMethodError extends Error {constructor(method: string) {const known = (FORBIDDEN_METHODS as readonly string[]).includes(method)super(known? `Refusing to call "${method}". Plinth write methods hit live production and ` +`orders.setStatus can email customers via notify=1. This client is read-only by ` +`construction; enabling writes requires sign-off from Meridian and the Plinth team.`: `Refusing to call unknown Plinth method "${method}". Allowed: ${READ_METHODS.join(', ')}.`,)this.name = 'PlinthForbiddenMethodError'}}/** Call this before building any request. There is no bypass. */export function assertReadMethod(method: string): asserts method is ReadMethod {if (!(READ_METHODS as readonly string[]).includes(method)) {throw new PlinthForbiddenMethodError(method)}}// --- Hazard 1: fail-open parameters -----------------------------------------/*** "Unknown/unsupported params are silently dropped, never error. Verified:* phone on customers.fetch returns the full UNFILTERED list. Request* params carry no filtering or access-control guarantee."** This is the dangerous one. Send a filter Plinth does not recognize and it does* not reject the call; it ignores the filter and returns everything. A query* you believe is scoped to one customer comes back as the whole table, and* nothing in the response says so.** Defence: allowlist parameters on the way out. Never trust the server to* reject a bad one, because it will not.*/export const GET_ORDERS_PARAMS = ['order_ref','ref_cmp','customer_ref','shop','subshops','status_code','since','until','offset','limit',] as constexport class PlinthUnknownParamError extends Error {constructor(method: string, param: string, allowed: readonly string[]) {super(`Refusing to send unknown param "${param}" to ${method}. Plinth drops unknown params ` +`silently and returns UNFILTERED results, so this would look like success and ` +`return the whole table. Allowed: ${allowed.join(', ')}.`,)this.name = 'PlinthUnknownParamError'}}export function assertKnownParams(method: string,params: Record<string, unknown>,allowed: readonly string[],): void {for (const k of Object.keys(params)) {if (params[k] === undefined) continueif (!allowed.includes(k)) throw new PlinthUnknownParamError(method, k, allowed)}}// --- Hazard 2: PHP collection type switching --------------------------------/*** "Empty collections arrive as [] but populated ones as objects keyed '1'..'N'.* Both forms can occur within one response (PHP artifact)."** So `order.lines` is `{"1": {...}, "2": {...}}` when there are lines and* `[]` when there are none, and `order.shipments` can be the other form in the* same object. Anything doing `.map()` on these breaks on empty, and anything* doing `Object.values()` gets array indices as keys on populated ones, which* happens to work, but only by accident.*/export function phpCollection<T>(value: unknown): T[] {if (value === null || value === undefined) return []if (Array.isArray(value)) return value as T[]if (typeof value === 'object') return Object.values(value as Record<string, T>)return []}// --- Hazard 3: unstable numeric typing --------------------------------------/*** "Numeric-looking fields return as strings in some records and numbers in* others; per-field JSON type is not guaranteed."** Live example: tax_total arrives as "0.000000" on one order and "0.00" on the* next. unit_weight is "2.724480" (string) while ship_weight is 2.72* (number). Comparing or summing these without coercion produces string* concatenation, which is the kind of bug that shows up as a revenue figure with* eleven digits.*/export function num(value: unknown, fallback = 0): number {if (typeof value === 'number') return Number.isFinite(value) ? value : fallbackif (typeof value === 'string') {const n = Number(value.trim())return Number.isFinite(n) ? n : fallback}return fallback}/** Null or empty string both mean absent on Plinth. Documented for purchase_order,* press_name, proof_approver, promo_code. Assume it applies everywhere. */export function str(value: unknown): string | null {if (typeof value !== 'string') return nullconst t = value.trim()return t.length ? t : null}// --- Dates ------------------------------------------------------------------/*** "yyyymmdd, no separators (ISO rejected with {error: 'Bad since date'})"* Note the month-before-day order: yyyyddmm silently produces a different window* rather than an error, for any day <= 12.*/export function toPlinthDate(iso: string): string {const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)if (!m) throw new Error(`Expected YYYY-MM-DD, got "${iso}"`)const [, y, mo, d] = mreturn `${y}${mo}${d}`}// --- Errors -----------------------------------------------------------------/*** Plinth returns {"error": "Invalid request"} for most failures, including no results.** This means an empty result set and a malformed request are indistinguishable.* Callers must treat "Invalid request" as "no rows, possibly", never as "your* request was wrong", and must never retry it as if it were transient.*/export const AMBIGUOUS_ERRORS = ['Invalid request'] as constexport interface PlinthErrorBody {error?: stringnotice?: string}export function readError(body: unknown): string | null {if (!body || typeof body !== 'object') return nullconst b = body as PlinthErrorBodyreturn b.error ?? b.notice ?? null}/** True when the error is indistinguishable from an empty result. */export function isEmptyOrInvalid(body: unknown): boolean {const e = readError(body)return e !== null && (AMBIGUOUS_ERRORS as readonly string[]).includes(e)}