mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

hazards.ts

Language TypeScript7.8 KB216 lines

packages/plinth-client/src/hazards.ts

  1. /**
  2. * The three ways Plinth will silently give you wrong data.
  3. *
  4. * Each is verified against production probes and handled here rather than at
  5. * call sites, because each one fails silently: you get a plausible response with
  6. * wrong contents, not an error.
  7. */
  8. // --- Hazard 0: read-only cannot be enforced by HTTP verb ---------------------
  9. /**
  10. * Plinth dispatches every operation through one URL with a `method` query
  11. * parameter. `orders.setStatus` is not a POST route; it is the same endpoint with
  12. * a different parameter value.
  13. *
  14. * So "we only issue GET requests" enforces nothing. A plain
  15. *
  16. * GET .../dispatch?api_key=...&method=orders.setStatus&notify=1
  17. *
  18. * writes to live production and can fire customer notification emails. There is
  19. * no sandbox; there is no undo.
  20. *
  21. * The only enforcement that works at this layer is a method allowlist. Anything
  22. * not on this list is refused before a request is built, so a typo, a bad merge,
  23. * or a string that reached a call site from untrusted data cannot reach a write.
  24. *
  25. * This is deliberately a hardcoded constant and not configurable. There is no
  26. * env var to flip.
  27. */
  28. export const READ_METHODS = [
  29. 'orders.fetch',
  30. 'statuses.list',
  31. 'customers.fetch',
  32. 'orders.splitShipments',
  33. 'products.fetch',
  34. 'inventory.fetch',
  35. ] as const
  36. /**
  37. * Deliberately not on the allowlist: `lines.fetch`.
  38. *
  39. * It is a plausible name for the API behind the Plinth report "Get order products
  40. * details", but plausible is not verified, and an allowlist containing guesses
  41. * is not an allowlist. Nothing goes on this list until a documented or observed
  42. * response proves it exists and is a getter.
  43. */
  44. export type ReadMethod = (typeof READ_METHODS)[number]
  45. /**
  46. * Known write methods, listed so the error message can be specific about why a
  47. * refusal happened. Not approved for any integration; per the Plinth reference,
  48. * enabling them requires explicit sign-off from Meridian and the Plinth team.
  49. */
  50. export const FORBIDDEN_METHODS = ['orders.setStatus', 'inventory.adjust'] as const
  51. export class PlinthForbiddenMethodError extends Error {
  52. constructor(method: string) {
  53. const known = (FORBIDDEN_METHODS as readonly string[]).includes(method)
  54. super(
  55. known
  56. ? `Refusing to call "${method}". Plinth write methods hit live production and ` +
  57. `orders.setStatus can email customers via notify=1. This client is read-only by ` +
  58. `construction; enabling writes requires sign-off from Meridian and the Plinth team.`
  59. : `Refusing to call unknown Plinth method "${method}". Allowed: ${READ_METHODS.join(', ')}.`,
  60. )
  61. this.name = 'PlinthForbiddenMethodError'
  62. }
  63. }
  64. /** Call this before building any request. There is no bypass. */
  65. export function assertReadMethod(method: string): asserts method is ReadMethod {
  66. if (!(READ_METHODS as readonly string[]).includes(method)) {
  67. throw new PlinthForbiddenMethodError(method)
  68. }
  69. }
  70. // --- Hazard 1: fail-open parameters -----------------------------------------
  71. /**
  72. * "Unknown/unsupported params are silently dropped, never error. Verified:
  73. * phone on customers.fetch returns the full UNFILTERED list. Request
  74. * params carry no filtering or access-control guarantee."
  75. *
  76. * This is the dangerous one. Send a filter Plinth does not recognize and it does
  77. * not reject the call; it ignores the filter and returns everything. A query
  78. * you believe is scoped to one customer comes back as the whole table, and
  79. * nothing in the response says so.
  80. *
  81. * Defence: allowlist parameters on the way out. Never trust the server to
  82. * reject a bad one, because it will not.
  83. */
  84. export const GET_ORDERS_PARAMS = [
  85. 'order_ref',
  86. 'ref_cmp',
  87. 'customer_ref',
  88. 'shop',
  89. 'subshops',
  90. 'status_code',
  91. 'since',
  92. 'until',
  93. 'offset',
  94. 'limit',
  95. ] as const
  96. export class PlinthUnknownParamError extends Error {
  97. constructor(method: string, param: string, allowed: readonly string[]) {
  98. super(
  99. `Refusing to send unknown param "${param}" to ${method}. Plinth drops unknown params ` +
  100. `silently and returns UNFILTERED results, so this would look like success and ` +
  101. `return the whole table. Allowed: ${allowed.join(', ')}.`,
  102. )
  103. this.name = 'PlinthUnknownParamError'
  104. }
  105. }
  106. export function assertKnownParams(
  107. method: string,
  108. params: Record<string, unknown>,
  109. allowed: readonly string[],
  110. ): void {
  111. for (const k of Object.keys(params)) {
  112. if (params[k] === undefined) continue
  113. if (!allowed.includes(k)) throw new PlinthUnknownParamError(method, k, allowed)
  114. }
  115. }
  116. // --- Hazard 2: PHP collection type switching --------------------------------
  117. /**
  118. * "Empty collections arrive as [] but populated ones as objects keyed '1'..'N'.
  119. * Both forms can occur within one response (PHP artifact)."
  120. *
  121. * So `order.lines` is `{"1": {...}, "2": {...}}` when there are lines and
  122. * `[]` when there are none, and `order.shipments` can be the other form in the
  123. * same object. Anything doing `.map()` on these breaks on empty, and anything
  124. * doing `Object.values()` gets array indices as keys on populated ones, which
  125. * happens to work, but only by accident.
  126. */
  127. export function phpCollection<T>(value: unknown): T[] {
  128. if (value === null || value === undefined) return []
  129. if (Array.isArray(value)) return value as T[]
  130. if (typeof value === 'object') return Object.values(value as Record<string, T>)
  131. return []
  132. }
  133. // --- Hazard 3: unstable numeric typing --------------------------------------
  134. /**
  135. * "Numeric-looking fields return as strings in some records and numbers in
  136. * others; per-field JSON type is not guaranteed."
  137. *
  138. * Live example: tax_total arrives as "0.000000" on one order and "0.00" on the
  139. * next. unit_weight is "2.724480" (string) while ship_weight is 2.72
  140. * (number). Comparing or summing these without coercion produces string
  141. * concatenation, which is the kind of bug that shows up as a revenue figure with
  142. * eleven digits.
  143. */
  144. export function num(value: unknown, fallback = 0): number {
  145. if (typeof value === 'number') return Number.isFinite(value) ? value : fallback
  146. if (typeof value === 'string') {
  147. const n = Number(value.trim())
  148. return Number.isFinite(n) ? n : fallback
  149. }
  150. return fallback
  151. }
  152. /** Null or empty string both mean absent on Plinth. Documented for purchase_order,
  153. * press_name, proof_approver, promo_code. Assume it applies everywhere. */
  154. export function str(value: unknown): string | null {
  155. if (typeof value !== 'string') return null
  156. const t = value.trim()
  157. return t.length ? t : null
  158. }
  159. // --- Dates ------------------------------------------------------------------
  160. /**
  161. * "yyyymmdd, no separators (ISO rejected with {error: 'Bad since date'})"
  162. * Note the month-before-day order: yyyyddmm silently produces a different window
  163. * rather than an error, for any day <= 12.
  164. */
  165. export function toPlinthDate(iso: string): string {
  166. const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
  167. if (!m) throw new Error(`Expected YYYY-MM-DD, got "${iso}"`)
  168. const [, y, mo, d] = m
  169. return `${y}${mo}${d}`
  170. }
  171. // --- Errors -----------------------------------------------------------------
  172. /**
  173. * Plinth returns {"error": "Invalid request"} for most failures, including no results.
  174. *
  175. * This means an empty result set and a malformed request are indistinguishable.
  176. * Callers must treat "Invalid request" as "no rows, possibly", never as "your
  177. * request was wrong", and must never retry it as if it were transient.
  178. */
  179. export const AMBIGUOUS_ERRORS = ['Invalid request'] as const
  180. export interface PlinthErrorBody {
  181. error?: string
  182. notice?: string
  183. }
  184. export function readError(body: unknown): string | null {
  185. if (!body || typeof body !== 'object') return null
  186. const b = body as PlinthErrorBody
  187. return b.error ?? b.notice ?? null
  188. }
  189. /** True when the error is indistinguishable from an empty result. */
  190. export function isEmptyOrInvalid(body: unknown): boolean {
  191. const e = readError(body)
  192. return e !== null && (AMBIGUOUS_ERRORS as readonly string[]).includes(e)
  193. }