diff --git a/src/__tests__/linq/httpClient.test.ts b/src/__tests__/linq/httpClient.test.ts new file mode 100644 index 000000000..e46d8dd7f --- /dev/null +++ b/src/__tests__/linq/httpClient.test.ts @@ -0,0 +1,383 @@ +/** + * 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, sep } 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) => ({ + // 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"), + })); + + // 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 ───────────────────────────────────────────────────────────