/** * Reference provider for Tern Lead Feed v1, in plain node:http. It is what * the tests sync against and a working example for platforms implementing * docs/LEAD-FEED-SPEC.md: * * GET /health -> { ok: true, version: "1" } * GET /leads?updated_since&cursor&limit * bearer token check, optional X-Tern-Signature check, * order by updated_at then id, keyset cursor, errors as { error: { code, message } } * * Not exported from the package index: node:http has no place in the worker bundle. */ import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import type { IncomingMessage, Server, ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; export interface MockProviderFailure { path: "/health" | "/leads"; status: number; retryAfter?: string; body?: unknown; /** Send a Location header, for redirect tests. */ location?: string; } export interface MockProviderOptions { leads: Array>; token: string; /** When set, every request must carry a valid X-Tern-Timestamp and X-Tern-Signature. */ signingSecret?: string; /** Signature freshness window in seconds (default 300). */ toleranceSeconds?: number; feedName?: string; /** Answered in order, one per matching request, before normal handling. */ failures?: MockProviderFailure[]; now?: () => Date; } export interface RecordedRequest { method: string; path: string; query: Record; headers: Record; status: number; } export interface MockProvider { url: string; requests: RecordedRequest[]; options: MockProviderOptions; close(): Promise; } interface CursorPosition { /** updated_at of the last lead on the previous page. */ u: string; /** id of the last lead on the previous page. */ i: string; } const encodeCursor = (p: CursorPosition): string => Buffer.from(JSON.stringify(p)).toString("base64url"); function decodeCursor(raw: string): CursorPosition | null { try { const p = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Partial; return typeof p.u === "string" && typeof p.i === "string" ? { u: p.u, i: p.i } : null; } catch { return null; } } /** Order by updated_at ascending, then id: the order the spec requires. */ export function compareLeads(a: Record, b: Record): number { const ta = Date.parse(String(a["updated_at"])); const tb = Date.parse(String(b["updated_at"])); if (ta !== tb) return ta - tb; return String(a["id"]).localeCompare(String(b["id"])); } function send(res: ServerResponse, status: number, body: unknown, headers: Record = {}): number { res.writeHead(status, { "content-type": "application/json", ...headers }); res.end(body === undefined ? "" : JSON.stringify(body)); return status; } const fail = (res: ServerResponse, status: number, code: string, message: string) => send(res, status, { error: { code, message } }); function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); } /** Provider side check of Tern's request signature. */ export function verifyTernSignature( secret: string, method: string, pathAndQuery: string, timestamp: string | undefined, signature: string | undefined, nowSeconds: number, toleranceSeconds = 300, ): boolean { if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false; if (Math.abs(nowSeconds - Number.parseInt(timestamp, 10)) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${method}.${pathAndQuery}`).digest("hex"); return safeEqual(signature.toLowerCase(), expected); } function handle(opts: MockProviderOptions, req: IncomingMessage, res: ServerResponse): number { const url = new URL(req.url ?? "/", "http://mock.local"); const now = opts.now?.() ?? new Date(); const failure = opts.failures?.findIndex((f) => f.path === url.pathname) ?? -1; if (failure !== -1 && opts.failures) { const [f] = opts.failures.splice(failure, 1); if (f) { const headers: Record = {}; if (f.retryAfter !== undefined) headers["retry-after"] = f.retryAfter; if (f.location) headers["location"] = f.location; return send(res, f.status, f.body ?? { error: { code: "injected", message: `injected ${f.status}` } }, headers); } } if (req.method !== "GET") return fail(res, 405, "method_not_allowed", "Only GET is supported"); const auth = req.headers["authorization"] ?? ""; if (!safeEqual(auth, `Bearer ${opts.token}`)) return fail(res, 401, "unauthorized", "Missing or unknown token"); if (opts.signingSecret) { const ok = verifyTernSignature( opts.signingSecret, req.method, `${url.pathname}${url.search}`, req.headers["x-tern-timestamp"] as string | undefined, req.headers["x-tern-signature"] as string | undefined, Math.floor(now.getTime() / 1000), opts.toleranceSeconds, ); if (!ok) return fail(res, 401, "bad_signature", "X-Tern-Signature did not verify"); } if (url.pathname === "/health") return send(res, 200, { ok: true, version: "1" }); if (url.pathname !== "/leads") return fail(res, 404, "not_found", "Unknown path"); const limitRaw = url.searchParams.get("limit"); const limit = limitRaw === null ? 100 : Number(limitRaw); if (!Number.isInteger(limit) || limit < 1 || limit > 500) return fail(res, 400, "invalid_limit", "limit must be 1 to 500"); const sinceRaw = url.searchParams.get("updated_since"); const since = sinceRaw ? Date.parse(sinceRaw) : null; if (sinceRaw && Number.isNaN(since)) return fail(res, 400, "invalid_updated_since", "updated_since must be ISO 8601"); const cursorRaw = url.searchParams.get("cursor"); const after = cursorRaw ? decodeCursor(cursorRaw) : null; if (cursorRaw && !after) return fail(res, 400, "invalid_cursor", "cursor is not recognised"); const rows = [...opts.leads] .sort(compareLeads) .filter((l) => since === null || Date.parse(String(l["updated_at"])) >= since) .filter((l) => !after || compareLeads(l, { updated_at: after.u, id: after.i }) > 0); const page = rows.slice(0, limit); const last = page[page.length - 1]; const next = rows.length > limit && last ? encodeCursor({ u: String(last["updated_at"]), i: String(last["id"]) }) : null; return send(res, 200, { data: page, next_cursor: next, feed: { name: opts.feedName ?? "Mock feed", version: "1" } }); } /** Start the mock on 127.0.0.1 with a random port. */ export async function startMockProvider(options: MockProviderOptions): Promise { const requests: RecordedRequest[] = []; const server: Server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://mock.local"); const status = handle(options, req, res); const headers: Record = {}; for (const [k, v] of Object.entries(req.headers)) headers[k] = Array.isArray(v) ? v.join(",") : v; requests.push({ method: req.method ?? "GET", path: url.pathname, query: Object.fromEntries(url.searchParams), headers, status }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}`, requests, options, close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))), }; }