@faremeter/facilitator 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,41 @@
1
1
  import { getLogger } from "@faremeter/logs";
2
2
  import { Hono } from "hono";
3
3
  import * as x from "@faremeter/types/x402";
4
+ import * as x2 from "@faremeter/types/x402v2";
4
5
  import { isValidationError } from "@faremeter/types";
5
- import {} from "@faremeter/types/x402";
6
+ import { caip2ToLegacyName, legacyNameToCAIP2 } from "@faremeter/info/evm";
7
+ import { caip2ToLegacyNetworkIds, legacyNetworkIdToCAIP2, } from "@faremeter/info/solana";
6
8
  import { allSettledWithTimeout } from "./promise.js";
9
+ import { adaptRequirementsV1ToV2, adaptPayloadV1ToV2, adaptVerifyResponseV2ToV1, adaptSettleResponseV2ToV1, adaptRequirementsV2ToV1, extractResourceInfoV1, adaptSupportedKindV2ToV1, } from "./adapters.js";
7
10
  const logger = await getLogger(["faremeter", "facilitator"]);
11
+ /**
12
+ * Translate a CAIP-2 network identifier to a v1 legacy network name.
13
+ * Falls through to the original identifier if no mapping exists.
14
+ */
15
+ function translateNetwork(network) {
16
+ const evmLegacy = caip2ToLegacyName(network);
17
+ if (evmLegacy)
18
+ return evmLegacy;
19
+ const solanaLegacy = caip2ToLegacyNetworkIds(network);
20
+ const firstSolanaId = solanaLegacy?.[0];
21
+ if (firstSolanaId)
22
+ return firstSolanaId;
23
+ return network;
24
+ }
25
+ /**
26
+ * Normalize a v1 legacy network name to a CAIP-2 identifier.
27
+ * Falls through to the original identifier if no mapping exists (it may
28
+ * already be CAIP-2 or an unknown network).
29
+ */
30
+ function normalizeNetwork(network) {
31
+ const evmCaip2 = legacyNameToCAIP2(network);
32
+ if (evmCaip2)
33
+ return evmCaip2;
34
+ const solanaNetwork = legacyNetworkIdToCAIP2(network);
35
+ if (solanaNetwork)
36
+ return solanaNetwork.caip2;
37
+ return network;
38
+ }
8
39
  function summarizeRequirements({ scheme, network, asset, payTo, }) {
9
40
  return {
10
41
  scheme,
@@ -13,181 +44,371 @@ function summarizeRequirements({ scheme, network, asset, payTo, }) {
13
44
  payTo,
14
45
  };
15
46
  }
47
+ export function getClientIP(c) {
48
+ const xff = c.req.header("X-Forwarded-For");
49
+ if (xff) {
50
+ const firstIP = xff.split(",")[0]?.trim();
51
+ if (firstIP)
52
+ return firstIP;
53
+ }
54
+ return c.req.header("X-Real-IP");
55
+ }
16
56
  function processException(step, e, cb) {
17
- let msg = undefined;
18
57
  // XXX - We can do a better job of determining if it's a chain
19
58
  // error, or some other issue.
20
- if (e instanceof Error) {
21
- msg = e.message;
22
- }
23
- else {
24
- msg = `unknown error handling ${step}`;
25
- }
59
+ const msg = e instanceof Error ? e.message : `unknown error handling ${step}`;
26
60
  logger.error(`Caught exception during ${step}`, {
27
61
  exception: e,
28
62
  });
29
63
  return cb(msg);
30
64
  }
65
+ /**
66
+ * Creates a Hono router with x402 facilitator endpoints.
67
+ *
68
+ * The router provides the following endpoints:
69
+ * - POST /verify - Verify a payment without settling
70
+ * - POST /settle - Verify and settle a payment
71
+ * - POST /accepts - Get payment requirements for a resource
72
+ * - GET /supported - List supported payment schemes and networks
73
+ *
74
+ * Both v1 and v2 protocol formats are supported on all endpoints.
75
+ *
76
+ * @param args - Configuration including payment handlers and timeouts
77
+ * @returns A Hono router instance with facilitator endpoints
78
+ */
31
79
  export function createFacilitatorRoutes(args) {
32
80
  const router = new Hono();
33
- function sendVerifyError(c, status, msg) {
34
- const response = {
35
- isValid: false,
36
- };
81
+ function logRejected(results, label) {
82
+ for (const r of results) {
83
+ if (r.status === "rejected") {
84
+ const message = r.reason instanceof Error ? r.reason.message : "unknown reason";
85
+ logger.error(`failed to retrieve ${label} from facilitator handler: ${message}`, r.reason);
86
+ }
87
+ }
88
+ }
89
+ function logError(msg, stepLabel) {
37
90
  if (msg !== undefined) {
38
- response.invalidReason = msg;
39
91
  logger.error(msg);
40
92
  }
41
93
  else {
42
- logger.error("unknown error during verification");
94
+ logger.error(`unknown error during ${stepLabel}`);
43
95
  }
96
+ }
97
+ function sendVerifyErrorV1(c, status, msg) {
98
+ logError(msg, "verification");
44
99
  c.status(status);
100
+ const response = { isValid: false, payer: "" };
101
+ if (msg !== undefined) {
102
+ response.invalidReason = msg;
103
+ }
45
104
  return c.json(response);
46
105
  }
47
- router.post("/verify", async (c) => {
48
- const x402Req = x.x402VerifyRequest(await c.req.json());
49
- if (isValidationError(x402Req)) {
50
- return sendVerifyError(c, 400, `couldn't validate request: ${x402Req.summary}`);
106
+ function sendSettleErrorV1(c, status, msg) {
107
+ logError(msg, "settlement");
108
+ c.status(status);
109
+ const response = {
110
+ success: false,
111
+ payer: "",
112
+ transaction: "",
113
+ network: "",
114
+ };
115
+ if (msg !== undefined) {
116
+ response.errorReason = msg;
51
117
  }
52
- let paymentPayload = x402Req.paymentPayload;
53
- if (paymentPayload === undefined) {
54
- const decodedHeader = x.x402PaymentHeaderToPayload(x402Req.paymentHeader);
55
- if (isValidationError(decodedHeader)) {
56
- return sendVerifyError(c, 400, `couldn't validate x402 payload: ${decodedHeader.summary}`);
57
- }
58
- paymentPayload = decodedHeader;
118
+ return c.json(response);
119
+ }
120
+ // Accepts errors are always v1 format. A v2 request that fails to parse
121
+ // falls through to v1 parsing, so this path is only reached for v1 errors.
122
+ function sendAcceptsError(c, status, msg) {
123
+ logError(msg, "accepts");
124
+ c.status(status);
125
+ return c.json({ x402Version: 1, accepts: [], error: msg ?? "" });
126
+ }
127
+ function sendVerifyErrorV2(c, status, msg) {
128
+ logError(msg, "verification");
129
+ c.status(status);
130
+ const response = { isValid: false };
131
+ if (msg !== undefined) {
132
+ response.invalidReason = msg;
59
133
  }
60
- logger.debug("starting verifyment attempt for request", x402Req);
134
+ return c.json(response);
135
+ }
136
+ function sendSettleErrorV2(c, status, msg) {
137
+ logError(msg, "settlement");
138
+ c.status(status);
139
+ const response = {
140
+ success: false,
141
+ transaction: "",
142
+ network: "",
143
+ };
144
+ if (msg !== undefined) {
145
+ response.errorReason = msg;
146
+ }
147
+ return c.json(response);
148
+ }
149
+ /**
150
+ * Iterate handlers, invoking each until one returns a non-null result.
151
+ * Returns the handler result, or null if no handler matched.
152
+ * Throws on handler exceptions (caller decides how to surface the error).
153
+ */
154
+ async function tryHandlers(invoke) {
61
155
  for (const handler of args.handlers) {
62
- let t;
63
- if (handler.handleVerify === undefined) {
64
- continue;
156
+ const result = await invoke(handler);
157
+ if (result !== null) {
158
+ return result;
65
159
  }
160
+ }
161
+ return null;
162
+ }
163
+ router.post("/verify", async (c) => {
164
+ const body = await c.req.json();
165
+ // Try v2 format first
166
+ const v2Req = x2.x402VerifyRequest(body);
167
+ if (!isValidationError(v2Req)) {
168
+ const clientIP = getClientIP(c);
169
+ logger.debug("starting verification attempt for v2 request", {
170
+ ...v2Req,
171
+ clientIP,
172
+ });
173
+ let result;
66
174
  try {
67
- t = await handler.handleVerify(x402Req.paymentRequirements, paymentPayload);
175
+ result = await tryHandlers((handler) => handler.handleVerify
176
+ ? handler.handleVerify(v2Req.paymentRequirements, v2Req.paymentPayload)
177
+ : Promise.resolve(null));
68
178
  }
69
179
  catch (e) {
70
- return processException("verify", e, (msg) => sendVerifyError(c, 500, msg));
180
+ return processException("verify", e, (msg) => sendVerifyErrorV2(c, 500, msg));
71
181
  }
72
- if (t === null) {
73
- continue;
182
+ if (result === null) {
183
+ logger.warning("attempt to verify was made with no handler found, requirements summary was", summarizeRequirements(v2Req.paymentRequirements));
184
+ return sendVerifyErrorV2(c, 400, "no matching payment handler found");
74
185
  }
75
- logger.debug("facilitator handler agreed to verify and returned", t);
76
- logger.info(`${t.isValid ? "succeeded" : "failed"} verifying request`, {
77
- ...t,
78
- requirements: summarizeRequirements(x402Req.paymentRequirements),
186
+ logger.info(`${result.isValid ? "succeeded" : "failed"} verifying v2 request`, {
187
+ ...result,
188
+ requirements: summarizeRequirements(v2Req.paymentRequirements),
189
+ clientIP,
79
190
  });
80
- return c.json(t);
81
- }
82
- logger.warning("attempt to verify was made with no handler found, requirements summary was", summarizeRequirements(x402Req.paymentRequirements));
83
- return sendVerifyError(c, 400, "no matching payment handler found");
84
- });
85
- function sendSettleError(c, status, msg) {
86
- const response = {
87
- success: false,
88
- txHash: null,
89
- networkId: null,
90
- };
91
- if (msg !== undefined) {
92
- response.error = msg;
93
- logger.error(msg);
191
+ return c.json(result);
94
192
  }
95
- else {
96
- logger.error("unknown error during settlement");
193
+ // Try v1 format
194
+ const v1Req = x.x402VerifyRequest(body);
195
+ if (isValidationError(v1Req)) {
196
+ return sendVerifyErrorV1(c, 400, `couldn't validate request: ${v1Req.summary}`);
97
197
  }
98
- c.status(status);
99
- return c.json(response);
100
- }
101
- router.post("/settle", async (c) => {
102
- const x402Req = x.x402SettleRequest(await c.req.json());
103
- if (isValidationError(x402Req)) {
104
- return sendSettleError(c, 400, `couldn't validate request: ${x402Req.summary}`);
105
- }
106
- let paymentPayload = x402Req.paymentPayload;
198
+ let paymentPayload = v1Req.paymentPayload;
107
199
  if (paymentPayload === undefined) {
108
- const decodedHeader = x.x402PaymentHeaderToPayload(x402Req.paymentHeader);
200
+ const decodedHeader = x.x402PaymentHeaderToPayload(v1Req.paymentHeader);
109
201
  if (isValidationError(decodedHeader)) {
110
- return sendSettleError(c, 400, `couldn't validate x402 payload: ${decodedHeader.summary}`);
202
+ return sendVerifyErrorV1(c, 400, `couldn't validate x402 payload: ${decodedHeader.summary}`);
111
203
  }
112
204
  paymentPayload = decodedHeader;
113
205
  }
114
- logger.debug("starting settlement attempt for request", x402Req);
115
- for (const handler of args.handlers) {
116
- let t;
206
+ const clientIP = getClientIP(c);
207
+ logger.debug("starting verification attempt for v1 request", {
208
+ ...v1Req,
209
+ clientIP,
210
+ });
211
+ const v2Requirements = adaptRequirementsV1ToV2(v1Req.paymentRequirements, normalizeNetwork);
212
+ const v2Payload = adaptPayloadV1ToV2(paymentPayload, v1Req.paymentRequirements, normalizeNetwork);
213
+ let result;
214
+ try {
215
+ result = await tryHandlers((handler) => handler.handleVerify
216
+ ? handler.handleVerify(v2Requirements, v2Payload)
217
+ : Promise.resolve(null));
218
+ }
219
+ catch (e) {
220
+ return processException("verify", e, (msg) => sendVerifyErrorV1(c, 500, msg));
221
+ }
222
+ if (result === null) {
223
+ logger.warning("attempt to verify was made with no handler found, requirements summary was", summarizeRequirements(v2Requirements));
224
+ return sendVerifyErrorV1(c, 400, "no matching payment handler found");
225
+ }
226
+ logger.info(`${result.isValid ? "succeeded" : "failed"} verifying v1 request`, {
227
+ ...result,
228
+ requirements: summarizeRequirements(v2Requirements),
229
+ clientIP,
230
+ });
231
+ return c.json(adaptVerifyResponseV2ToV1(result));
232
+ });
233
+ router.post("/settle", async (c) => {
234
+ const body = await c.req.json();
235
+ // Try v2 format first
236
+ const v2Req = x2.x402SettleRequest(body);
237
+ if (!isValidationError(v2Req)) {
238
+ const clientIP = getClientIP(c);
239
+ logger.debug("starting settlement attempt for v2 request", {
240
+ ...v2Req,
241
+ clientIP,
242
+ });
243
+ let result;
117
244
  try {
118
- t = await handler.handleSettle(x402Req.paymentRequirements, paymentPayload);
245
+ result = await tryHandlers((handler) => handler.handleSettle(v2Req.paymentRequirements, v2Req.paymentPayload));
119
246
  }
120
247
  catch (e) {
121
- return processException("settle", e, (msg) => sendSettleError(c, 500, msg));
248
+ return processException("settle", e, (msg) => sendSettleErrorV2(c, 500, msg));
122
249
  }
123
- if (t === null) {
124
- continue;
250
+ if (result === null) {
251
+ logger.warning("attempt to settle was made with no handler found, requirements summary was", summarizeRequirements(v2Req.paymentRequirements));
252
+ return sendSettleErrorV2(c, 400, "no matching payment handler found");
125
253
  }
126
- logger.debug("facilitator handler accepted settlement and returned", t);
127
- logger.info(`${t.success ? "succeeded" : "failed"} settlement request`, {
128
- requirements: summarizeRequirements(x402Req.paymentRequirements),
129
- txHash: t.txHash,
254
+ logger.info(`${result.success ? "succeeded" : "failed"} settlement v2 request`, {
255
+ requirements: summarizeRequirements(v2Req.paymentRequirements),
256
+ transaction: result.transaction,
257
+ clientIP,
130
258
  });
131
- return c.json(t);
259
+ return c.json(result);
132
260
  }
133
- sendSettleError(c, 400, "no matching payment handler found");
134
- logger.warning("attempt to settle was made with no handler found, requirements summary was", summarizeRequirements(x402Req.paymentRequirements));
261
+ // Try v1 format
262
+ const v1Req = x.x402SettleRequest(body);
263
+ if (isValidationError(v1Req)) {
264
+ return sendSettleErrorV1(c, 400, `couldn't validate request: ${v1Req.summary}`);
265
+ }
266
+ let paymentPayload = v1Req.paymentPayload;
267
+ if (paymentPayload === undefined) {
268
+ const decodedHeader = x.x402PaymentHeaderToPayload(v1Req.paymentHeader);
269
+ if (isValidationError(decodedHeader)) {
270
+ return sendSettleErrorV1(c, 400, `couldn't validate x402 payload: ${decodedHeader.summary}`);
271
+ }
272
+ paymentPayload = decodedHeader;
273
+ }
274
+ const clientIP = getClientIP(c);
275
+ logger.debug("starting settlement attempt for v1 request", {
276
+ ...v1Req,
277
+ clientIP,
278
+ });
279
+ const v2Requirements = adaptRequirementsV1ToV2(v1Req.paymentRequirements, normalizeNetwork);
280
+ const v2Payload = adaptPayloadV1ToV2(paymentPayload, v1Req.paymentRequirements, normalizeNetwork);
281
+ let result;
282
+ try {
283
+ result = await tryHandlers((handler) => handler.handleSettle(v2Requirements, v2Payload));
284
+ }
285
+ catch (e) {
286
+ return processException("settle", e, (msg) => sendSettleErrorV1(c, 500, msg));
287
+ }
288
+ if (result === null) {
289
+ logger.warning("attempt to settle was made with no handler found, requirements summary was", summarizeRequirements(v2Requirements));
290
+ return sendSettleErrorV1(c, 400, "no matching payment handler found");
291
+ }
292
+ logger.info(`${result.success ? "succeeded" : "failed"} settlement v1 request`, {
293
+ requirements: summarizeRequirements(v2Requirements),
294
+ transaction: result.transaction,
295
+ clientIP,
296
+ });
297
+ return c.json(adaptSettleResponseV2ToV1(result, translateNetwork));
135
298
  });
136
299
  router.post("/accepts", async (c) => {
137
- const x402Req = x.x402PaymentRequiredResponse(await c.req.json());
138
- if (isValidationError(x402Req)) {
139
- return sendSettleError(c, 400, `couldn't parse required response: ${x402Req.summary}`);
140
- }
141
- const results = await allSettledWithTimeout(args.handlers.flatMap((x) => x.getRequirements(x402Req.accepts)), args.timeout?.getRequirements ?? 500);
142
- const accepts = results
143
- .filter((x) => x.status === "fulfilled")
144
- .map((x) => x.value)
300
+ const body = await c.req.json();
301
+ const clientIP = getClientIP(c);
302
+ // The /accepts request body shares the same shape as the payment required
303
+ // response (resource + accepts array), so we reuse the response validator.
304
+ const v2Req = x2.x402PaymentRequiredResponse(body);
305
+ if (!isValidationError(v2Req)) {
306
+ // Native v2 request - call handlers directly with v2 requirements
307
+ const results = await allSettledWithTimeout(args.handlers.flatMap((handler) => handler.getRequirements({
308
+ accepts: v2Req.accepts,
309
+ resource: v2Req.resource,
310
+ })), args.timeout?.getRequirements ?? 500);
311
+ const accepts = results
312
+ .filter((r) => r.status === "fulfilled")
313
+ .map((r) => r.value)
314
+ .flat();
315
+ logRejected(results, "requirements");
316
+ logger.debug(`returning ${accepts.length} accepts for v2 request`, {
317
+ accepts: accepts.map(summarizeRequirements),
318
+ clientIP,
319
+ });
320
+ c.status(200);
321
+ return c.json({
322
+ x402Version: 2,
323
+ resource: v2Req.resource,
324
+ accepts,
325
+ });
326
+ }
327
+ // Try v1 format (lenient: Coinbase's implementation omits the error field)
328
+ const v1Req = x.x402PaymentRequiredResponseLenient(body);
329
+ if (isValidationError(v1Req)) {
330
+ return sendAcceptsError(c, 400, `couldn't parse required response: ${v1Req.summary}`);
331
+ }
332
+ // Adapt v1 accepts to v2, normalizing legacy network names to CAIP-2
333
+ const v2Accepts = v1Req.accepts.map((req) => adaptRequirementsV1ToV2(req, normalizeNetwork));
334
+ const resourceInfo = v1Req.accepts[0]
335
+ ? extractResourceInfoV1(v1Req.accepts[0])
336
+ : { url: "" };
337
+ const results = await allSettledWithTimeout(args.handlers.flatMap((handler) => handler.getRequirements({
338
+ accepts: v2Accepts,
339
+ resource: resourceInfo,
340
+ })), args.timeout?.getRequirements ?? 500);
341
+ const v2Results = results
342
+ .filter((r) => r.status === "fulfilled")
343
+ .map((r) => r.value)
145
344
  .flat();
146
- results.forEach((x) => {
147
- if (x.status === "rejected") {
148
- let message;
149
- if (x.reason instanceof Error) {
150
- message = x.reason.message;
151
- }
152
- else {
153
- message = "unknown reason";
154
- }
155
- logger.error(`failed to retrieve requirements from facilitator handler: ${message}`, x.reason);
156
- }
157
- });
158
- logger.debug(`returning ${accepts.length} accepts`, {
159
- accepts: accepts.map(summarizeRequirements),
345
+ logRejected(results, "requirements");
346
+ // Adapt v2 results back to v1 format
347
+ const accepts = v2Results.map((r) => adaptRequirementsV2ToV1(r, resourceInfo, translateNetwork));
348
+ logger.debug(`returning ${accepts.length} accepts for v1 request`, {
349
+ accepts: accepts.map((a) => ({
350
+ scheme: a.scheme,
351
+ network: a.network,
352
+ asset: a.asset,
353
+ payTo: a.payTo,
354
+ })),
355
+ clientIP,
160
356
  });
161
357
  c.status(200);
162
358
  return c.json({
163
359
  x402Version: 1,
164
360
  accepts,
361
+ error: "",
165
362
  });
166
363
  });
167
364
  router.get("/supported", async (c) => {
168
- const results = await allSettledWithTimeout(args.handlers.flatMap((x) => (x.getSupported ? x.getSupported() : [])), args.timeout?.getSupported ?? 500);
169
- const kinds = results
170
- .filter((x) => x.status === "fulfilled")
171
- .map((x) => x.value)
365
+ const clientIP = getClientIP(c);
366
+ const results = await allSettledWithTimeout(args.handlers.flatMap((handler) => handler.getSupported ? handler.getSupported() : []), args.timeout?.getSupported ?? 500);
367
+ const v2Kinds = results
368
+ .filter((r) => r.status === "fulfilled")
369
+ .map((r) => r.value)
172
370
  .flat();
173
- results.forEach((x) => {
174
- if (x.status === "rejected") {
175
- let message;
176
- if (x.reason instanceof Error) {
177
- message = x.reason.message;
371
+ logRejected(results, "supported");
372
+ // Aggregate signers from handlers
373
+ const signers = {};
374
+ for (const handler of args.handlers) {
375
+ if (handler.getSigners) {
376
+ try {
377
+ const handlerSigners = await handler.getSigners();
378
+ for (const [network, addresses] of Object.entries(handlerSigners)) {
379
+ if (signers[network]) {
380
+ // Merge addresses, avoiding duplicates
381
+ const existing = new Set(signers[network]);
382
+ for (const addr of addresses) {
383
+ if (!existing.has(addr)) {
384
+ signers[network].push(addr);
385
+ }
386
+ }
387
+ }
388
+ else {
389
+ signers[network] = [...addresses];
390
+ }
391
+ }
178
392
  }
179
- else {
180
- message = "unknown reason";
393
+ catch (e) {
394
+ logger.error("failed to retrieve signers from facilitator handler", {
395
+ error: e,
396
+ });
181
397
  }
182
- logger.error(`failed to retrieve supported from facilitator handler: ${message}`, x.reason);
183
398
  }
184
- });
185
- logger.debug(`returning ${kinds.length} kinds supported`, {
186
- kinds,
399
+ }
400
+ // Advertise both v1 and v2 support for all kinds
401
+ const v1Kinds = v2Kinds.map((k) => adaptSupportedKindV2ToV1(k, translateNetwork));
402
+ const allKinds = [...v1Kinds, ...v2Kinds];
403
+ logger.debug(`returning ${allKinds.length} kinds supported`, {
404
+ kinds: allKinds,
405
+ clientIP,
187
406
  });
188
407
  c.status(200);
189
408
  return c.json({
190
- kinds,
409
+ kinds: allKinds,
410
+ extensions: [],
411
+ signers,
191
412
  });
192
413
  });
193
414
  return router;
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env pnpm tsx
2
+ export {};
3
+ //# sourceMappingURL=routes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.test.d.ts","sourceRoot":"","sources":["../../src/routes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env pnpm tsx
2
+ import t from "tap";
3
+ import { getClientIP } from "./routes.js";
4
+ function mockContext(headers) {
5
+ return {
6
+ req: {
7
+ header: (key) => headers[key],
8
+ },
9
+ };
10
+ }
11
+ await t.test("getClientIP returns first IP from X-Forwarded-For", async (t) => {
12
+ const c = mockContext({
13
+ "X-Forwarded-For": "192.168.1.1, 10.0.0.1, 172.16.0.1",
14
+ });
15
+ t.equal(getClientIP(c), "192.168.1.1");
16
+ t.end();
17
+ });
18
+ await t.test("getClientIP trims whitespace from X-Forwarded-For", async (t) => {
19
+ const c = mockContext({
20
+ "X-Forwarded-For": " 192.168.1.1 , 10.0.0.1",
21
+ });
22
+ t.equal(getClientIP(c), "192.168.1.1");
23
+ t.end();
24
+ });
25
+ await t.test("getClientIP returns single IP from X-Forwarded-For", async (t) => {
26
+ const c = mockContext({
27
+ "X-Forwarded-For": "192.168.1.1",
28
+ });
29
+ t.equal(getClientIP(c), "192.168.1.1");
30
+ t.end();
31
+ });
32
+ await t.test("getClientIP falls back to X-Real-IP when X-Forwarded-For is missing", async (t) => {
33
+ const c = mockContext({
34
+ "X-Real-IP": "10.0.0.1",
35
+ });
36
+ t.equal(getClientIP(c), "10.0.0.1");
37
+ t.end();
38
+ });
39
+ await t.test("getClientIP prefers X-Forwarded-For over X-Real-IP", async (t) => {
40
+ const c = mockContext({
41
+ "X-Forwarded-For": "192.168.1.1",
42
+ "X-Real-IP": "10.0.0.1",
43
+ });
44
+ t.equal(getClientIP(c), "192.168.1.1");
45
+ t.end();
46
+ });
47
+ await t.test("getClientIP returns undefined when no proxy headers present", async (t) => {
48
+ const c = mockContext({});
49
+ t.equal(getClientIP(c), undefined);
50
+ t.end();
51
+ });
52
+ await t.test("getClientIP returns undefined for empty X-Forwarded-For", async (t) => {
53
+ const c = mockContext({
54
+ "X-Forwarded-For": "",
55
+ });
56
+ t.equal(getClientIP(c), undefined);
57
+ t.end();
58
+ });
59
+ await t.test("getClientIP returns undefined for whitespace-only X-Forwarded-For", async (t) => {
60
+ const c = mockContext({
61
+ "X-Forwarded-For": " ",
62
+ });
63
+ t.equal(getClientIP(c), undefined);
64
+ t.end();
65
+ });