From 9bf964e1790a0a5b80c2a781b404314ac27a57ab Mon Sep 17 00:00:00 2001 From: Jemimah Ekong Date: Sat, 17 Oct 2026 19:35:35 +0100 Subject: [PATCH 1/2] refactor(linq): one HTTP client for the provider, used by every rail (W078) client.ts, onramp.ts and bills.ts each had their own call() against the same provider, with the same key and base URL, and they had drifted: which fields an error was read from, whether a plain-text body survived, whether `code` was parsed at all. For a day the KYC screen could only say "rejected the request" because of it. src/linq/http.ts now owns the auth header, timeouts and deadlines, JSON and non-JSON error parsing, code extraction, the 429 retry and failure logging. Per-call differences are arguments: timeoutMs for the NIN check, rateLimitRetries for the offramp. LinqError and retryOnProviderFailure move there and are re-exported from client.ts, so no import changes. Where the copies disagreed, the most complete behaviour won (details in the PR). Two latent bugs fixed on the way: the NIN check's 30s timeout was being capped at 15s, and a missing API key on the onramp/bills rails reported as "Could not reach Linq". Logged response bodies now redact digit runs of 7+. src/__tests__/linq/ passes with no assertion changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz --- src/__tests__/linq/httpClient.test.ts | 378 ++++++++++++++++++++++++++ src/linq/bills.ts | 82 +----- src/linq/client.ts | 204 +------------- src/linq/http.ts | 361 ++++++++++++++++++++++++ src/linq/onramp.ts | 194 +------------ 5 files changed, 758 insertions(+), 461 deletions(-) create mode 100644 src/__tests__/linq/httpClient.test.ts create mode 100644 src/linq/http.ts diff --git a/src/__tests__/linq/httpClient.test.ts b/src/__tests__/linq/httpClient.test.ts new file mode 100644 index 000000000..2618f6846 --- /dev/null +++ b/src/__tests__/linq/httpClient.test.ts @@ -0,0 +1,378 @@ +/** + * W078 — one HTTP client for Linq. + * + * `client.ts`, `onramp.ts` and `bills.ts` each had their own `call()` against + * the same provider, and they drifted: one parsed `code` and kept a plain-text + * error body, one threw both away, one read only `message` and `error`. For a + * day the KYC screen could only say "rejected the request" because of it. + * + * The existing suites in this directory pin each rail's error shapes and pass + * untouched. These pin what only a single client can promise: + * + * - exactly one implementation reaches the provider; + * - every provider call, on every rail, carries the provider's `code`; + * - every API route forwards that `code` to its caller; + * - a short plain-text body survives, and an HTML page does not — everywhere; + * - the per-call differences (timeout, 429 retry) are arguments that work. + * + * `global.fetch` is a stub in every test. The naira rails have no sandbox. + */ +import { readdirSync, readFileSync, statSync } from "fs"; +import { join } from "path"; +import express from "express"; +import request from "supertest"; + +jest.mock("../../db", () => ({ + prisma: { + offrampOrder: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(), + update: jest.fn(), + }, + }, +})); +jest.mock("../../linq/ngnOrders", () => ({ + recordNgnOrder: jest.fn().mockResolvedValue(undefined), + findNgnOrder: jest.fn().mockResolvedValue(null), +})); + +import { LinqError, linqCall, loggableBody } from "../../linq/http"; +import * as client from "../../linq/client"; +import * as onramp from "../../linq/onramp"; +import * as bills from "../../linq/bills"; +import { createNgnRouter, __clearNgnRateCache } from "../../api/ngn"; +import { createOfframpRouter } from "../../api/offramp"; +import { networkMiddleware } from "../../middleware/network"; + +const G = "GBUO4RL4RTGRFSUDUMRFMC75EWYCRTX5OE3PBIXJVDCZULOXQ2TKDR4Z"; +/** Plainly not a person's NIN; eleven digits only to pass the shape check. */ +const TEST_NIN = "00000000000"; + +type Stub = { ok: boolean; status: number; headers?: { get(k: string): string | null }; text(): Promise }; + +const reply = (status: number, body: string, headers: Record = {}): Stub => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, + text: async () => body, +}); +const json = (status: number, body: unknown) => reply(status, JSON.stringify(body)); + +/** Every fetch answers with `stub`. */ +function provider(stub: Stub | ((url: string) => Stub)): jest.Mock { + const mock = jest.fn(async (url: unknown) => (typeof stub === "function" ? stub(String(url)) : stub)); + global.fetch = mock as never; + return mock; +} + +beforeEach(() => { + process.env.LINQ_API_KEY = "biz_live_test"; + process.env.NETWORKS = "testnet,mainnet"; + delete process.env.LINQ_BASE_URL; + jest.restoreAllMocks(); + jest.spyOn(console, "warn").mockImplementation(() => {}); + __clearNgnRateCache(); +}); + +// ─── One implementation ───────────────────────────────────────────────────── + +describe("exactly one client reaches the provider", () => { + const SRC = join(__dirname, "../.."); + + function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (statSync(full).isDirectory()) return name === "__tests__" ? [] : sourceFiles(full); + return /\.ts$/.test(name) ? [full] : []; + }); + } + + const files = sourceFiles(SRC).map((path) => ({ + path: path.slice(SRC.length + 1), + text: readFileSync(path, "utf8"), + })); + + // The three things that make a request *to Linq*: its base URL, its host, + // and its auth header. Only the shared client may hold any of them. + it.each([ + ["the LINQ_BASE_URL variable", /LINQ_BASE_URL/], + ["the provider's host", /uselinq/], + ["the X-API-Key header", /X-API-Key/], + ])("only http.ts names %s", (_what, pattern) => { + const holders = files.filter((f) => pattern.test(f.text)).map((f) => f.path); + expect(holders).toEqual(["linq/http.ts"]); + }); + + it("has no other call() or fetch() in the Linq modules but Horizon's price feed", () => { + const linq = files.filter((f) => f.path.startsWith("linq/") && f.path !== "linq/http.ts"); + for (const f of linq) expect(f.text).not.toMatch(/async function call\b/); + const fetching = linq.filter((f) => /\bfetch\(/.test(f.text)).map((f) => f.path); + // coinPrice reads Horizon's order book, not Linq. + expect(fetching).toEqual(["linq/coinPrice.ts"]); + }); +}); + +// ─── Every provider call carries `code` ───────────────────────────────────── + +describe("every provider call carries the provider's code", () => { + const order = { customerRef: "veil_x", amountStableCoin: 10, walletAddress: G, rate: 1500, coin: "usdc" as const }; + const bill = { + customerRef: "veil_x", + billCategory: "airtime" as const, + provider: "MTN", + customerId: "08000000000", + amountNgn: 1000, + amountStableCoin: 0.66, + rate: 1500, + coin: "usdc" as const, + refundAddress: G, + }; + const offramp = { + amountNGN: 2000, + bankAccount: "0000000000", + bankCode: "058", + bankName: "Test", + accountName: "Test Person", + idempotencyKey: "k", + }; + + const CALLS: Array<[string, () => Promise]> = [ + ["client.getRate", () => client.getRate()], + ["client.verifyBank", () => client.verifyBank("058", "0000000000")], + ["client.checkStellarTrustline", () => client.checkStellarTrustline(G)], + ["client.createOfframpOrder", () => client.createOfframpOrder(offramp)], + ["client.getOfframpStatus", () => client.getOfframpStatus("ord_1")], + ["onramp.provisionCustomer", () => onramp.provisionCustomer({ customerRef: "veil_x", firstName: "A", lastName: "B", email: "a@example.com", phone: "08000000000" })], + ["onramp.getCustomerStatus", () => onramp.getCustomerStatus("veil_x")], + ["onramp.submitCustomerKyc", () => onramp.submitCustomerKyc("veil_x", TEST_NIN)], + ["onramp.getOnrampRate", () => onramp.getOnrampRate()], + ["onramp.createOnrampOrder", () => onramp.createOnrampOrder(order)], + ["onramp.getOnrampStatus", () => onramp.getOnrampStatus("veil_x", "ord_1")], + ["bills.payBill", () => bills.payBill(bill)], + ["bills.getBillStatus", () => bills.getBillStatus("veil_x", "ord_1")], + ]; + + it.each(CALLS)("%s", async (_name, run) => { + provider(json(409, { message: "Refused", code: "provider_said_no" })); + const err = await run().catch((e) => e); + expect(err).toBeInstanceOf(LinqError); + expect(err).toMatchObject({ status: 409, code: "provider_said_no", message: "Refused" }); + }); + + // `client.ts` read only `code`; `bills.ts` read none. Now every rail reads + // every key the provider has been seen to use. + it.each(["code", "errorCode", "error_code"])("reads it from `%s` on every rail", async (key) => { + for (const run of [() => client.getRate(), () => onramp.getCustomerStatus("r"), () => bills.getBillStatus("r", "o")]) { + provider(json(409, { [key]: "provider_said_no" })); + await expect(run()).rejects.toMatchObject({ code: "provider_said_no" }); + } + }); +}); + +// ─── Every route forwards `code` ──────────────────────────────────────────── + +describe("every API route forwards the provider's code", () => { + function makeApp() { + const app = express(); + app.use(express.json()); + app.use(networkMiddleware); + app.use("/ngn", createNgnRouter()); + app.use("/offramp", createOfframpRouter()); + return app; + } + + /** + * Refuse everything with a code — except the onramp rate, which the order + * routes check before placing an order, so the refusal reaches the call + * under test rather than the price check in front of it. + */ + const refuseAllBut = (allowRate: boolean) => + provider((url) => + allowRate && url.includes("/onramprate") + ? reply(200, "1500") + : json(409, { message: "Refused", code: "provider_said_no" }), + ); + + const ROUTES: Array<[string, (app: express.Express) => request.Test, boolean]> = [ + ["POST /ngn/customers", (a) => request(a).post("/ngn/customers").send({ customerRef: "r", firstName: "A", lastName: "B", email: "a@example.com", phone: "0800" }), false], + ["GET /ngn/customers/status", (a) => request(a).get("/ngn/customers/status?customerRef=r"), false], + ["POST /ngn/customers/kyc", (a) => request(a).post("/ngn/customers/kyc").send({ customerRef: "r", nin: TEST_NIN }), false], + ["GET /ngn/onramp/rate", (a) => request(a).get("/ngn/onramp/rate?coin=usdc"), false], + ["POST /ngn/onramp/orders", (a) => request(a).post("/ngn/onramp/orders").send({ customerRef: "r", amountStableCoin: 10, walletAddress: G, rate: 1500, coin: "usdc" }), true], + ["GET /ngn/onramp/orders/:id", (a) => request(a).get("/ngn/onramp/orders/ord_1?customerRef=r"), false], + ["POST /ngn/bills", (a) => request(a).post("/ngn/bills").send({ customerRef: "r", billCategory: "airtime", provider: "MTN", customerId: "0800", amountNgn: 1000, amountStableCoin: 0.66, rate: 1500, coin: "usdc", refundAddress: G }), true], + ["GET /ngn/bills/:id", (a) => request(a).get("/ngn/bills/ord_1?customerRef=r"), false], + ["GET /offramp/rate", (a) => request(a).get("/offramp/rate"), false], + ["POST /offramp/verify-bank", (a) => request(a).post("/offramp/verify-bank").send({ bankCode: "058", accountNumber: "0000000000" }), false], + ["GET /offramp/trustline", (a) => request(a).get(`/offramp/trustline?address=${G}`), false], + ["POST /offramp/orders", (a) => request(a).post("/offramp/orders").send({ amountStableCoin: 50, bankAccount: "0000000000", bankCode: "058", bankName: "Test", accountName: "Test Person", walletAddress: G, idempotencyKey: "k1" }), false], + ]; + + it.each(ROUTES)("%s", async (_name, send, allowRate) => { + const stub = refuseAllBut(allowRate); + const res = await send(makeApp()).set("x-network", "mainnet"); + expect(stub).toHaveBeenCalled(); + expect(res.status).toBe(409); + expect(res.body.code).toBe("provider_said_no"); + expect(res.body.error).toBe("Refused"); + }); +}); + +// ─── Plain text survives; HTML does not — on every rail ───────────────────── + +describe("non-JSON error bodies, the same on every rail", () => { + const RAILS: Array<[string, () => Promise]> = [ + ["client", () => client.verifyBank("058", "0000000000")], + ["onramp", () => onramp.getCustomerStatus("veil_x")], + ["bills", () => bills.getBillStatus("veil_x", "ord_1")], + ]; + + it.each(RAILS)("%s keeps a short plain-text reason, with its status", async (_rail, run) => { + provider(reply(409, "NIN already used")); + await expect(run()).rejects.toMatchObject({ status: 409, message: "NIN already used" }); + }); + + // `client.ts` used to echo any non-JSON body, markup included. + it.each(RAILS)("%s refuses to echo an HTML error page", async (_rail, run) => { + provider(reply(502, "

502 Bad Gateway

")); + const err = (await run().catch((e: unknown) => e)) as LinqError; + expect(err.status).toBe(502); + expect(err.message).toBe("Linq rejected the request"); + expect(err.message).not.toContain("<"); + }); + + it.each(RAILS)("%s refuses a plain-text body too long to be a message", async (_rail, run) => { + provider(reply(500, "x".repeat(201))); + await expect(run()).rejects.toMatchObject({ message: "Linq rejected the request" }); + }); +}); + +// ─── Per-call differences are arguments ───────────────────────────────────── + +describe("per-call options", () => { + it("retries a 429 only when the call opts in", async () => { + const limited = reply(429, "Rate limit exceeded", { "retry-after": "0" }); + + let stub = provider(limited); + await expect(linqCall("/x")).rejects.toMatchObject({ status: 429 }); + expect(stub).toHaveBeenCalledTimes(1); + + stub = provider(limited); + await expect(linqCall("/x", { rateLimitRetries: 2 })).rejects.toMatchObject({ status: 429 }); + expect(stub).toHaveBeenCalledTimes(3); + }); + + it("keeps the offramp's 429 retry, and never retries a NIN submission", async () => { + let stub = provider(reply(429, "slow down", { "retry-after": "0" })); + await expect(client.getRate()).rejects.toMatchObject({ status: 429 }); + expect(stub).toHaveBeenCalledTimes(3); + + stub = provider(reply(429, "slow down", { "retry-after": "0" })); + await expect(onramp.submitCustomerKyc("veil_x", TEST_NIN)).rejects.toMatchObject({ status: 429 }); + expect(stub).toHaveBeenCalledTimes(1); + }); + + it("reports the provider's wait on every rail", async () => { + provider(reply(429, JSON.stringify({ message: "slow" }), { "retry-after": "7" })); + await expect(bills.getBillStatus("r", "o")).rejects.toMatchObject({ retryAfterSeconds: 7 }); + }); + + describe("timeouts", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + /** A provider that never answers, but honours the abort signal. */ + function silentProvider() { + global.fetch = jest.fn( + (_url: unknown, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => + reject(Object.assign(new Error("aborted"), { name: "AbortError" })), + ); + }), + ) as never; + } + + it("gives an ordinary call fifteen seconds", async () => { + silentProvider(); + const pending = onramp.getCustomerStatus("veil_x").catch((e) => e); + await jest.advanceTimersByTimeAsync(15_001); + await expect(pending).resolves.toMatchObject({ status: 504 }); + }); + + // The old onramp client capped every timeout at its fifteen-second default + // when no deadline was passed — so the NIN check's thirty seconds, added + // because NIMC answers slowly, never applied. + it("gives a NIN check the thirty seconds it asks for", async () => { + silentProvider(); + let settled = false; + const pending = onramp + .submitCustomerKyc("veil_x", TEST_NIN) + .catch((e) => e) + .finally(() => (settled = true)); + await jest.advanceTimersByTimeAsync(15_001); + expect(settled).toBe(false); + await jest.advanceTimersByTimeAsync(15_000); + await expect(pending).resolves.toMatchObject({ status: 504 }); + }); + + it("never waits past the deadline it was given", async () => { + silentProvider(); + const pending = linqCall("/x", { timeoutMs: 30_000, deadline: Date.now() + 2_000 }).catch((e) => e); + await jest.advanceTimersByTimeAsync(2_001); + await expect(pending).resolves.toMatchObject({ status: 504 }); + }); + }); +}); + +// ─── Configuration and logging ────────────────────────────────────────────── + +describe("configuration and logging", () => { + // The onramp client built the auth header inside its network try/catch, so + // a missing key was reported as "Could not reach Linq". + it("reports a missing key as configuration (500), not as the provider being down", async () => { + delete process.env.LINQ_API_KEY; + const stub = provider(json(200, {})); + await expect(onramp.getCustomerStatus("r")).rejects.toMatchObject({ + status: 500, + message: "LINQ_API_KEY is not configured", + }); + expect(stub).not.toHaveBeenCalled(); + }); + + it("does not send the key to the public rate endpoints", async () => { + const stub = provider(reply(200, "1500")); + await onramp.getOnrampRate(); + expect(stub.mock.calls[0][1].headers).not.toHaveProperty("X-API-Key"); + }); + + it("keeps a path prefix in LINQ_BASE_URL, and the query string", async () => { + process.env.LINQ_BASE_URL = "https://linq.example/api/"; + const stub = provider(json(200, { status: "ok" })); + await onramp.getOnrampStatus("veil x", "ord_1"); + await client.getOfframpStatus("ord 2"); + expect(String(stub.mock.calls[0][0])).toBe( + "https://linq.example/api/b2b/onramp/status?customerRef=veil+x&orderId=ord_1", + ); + expect(String(stub.mock.calls[1][0])).toBe("https://linq.example/api/b2b/status?id=ord%202"); + }); + + it("never logs a request body, and redacts identifiers echoed in a response", async () => { + provider(json(422, { message: `NIN ${TEST_NIN} could not be verified` })); + await onramp.submitCustomerKyc("veil_x", TEST_NIN).catch(() => {}); + + const logged = (console.warn as jest.Mock).mock.calls.flat().join("\n"); + expect(logged).toMatch(/422 from \/b2b\/customers\/kyc/); + expect(logged).not.toContain(TEST_NIN); + expect(logged).toContain("[redacted]"); + expect(logged).not.toContain("veil_x"); + }); + + it("redacts long digit runs and truncates", () => { + expect(loggableBody("phone 08012345678, acct 0123456789, code 123")).toBe( + "phone [redacted], acct [redacted], code 123", + ); + expect(loggableBody("x".repeat(600))).toHaveLength(500); + expect(loggableBody("")).toBe("(empty)"); + }); +}); diff --git a/src/linq/bills.ts b/src/linq/bills.ts index 65f739e95..1ede1cb62 100644 --- a/src/linq/bills.ts +++ b/src/linq/bills.ts @@ -36,86 +36,14 @@ * every call moves real money. See `assertOnrampNetwork` in `./onramp`. */ -import { LinqError, retryOnProviderFailure } from "./client"; - -const BASE_URL = - process.env.LINQ_BASE_URL?.trim() || - "https://confidential-brianna-uselinq-52e2b233.koyeb.app"; - -const TIMEOUT_MS = 15_000; -const TOTAL_BUDGET_MS = 35_000; - -function apiKey(): string { - const key = process.env.LINQ_API_KEY?.trim(); - if (!key) throw new LinqError("LINQ_API_KEY is not configured", 500); - return key; -} - -type CallInit = { - method?: string; - body?: unknown; - deadline?: number; - query?: Record; -}; +import { LinqError, linqCall, retryOnProviderFailure, TOTAL_BUDGET_MS } from "./http"; /** - * One request. Nothing here logs a request body: these carry the customer's - * phone number and meter number, and an error handler that prints the body it - * failed on is how personal data reaches a log aggregator. + * Every bill request goes through the shared client in `./http`. Bodies here + * carry the customer's phone or meter number; that client never logs a + * request body, and redacts long digit runs from any response it logs. */ -async function call(path: string, init: CallInit = {}): Promise { - const { method = "GET", body, deadline, query } = init; - - const url = new URL(path, BASE_URL); - for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v); - - const remaining = deadline ? deadline - Date.now() : TIMEOUT_MS; - if (remaining <= 0) throw new LinqError("Linq did not respond in time", 504); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), Math.min(remaining, TIMEOUT_MS)); - - let response: Response; - try { - response = await fetch(url, { - method, - signal: controller.signal, - headers: { - "X-API-Key": apiKey(), - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - throw new LinqError("Linq did not respond in time", 504); - } - throw new LinqError("Could not reach Linq", 502); - } finally { - clearTimeout(timer); - } - - const text = await response.text(); - let parsed: unknown; - try { - parsed = text ? JSON.parse(text) : null; - } catch { - throw new LinqError( - response.ok ? "Linq returned a response we could not read" : "Linq rejected the request", - response.ok ? 502 : response.status, - ); - } - - if (!response.ok) { - const message = - (parsed as { message?: string; error?: string } | null)?.message ?? - (parsed as { message?: string; error?: string } | null)?.error ?? - "Linq rejected the request"; - throw new LinqError(message, response.status); - } - - return parsed as T; -} +const call = linqCall; // ─── Categories ────────────────────────────────────────────────────────────── diff --git a/src/linq/client.ts b/src/linq/client.ts index 413fde6c6..201fe9434 100644 --- a/src/linq/client.ts +++ b/src/linq/client.ts @@ -11,96 +11,13 @@ * code, unsupported coin) far better than anything we would invent. */ -const BASE_URL = - process.env.LINQ_BASE_URL?.trim() || - "https://confidential-brianna-uselinq-52e2b233.koyeb.app"; +import { LinqError, linqCall, retryOnProviderFailure, TOTAL_BUDGET_MS, type LinqCallOptions } from "./http"; -const TIMEOUT_MS = 15_000; - -export class LinqError extends Error { - constructor( - message: string, - readonly status: number, - /** - * Linq's machine-readable reason, when it sent one. - * - * `nin_already_used`, `customer_not_verified`, `customer_not_found` and so - * on. Carried because the alternative is what the clients do today: match - * on the prose in `message`, which silently starts mis-routing real users - * the first time Linq rewords an error. Optional, because older responses - * and non-JSON error bodies have none. - */ - readonly code?: string, - /** - * Seconds to wait, from a `Retry-After` header on a 429. - * - * Read because a rate limit without a number is indistinguishable from an - * outage: a tester hit a 429 creating an order and the screen could only - * say "try again in a few seconds", which was a guess. The provider knows - * the answer and sends it; nothing was reading it. - */ - readonly retryAfterSeconds?: number, - /** - * The `customerRef` Linq says this person already has. - * - * Sent on `email_already_in_use` (and on the NIN equivalent): the identity - * is already bound to a reference, and Linq returns which one rather than - * only refusing. Carried because the alternative is parsing it out of the - * prose — the message is literally - * `This email is already registered under customerRef "veil_…"`. - * - * Reusing the reference Linq names is the whole fix. Provisioning a second - * customer for the same person is what locks them out: their identity stays - * bound to the first reference and every later call made under the new one - * is refused. - */ - readonly existingCustomerRef?: string, - ) { - super(message); - this.name = "LinqError"; - } -} - -function apiKey(): string { - const key = process.env.LINQ_API_KEY?.trim(); - if (!key) throw new LinqError("LINQ_API_KEY is not configured", 500); - return key; -} - -/** A provider 429 is retried this many times before it reaches the user. */ -const RATE_LIMIT_RETRIES = 2; -const MAX_RETRY_DELAY_MS = 3_000; - -/** - * Everything one logical provider operation may spend, retries included. - * - * Without this the budgets multiplied: three 15s attempts plus two 3s waits - * is 51s inside `call`, and `retryOnProviderFailure` could run that whole - * sequence twice — about 103 seconds on an order creation. The mobile client - * gives up after 20s, so users were told "cash out is unavailable" while this - * server was still working, and the order it went on to create never appeared - * in the app. A server that answers a person has to finish before they are - * told it failed, so this ceiling stays below every client timeout. - */ -const TOTAL_BUDGET_MS = 35_000; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -type CallInit = { - method?: string; - body?: unknown; - auth?: boolean; - /** - * Absolute time (ms since epoch) this operation must be finished by. - * Defaults to {@link TOTAL_BUDGET_MS} from the first attempt. Pass one - * explicitly to share a single budget across several calls. - */ - deadline?: number; -}; -type RateLimited = LinqError & { retryAfterMs?: number }; +// Re-exported so every existing import of these from `./client` keeps working. +export { LinqError, retryOnProviderFailure } from "./http"; /** - * Every provider request, with rate limits absorbed. + * A provider 429 is retried this many times before it reaches the user. * * All Veil users share one provider key, and the provider limits it. A user * creating a cash-out got "rate limit exceeded, please wait" and succeeded on @@ -109,88 +26,11 @@ type RateLimited = LinqError & { retryAfterMs?: number }; * work was done, so repeating it (order creation included, with the same * idempotencyKey) cannot create anything twice. */ -async function call(path: string, init: CallInit = {}): Promise { - const deadline = init.deadline ?? Date.now() + TOTAL_BUDGET_MS; - - for (let attempt = 0; ; attempt++) { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - throw new LinqError("The payout service did not respond in time", 504); - } - - try { - // Never wait past the deadline, even on the first attempt: a call made - // late in a shared budget gets what is left of it, not a fresh 15s. - return await callOnce(path, init, Math.min(TIMEOUT_MS, remaining)); - } catch (err) { - if (!(err instanceof LinqError) || err.status !== 429 || attempt >= RATE_LIMIT_RETRIES) { - throw err; - } - const wait = (err as RateLimited).retryAfterMs ?? Math.min(1_000 * (attempt + 1), MAX_RETRY_DELAY_MS); - // A wait that would end after the deadline buys nothing; surface the 429. - if (Date.now() + wait >= deadline) throw err; - await sleep(wait); - } - } -} +const RATE_LIMIT_RETRIES = 2; -async function callOnce( - path: string, - init: CallInit, - timeoutMs: number = TIMEOUT_MS, -): Promise { - const { method = "GET", body, auth = true } = init; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(`${BASE_URL}${path}`, { - method, - signal: controller.signal, - headers: { - ...(auth ? { "X-API-Key": apiKey() } : {}), - ...(body ? { "Content-Type": "application/json" } : {}), - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - const text = await res.text(); - // An error body is not always JSON. A plain-text "Rate limit exceeded" - // used to throw inside JSON.parse, turning a retryable 429 into a 502 - // whose message was the parser's complaint about the text. - let parsed: unknown = {}; - try { - parsed = text ? (JSON.parse(text) as unknown) : {}; - } catch { - if (res.ok) throw new LinqError("Linq returned a malformed response", 502); - parsed = { message: text.trim() }; - } - if (!res.ok) { - const message = - (parsed as { message?: string })?.message || `Linq returned ${res.status}`; - const rawCode = (parsed as { code?: unknown })?.code; - const code = typeof rawCode === "string" && rawCode ? rawCode : undefined; - const header = res.headers?.get?.("retry-after"); - const seconds = header == null ? Number.NaN : Number(header); - // Linq names the reference an already-registered identity belongs to. - // Dropping it here is what forced the prose to be parsed downstream. - const rawExisting = (parsed as { customerRef?: unknown })?.customerRef; - const existingCustomerRef = - typeof rawExisting === "string" && rawExisting ? rawExisting : undefined; - throw Object.assign(new LinqError(message, res.status, code, undefined, existingCustomerRef), { - retryAfterMs: Number.isFinite(seconds) - ? Math.min(Math.max(0, seconds) * 1_000, MAX_RETRY_DELAY_MS) - : undefined, - }); - } - return parsed as T; - } catch (err) { - if (err instanceof LinqError) throw err; - if ((err as Error)?.name === "AbortError") { - throw new LinqError("Linq did not respond in time", 504); - } - throw new LinqError((err as Error)?.message ?? "Linq request failed", 502); - } finally { - clearTimeout(timer); - } +/** Every offramp request: the shared client, with this rail's 429 retry. */ +function call(path: string, options: LinqCallOptions = {}): Promise { + return linqCall(path, { rateLimitRetries: RATE_LIMIT_RETRIES, ...options }); } // ─── Rate ──────────────────────────────────────────────────────────────────── @@ -340,34 +180,6 @@ export async function createOfframpOrder( return retryOnProviderFailure(request, 1_500, deadline); } -/** - * One retry when the provider fails on its side: a 5xx, or no response. - * - * Order creation can fail transiently at the provider — "Wallet generation - * failed" is it not managing to mint the deposit address — and the user was - * shown that and left to try again by hand. A 4xx is about the request and - * is never retried. The retry sends the identical body, idempotencyKey - * included, so the provider can recognise it as the same order. - * - * `deadline` bounds the pair. Without it the retry doubled the caller's whole - * budget, which is how a 35s ceiling silently became 70. - */ -export async function retryOnProviderFailure( - request: () => Promise, - delayMs = 1500, - deadline?: number, -): Promise { - try { - return await request(); - } catch (err) { - if (!(err instanceof LinqError) || err.status < 500) throw err; - // No point sleeping into a deadline we cannot then do work before. - if (deadline !== undefined && Date.now() + delayMs >= deadline) throw err; - await sleep(delayMs); - return request(); - } -} - export interface OfframpStatus { id: string; status: string; diff --git a/src/linq/http.ts b/src/linq/http.ts new file mode 100644 index 000000000..9c7d7eaae --- /dev/null +++ b/src/linq/http.ts @@ -0,0 +1,361 @@ +/** + * The one HTTP client for Linq. + * + * Every request to the provider goes through {@link linqCall}. There used to be + * three — in `client.ts`, `onramp.ts` and `bills.ts` — written months apart + * against the same base URL with the same key, and they drifted in every detail + * that matters: which fields an error was read from, whether a plain-text body + * survived, whether a `code` was parsed at all. For a full day the KYC screen + * could only say "rejected the request", because the copy serving it threw + * away both halves of the provider's answer while its sibling parsed them. + * + * So this file owns everything that is the same for every call — the auth + * header, the timeout and the deadline, error parsing, `code` extraction, the + * rate-limit retry, and the failure logging — and the per-call differences are + * arguments. A NIN check that needs thirty seconds passes `timeoutMs`; it does + * not get a client of its own. + * + * ## What this never does + * + * **Log a request body.** These bodies carry a NIN, a customer's name, email + * and phone, a meter number, a bank account. An error path that prints what it + * failed on is how personal data reaches a log aggregator. Response bodies are + * logged on failure because the provider asked for exactly that — but long + * digit runs in them are redacted first, in case an error echoes an identifier + * back. + */ + +const DEFAULT_BASE_URL = "https://confidential-brianna-uselinq-52e2b233.koyeb.app"; + +/** Read per call, so a test or a redeploy that changes it is honoured. */ +function baseUrl(): string { + return (process.env.LINQ_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, ""); +} + +/** One attempt's ceiling, unless the call says otherwise. */ +export const DEFAULT_TIMEOUT_MS = 15_000; + +/** + * Everything one logical operation may spend, retries included. + * + * Without a shared ceiling the budgets multiplied — three 15s attempts plus + * waits inside a call, run twice by `retryOnProviderFailure`, was about 103 + * seconds on an order creation. The clients give up after 20–40s, so users + * were told something had failed while this server was still working on it. A + * server that answers a person has to finish before they are told it failed, + * so this stays below every client timeout. + */ +export const TOTAL_BUDGET_MS = 35_000; + +/** The longest we will sleep on a provider 429 before trying again. */ +const MAX_RETRY_DELAY_MS = 3_000; + +/** A plain-text error body longer than this is a document, not a message. */ +const MAX_PLAIN_MESSAGE = 200; + +/** How much of a failed response body to log. */ +const MAX_LOGGED_BODY = 500; + +export class LinqError extends Error { + constructor( + message: string, + readonly status: number, + /** + * Linq's machine-readable reason, when it sent one. + * + * `nin_already_used`, `customer_not_verified`, `customer_not_found` and so + * on. Carried because the alternative is what the clients did: match on + * the prose in `message`, which silently starts mis-routing real users the + * first time Linq rewords an error. Optional, because older responses and + * non-JSON error bodies have none. + */ + readonly code?: string, + /** + * Seconds to wait, from a `Retry-After` header on a 429. + * + * Read because a rate limit without a number is indistinguishable from an + * outage: a tester hit a 429 creating an order and the screen could only + * say "try again in a few seconds", which was a guess. The provider knows + * the answer and sends it. + */ + readonly retryAfterSeconds?: number, + /** + * The `customerRef` Linq says this person already has. + * + * Sent on `email_already_in_use` (and on the NIN equivalent): the identity + * is already bound to a reference, and Linq returns which one rather than + * only refusing. Carried because the alternative is parsing it out of the + * prose — the message is literally + * `This email is already registered under customerRef "veil_…"`. + */ + readonly existingCustomerRef?: string, + ) { + super(message); + this.name = "LinqError"; + } +} + +/** A provider 429 carries how long we should wait before the retry. */ +type RateLimited = LinqError & { retryAfterMs?: number }; + +export interface LinqCallOptions { + method?: string; + /** JSON-encoded when present. Never logged. */ + body?: unknown; + /** Send `X-API-Key`. False only for the public rate endpoints. */ + auth?: boolean; + /** Query parameters, appended and encoded. */ + query?: Record; + /** + * Absolute time (ms since epoch) the whole operation must finish by, + * retries included. Pass one to share a single budget across several calls. + * Defaults to {@link TOTAL_BUDGET_MS} from now. + */ + deadline?: number; + /** + * One attempt's ceiling. A NIN check reaches NIMC and routinely takes longer + * than a lookup, so it raises this; it is still capped by the deadline. + */ + timeoutMs?: number; + /** + * How many times a provider 429 is retried before it reaches the caller. + * + * A 429 is refused before any work is done, so repeating the identical + * request cannot create anything twice — but some calls must still never be + * replayed: an identity submission is rate-limited on Linq's side and + * touches their identity provider. Zero unless the call opts in. + */ + rateLimitRetries?: number; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** The first of `values` that is a non-empty string, trimmed, or undefined. */ +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function apiKey(): string { + const key = process.env.LINQ_API_KEY?.trim(); + if (!key) throw new LinqError("LINQ_API_KEY is not configured", 500); + return key; +} + +/** + * `Retry-After`, in seconds, for telling a person how long to wait. + * + * The header is either a count of seconds or an HTTP date; both are legal and + * both appear in the wild. A value that is neither, or is zero or in the past, + * is no answer at all and is treated as absent — telling someone to retry + * immediately after a rate limit is how the limit gets deeper. + */ +function retryAfterSeconds(response: Response): number | undefined { + // `headers` is optional on purpose: a real Response always has it, but the + // stubs in this repo's tests are plain objects, and a header lookup is not + // worth failing a request over. + const raw = response.headers?.get?.("retry-after"); + if (!raw) return undefined; + + const asSeconds = Number(raw.trim()); + if (Number.isFinite(asSeconds)) return asSeconds > 0 ? Math.ceil(asSeconds) : undefined; + + const asDate = Date.parse(raw); + if (!Number.isFinite(asDate)) return undefined; + const seconds = Math.ceil((asDate - Date.now()) / 1000); + return seconds > 0 ? seconds : undefined; +} + +/** + * `Retry-After`, in milliseconds, for our own retry — capped. + * + * Unlike {@link retryAfterSeconds}, a zero counts: it is the provider telling + * *us* the window has already reset, and sleeping a second anyway buys + * nothing. Undefined means "not said", and the caller backs off on its own. + */ +function retryAfterWaitMs(response: Response): number | undefined { + const raw = response.headers?.get?.("retry-after"); + if (raw == null || raw.trim() === "") return undefined; + const asSeconds = Number(raw.trim()); + if (Number.isFinite(asSeconds)) return Math.min(Math.max(0, asSeconds) * 1_000, MAX_RETRY_DELAY_MS); + const asDate = Date.parse(raw); + if (!Number.isFinite(asDate)) return undefined; + return Math.min(Math.max(0, asDate - Date.now()), MAX_RETRY_DELAY_MS); +} + +/** + * A response body made safe to log: truncated, with any run of seven or more + * digits replaced. A NIN is eleven digits, a Nigerian phone number eleven, a + * bank account ten, a meter number usually more — and a provider error that + * helpfully echoes the value it rejected would otherwise put it in our logs. + */ +export function loggableBody(text: string): string { + const trimmed = text.slice(0, MAX_LOGGED_BODY); + return trimmed.replace(/\d{7,}/g, "[redacted]") || "(empty)"; +} + +/** Join the base URL and a path that may carry its own query string. */ +function buildUrl(path: string, query?: Record): URL { + // Concatenated rather than `new URL(path, base)`, which would drop any path + // prefix in LINQ_BASE_URL when `path` starts with a slash. + const url = new URL(`${baseUrl()}${path.startsWith("/") ? path : `/${path}`}`); + for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v); + return url; +} + +/** One request, no retries. */ +async function attempt( + path: string, + options: LinqCallOptions, + timeoutMs: number, +): Promise { + const { method = "GET", body, auth = true, query } = options; + const url = buildUrl(path, query); + + // Built before the request, so a missing key is the configuration error it + // is (500), not a network failure. + const headers: Record = { + ...(auth ? { "X-API-Key": apiKey() } : {}), + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }; + + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(url, { + method, + signal: controller.signal, + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch (err) { + // Logged because without it a timeout leaves no trace at all, and "no log + // line" is indistinguishable from "the logging is not working". The + // elapsed time says whether we gave up or the connection did. + const waited = Date.now() - startedAt; + if (err instanceof Error && err.name === "AbortError") { + console.warn(`[linq] timeout after ${waited}ms calling ${path}`); + throw new LinqError("Linq did not respond in time", 504); + } + console.warn( + `[linq] unreachable after ${waited}ms calling ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + throw new LinqError("Could not reach Linq", 502); + } finally { + clearTimeout(timer); + } + + const text = await response.text(); + + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : {}; + } catch { + if (response.ok) throw new LinqError("Linq returned a response we could not read", 502); + // Not JSON. A short plain-text body usually *is* the reason — "Rate limit + // exceeded, please wait", "NIN already used" — and discarding it left the + // KYC screen with nothing to say. An HTML page is a document, not a + // message: echoing it would put a gateway's markup on a user's screen. + const plain = text.trim(); + console.warn(`[linq] ${response.status} from ${path}, non-JSON body: ${loggableBody(plain)}`); + const message = + plain && plain.length <= MAX_PLAIN_MESSAGE && !/^\s*; + // Every key a reason has been seen under. A reason that arrives under a key + // nobody reads is a reason the user never gets: the screen falls back to + // guessing, and tells someone to check a name when their NIN is the problem. + const code = firstString(body["code"], body["errorCode"], body["error_code"]); + // The code rides in the message when there is no prose, because the prose + // is what reaches the user, and a bare fallback leaves them a guess. + const message = + firstString(body["message"], body["error"], body["msg"], body["detail"], body["reason"]) ?? + (code ? `Linq rejected the request (${code})` : "Linq rejected the request"); + // Linq names the reference an already-registered identity belongs to. + const existingCustomerRef = firstString(body["customerRef"], body["customer_ref"]); + throw Object.assign( + new LinqError(message, response.status, code, retryAfterSeconds(response), existingCustomerRef), + { retryAfterMs: retryAfterWaitMs(response) }, + ); + } + + return parsed as T; +} + +/** + * Every request to Linq. + * + * Returns the parsed body, or throws {@link LinqError} carrying Linq's own + * message, code and status — their 400s name the actual problem (a bad bank + * code, an unverified customer) far better than anything we would invent. + * + * A 429 is retried only when `rateLimitRetries` says so, waiting what the + * provider asked (or a short backoff), and never past the deadline. Every other + * failure is the caller's to handle; see {@link retryOnProviderFailure} for the + * one 5xx retry the order-creating calls take. + */ +export async function linqCall(path: string, options: LinqCallOptions = {}): Promise { + const deadline = options.deadline ?? Date.now() + TOTAL_BUDGET_MS; + const perAttempt = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const retries = options.rateLimitRetries ?? 0; + + for (let tried = 0; ; tried++) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new LinqError("Linq did not respond in time", 504); + + try { + // Never wait past the deadline, even on the first attempt: a call made + // late in a shared budget gets what is left of it, not a fresh 15s. + return await attempt(path, options, Math.min(perAttempt, remaining)); + } catch (err) { + if (!(err instanceof LinqError) || err.status !== 429 || tried >= retries) throw err; + const wait = + (err as RateLimited).retryAfterMs ?? Math.min(1_000 * (tried + 1), MAX_RETRY_DELAY_MS); + // A wait that would end after the deadline buys nothing; surface the 429. + if (Date.now() + wait >= deadline) throw err; + await sleep(wait); + } + } +} + +/** + * One retry when the provider fails on its side: a 5xx, or no response. + * + * Order creation can fail transiently at the provider — "Wallet generation + * failed" is it not managing to mint the deposit address. A 4xx is about the + * request and is never retried. The retry sends the identical body, + * idempotencyKey included, so the provider can recognise it as the same order. + * + * `deadline` bounds the pair, so the retry cannot double the caller's budget. + */ +export async function retryOnProviderFailure( + request: () => Promise, + delayMs = 1500, + deadline?: number, +): Promise { + try { + return await request(); + } catch (err) { + if (!(err instanceof LinqError) || err.status < 500) throw err; + if (deadline !== undefined && Date.now() + delayMs >= deadline) throw err; + await sleep(delayMs); + return request(); + } +} diff --git a/src/linq/onramp.ts b/src/linq/onramp.ts index a13645e17..9ea9f25b4 100644 --- a/src/linq/onramp.ts +++ b/src/linq/onramp.ts @@ -38,197 +38,15 @@ * refuses that outright rather than leaving it to a caller to remember. */ -import { LinqError, retryOnProviderFailure } from "./client"; - -const BASE_URL = - process.env.LINQ_BASE_URL?.trim() || - "https://confidential-brianna-uselinq-52e2b233.koyeb.app"; - -const TIMEOUT_MS = 15_000; - -/** The first of `values` that is a non-empty string, or undefined. */ -function firstString(...values: unknown[]): string | undefined { - for (const value of values) { - if (typeof value === "string" && value.trim()) return value.trim(); - } - return undefined; -} - -/** - * One logical operation's whole budget, retries included — the same ceiling - * `client.ts` uses, and for the same reason: a server that answers a person - * has to finish before their client gives up and tells them it failed. - */ -const TOTAL_BUDGET_MS = 35_000; - -function apiKey(): string { - const key = process.env.LINQ_API_KEY?.trim(); - if (!key) throw new LinqError("LINQ_API_KEY is not configured", 500); - return key; -} - -type CallInit = { - method?: string; - body?: unknown; - auth?: boolean; - deadline?: number; - /** - * Per-attempt ceiling, when this call is slower than most by nature. - * - * The default suits a lookup. A NIN check is not one: it goes out to NIMC - * and routinely takes longer than fifteen seconds, so capping it there makes - * us abandon an answer that was on its way. - */ - timeoutMs?: number; - /** Query parameters, appended and encoded. */ - query?: Record; -}; - -/** - * `Retry-After`, in seconds, when the provider sent one. - * - * The header is either a count of seconds or an HTTP date; both are legal and - * both appear in the wild. A value that is neither, or is in the past, is no - * answer at all and is treated as absent rather than as zero — telling someone - * to retry immediately after a rate limit is how the limit gets deeper. - */ -function retryAfterSeconds(response: Response): number | undefined { - // `headers` is optional here on purpose: a real Response always has it, but - // the stubs in this repo's tests are plain objects, and a header lookup is - // not worth failing a request over. - const raw = response.headers?.get?.("retry-after"); - if (!raw) return undefined; - - const asSeconds = Number(raw.trim()); - if (Number.isFinite(asSeconds)) return asSeconds > 0 ? Math.ceil(asSeconds) : undefined; - - const asDate = Date.parse(raw); - if (!Number.isFinite(asDate)) return undefined; - const seconds = Math.ceil((asDate - Date.now()) / 1000); - return seconds > 0 ? seconds : undefined; -} +import { LinqError, linqCall, retryOnProviderFailure, TOTAL_BUDGET_MS } from "./http"; /** - * One request. Returns typed data or throws {@link LinqError} carrying Linq's - * own message, because their 400s name the actual problem — an unverified - * customer, an unsupported coin — far better than anything we would invent. - * - * Nothing in here logs a request body. The onramp bodies carry a NIN and a - * customer's name, email and phone; an exception handler that prints the body - * it failed on is how personal data ends up in a log aggregator. + * Every onramp and customer request goes through the shared client in + * `./http`, which owns auth, timeouts, error parsing and logging. Nothing here + * opts into the 429 retry: a NIN submission in particular must never be + * replayed, and the order-creating calls take `retryOnProviderFailure` instead. */ -async function call(path: string, init: CallInit = {}): Promise { - const { method = "GET", body, auth = true, deadline, timeoutMs, query } = init; - - const url = new URL(path, BASE_URL); - for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v); - - const remaining = deadline ? deadline - Date.now() : TIMEOUT_MS; - if (remaining <= 0) { - throw new LinqError("Linq did not respond in time", 504); - } - - const startedAt = Date.now(); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), Math.min(remaining, timeoutMs ?? TIMEOUT_MS)); - - let response: Response; - try { - response = await fetch(url, { - method, - signal: controller.signal, - headers: { - ...(auth ? { "X-API-Key": apiKey() } : {}), - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - } catch (err) { - // Logged for the same reason the non-2xx path is: without it a timeout - // leaves no trace at all, and "no log line" is indistinguishable from - // "the logging is not working" — which is exactly the dead end we hit - // looking for the first failure. The elapsed time is the useful part: it - // says whether we gave up or the connection did. - const waited = Date.now() - startedAt; - if (err instanceof Error && err.name === "AbortError") { - console.warn(`[linq] timeout after ${waited}ms calling ${path}`); - throw new LinqError("Linq did not respond in time", 504); - } - console.warn( - `[linq] unreachable after ${waited}ms calling ${path}: ${err instanceof Error ? err.message : String(err)}`, - ); - throw new LinqError("Could not reach Linq", 502); - } finally { - clearTimeout(timer); - } - - - const text = await response.text(); - let parsed: unknown; - try { - parsed = text ? JSON.parse(text) : null; - } catch { - // Not JSON. An HTML error page is not a message, so it is not echoed — - // but a short plain-text body usually is the reason, and discarding it - // left the KYC screen with nothing to say but a fixed string. Keep it when - // it is small enough to be a message rather than a document. - const plain = text.trim(); - if (!response.ok) { - console.warn( - `[linq] ${response.status} from ${path}, non-JSON body: ${plain.slice(0, 500) || "(empty)"}`, - ); - } - if (!response.ok && plain && plain.length <= 200 && !/^\s* | null; - // Read the same shapes `linq/client.ts` does, plus the ones seen on these - // routes. A reason that arrives under a key nobody reads is a reason the - // user never gets: the KYC screen then falls back to guessing the likeliest - // cause, and tells someone to check a name when their NIN is the problem. - const code = firstString(body?.["code"], body?.["errorCode"], body?.["error_code"]); - // The code rides in the message when there is no prose, because the prose - // is what reaches the user. Three testers in a row met the bare fallback - // "Linq rejected the request" on a NIN check, which says only that - // something went wrong and leaves the screen to guess the cause — it - // guessed a name mismatch each time. A code in the sentence is the - // difference between diagnosing the next one and asking them to try again. - const message = - firstString(body?.["message"], body?.["error"], body?.["msg"], body?.["detail"], body?.["reason"]) ?? - (code ? `Linq rejected the request (${code})` : "Linq rejected the request"); - // Linq names the reference an already-registered identity belongs to, in a - // `customerRef` field beside the code. Reading it is what lets - // `provisionCustomer` adopt the existing reference instead of stranding the - // person under a second one; the only other way to it is parsing the prose - // `This email is already registered under customerRef "veil_…"`. - const existingCustomerRef = firstString(body?.["customerRef"], body?.["customer_ref"]); - throw new LinqError( - message, - response.status, - code, - retryAfterSeconds(response), - existingCustomerRef, - ); - } - - return parsed as T; -} +const call = linqCall; // ─── Network guard ─────────────────────────────────────────────────────────── From dc10c4003c4c00dbdf71c951eb337a7acd098956 Mon Sep 17 00:00:00 2001 From: Miracle656 Date: Sun, 18 Oct 2026 10:28:32 +0100 Subject: [PATCH 2/2] test(linq): compare source paths with forward slashes on every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-client guard built its paths with `join`, which uses the platform separator, then compared them against literals written with forward slashes. On Windows every path arrived as `linq\http.ts` and compared unequal to `linq/http.ts`, so four assertions failed: - "linq/http.ts" + "linq\http.ts" The guard itself was satisfied — only the separator differed. That made this pass on CI's Linux and fail on a Windows checkout, which is the worst way for a test to be wrong: the person who can see the failure is the one least able to tell it is spurious. Normalise with `split(sep).join("/")` at the point the path is built, so every assertion below compares the same shape everywhere. src/__tests__/linq: 210 passed. tsc clean. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB --- src/__tests__/linq/httpClient.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/__tests__/linq/httpClient.test.ts b/src/__tests__/linq/httpClient.test.ts index 2618f6846..e46d8dd7f 100644 --- a/src/__tests__/linq/httpClient.test.ts +++ b/src/__tests__/linq/httpClient.test.ts @@ -18,7 +18,7 @@ * `global.fetch` is a stub in every test. The naira rails have no sandbox. */ import { readdirSync, readFileSync, statSync } from "fs"; -import { join } from "path"; +import { join, sep } from "path"; import express from "express"; import request from "supertest"; @@ -88,7 +88,12 @@ describe("exactly one client reaches the provider", () => { } const files = sourceFiles(SRC).map((path) => ({ - path: path.slice(SRC.length + 1), + // Normalise to forward slashes: `join` uses the platform separator, so on + // Windows every path below arrived as `linq\http.ts` and compared unequal + // to the `linq/http.ts` these assertions are written against. The guard was + // satisfied; only the separator differed, so this failed on a Windows + // checkout and passed on CI — the worst way for a test to be wrong. + path: path.slice(SRC.length + 1).split(sep).join("/"), text: readFileSync(path, "utf8"), }));