@reinconsole/gate 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,844 @@
1
+ import { EventEmitter } from 'events';
2
+ import { createHash } from 'crypto';
3
+ import { globMatch, isValidDecimal, GateReceipt, newId, sumDecimal, gt } from '@reinconsole/core';
4
+ import { PaymentRequirement, decimalToAtomic, atomicToDecimal } from '@reinconsole/sdk';
5
+ import { z } from 'zod';
6
+
7
+ // src/gate.ts
8
+
9
+ // src/errors.ts
10
+ var GateError = class extends Error {
11
+ constructor(code, message, options = {}) {
12
+ super(message);
13
+ this.code = code;
14
+ this.name = "GateError";
15
+ this.retryAfterSeconds = options.retryAfterSeconds;
16
+ }
17
+ code;
18
+ /** Seconds until the refused payment could be admitted (throttle codes). */
19
+ retryAfterSeconds;
20
+ };
21
+
22
+ // src/v2.ts
23
+ var CAIP2_ALIASES = {
24
+ base: "eip155:8453",
25
+ "base-sepolia": "eip155:84532",
26
+ polygon: "eip155:137",
27
+ "polygon-amoy": "eip155:80002",
28
+ bnb: "eip155:56",
29
+ bsc: "eip155:56",
30
+ solana: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
31
+ "solana-devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
32
+ };
33
+ function caip2Of(network) {
34
+ const key = network.toLowerCase();
35
+ return CAIP2_ALIASES[key] ?? key;
36
+ }
37
+ function sameNetwork(a, b) {
38
+ return caip2Of(a) === caip2Of(b);
39
+ }
40
+ function v2Requirements(requirement) {
41
+ return {
42
+ scheme: requirement.scheme,
43
+ network: caip2Of(requirement.network),
44
+ amount: requirement.maxAmountRequired,
45
+ asset: requirement.asset,
46
+ payTo: requirement.payTo,
47
+ ...requirement.maxTimeoutSeconds !== void 0 ? { maxTimeoutSeconds: requirement.maxTimeoutSeconds } : {},
48
+ ...requirement.extra ? { extra: requirement.extra } : {}
49
+ };
50
+ }
51
+ function buildPaymentRequiredV2(requirement, error) {
52
+ return {
53
+ x402Version: 2,
54
+ error,
55
+ ...requirement.resource ? {
56
+ resource: {
57
+ url: requirement.resource,
58
+ ...requirement.description ? { description: requirement.description } : {},
59
+ ...requirement.mimeType ? { mimeType: requirement.mimeType } : {}
60
+ }
61
+ } : {},
62
+ accepts: [v2Requirements(requirement)],
63
+ extensions: {}
64
+ };
65
+ }
66
+ function encodeBase64Json(value) {
67
+ return Buffer.from(JSON.stringify(value)).toString("base64");
68
+ }
69
+ var UintString = z.string().regex(/^\d+$/, "must be a decimal integer string");
70
+ var Transfer = z.object({
71
+ from: z.string().min(1),
72
+ to: z.string().min(1),
73
+ /** Atomic-unit amount, mirroring the requirement's maxAmountRequired. */
74
+ value: UintString
75
+ });
76
+ var Envelope = z.object({
77
+ x402Version: z.literal(1),
78
+ scheme: z.string(),
79
+ network: z.string(),
80
+ payload: z.record(z.unknown())
81
+ });
82
+ var AcceptedV2 = z.object({
83
+ scheme: z.string(),
84
+ network: z.string(),
85
+ amount: UintString,
86
+ asset: z.string().min(1),
87
+ payTo: z.string().min(1)
88
+ }).passthrough();
89
+ var EnvelopeV2 = z.object({
90
+ x402Version: z.literal(2),
91
+ accepted: AcceptedV2,
92
+ payload: z.record(z.unknown())
93
+ }).passthrough();
94
+ function inspectPaymentHeader(raw) {
95
+ let json;
96
+ try {
97
+ json = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
98
+ } catch {
99
+ throw new GateError("malformed_payment", "the payment header is not base64-encoded JSON");
100
+ }
101
+ const claimed = json?.x402Version;
102
+ if (claimed === 2) {
103
+ const envelope2 = EnvelopeV2.safeParse(json);
104
+ if (!envelope2.success) {
105
+ throw new GateError(
106
+ "malformed_payment",
107
+ `invalid v2 payment envelope: ${envelope2.error.message}`
108
+ );
109
+ }
110
+ const transfer2 = extractTransfer(envelope2.data.payload, "PAYMENT-SIGNATURE");
111
+ if (transfer2.value !== envelope2.data.accepted.amount) {
112
+ throw new GateError(
113
+ "malformed_payment",
114
+ `v2 envelope contradicts itself: accepted.amount ${envelope2.data.accepted.amount} vs signed value ${transfer2.value}`
115
+ );
116
+ }
117
+ return {
118
+ version: 2,
119
+ scheme: envelope2.data.accepted.scheme,
120
+ network: envelope2.data.accepted.network,
121
+ payer: transfer2.from,
122
+ to: transfer2.to,
123
+ value: transfer2.value,
124
+ envelope: json
125
+ };
126
+ }
127
+ const envelope = Envelope.safeParse(json);
128
+ if (!envelope.success) {
129
+ throw new GateError("malformed_payment", `invalid X-PAYMENT envelope: ${envelope.error.message}`);
130
+ }
131
+ const transfer = extractTransfer(envelope.data.payload, "X-PAYMENT");
132
+ return {
133
+ version: 1,
134
+ scheme: envelope.data.scheme,
135
+ network: envelope.data.network,
136
+ payer: transfer.from,
137
+ to: transfer.to,
138
+ value: transfer.value,
139
+ envelope: json
140
+ };
141
+ }
142
+ function extractTransfer(payload, headerName) {
143
+ const source = payload["authorization"] ?? payload;
144
+ const transfer = Transfer.safeParse(source);
145
+ if (!transfer.success) {
146
+ throw new GateError(
147
+ "malformed_payment",
148
+ `${headerName} carries no recognizable transfer: ${transfer.error.message}`
149
+ );
150
+ }
151
+ return transfer.data;
152
+ }
153
+ function encodeSettlementHeader(response) {
154
+ return Buffer.from(JSON.stringify(response)).toString("base64");
155
+ }
156
+
157
+ // src/rails.ts
158
+ var RailsUnreachableError = class extends Error {
159
+ constructor(message, options) {
160
+ super(message, options);
161
+ this.name = "RailsUnreachableError";
162
+ }
163
+ };
164
+ var NEVER_SENT_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]);
165
+ function isNeverSent(err) {
166
+ for (let e = err; e !== null && e !== void 0; e = e.cause) {
167
+ const code = e.code;
168
+ if (typeof code === "string" && NEVER_SENT_CODES.has(code)) return true;
169
+ if (e instanceof AggregateError && e.errors.some(isNeverSent)) return true;
170
+ }
171
+ return false;
172
+ }
173
+ function classifyTransport(err) {
174
+ if (isNeverSent(err)) {
175
+ throw new RailsUnreachableError(messageOf(err), { cause: err });
176
+ }
177
+ throw err;
178
+ }
179
+ function messageOf(err) {
180
+ return err instanceof Error ? err.message : String(err);
181
+ }
182
+ function mockFacilitatorRails(facilitator) {
183
+ return {
184
+ async verify(paymentHeader, requirement) {
185
+ try {
186
+ facilitator.verify(paymentHeader, requirement);
187
+ } catch (err) {
188
+ throw new GateError("verify_failed", messageOf(err));
189
+ }
190
+ },
191
+ async settle(paymentHeader, requirement) {
192
+ let settled;
193
+ try {
194
+ settled = facilitator.settle(paymentHeader, requirement);
195
+ } catch (err) {
196
+ throw new GateError("settle_failed", messageOf(err));
197
+ }
198
+ return {
199
+ header: settled.header,
200
+ transaction: settled.response.transaction,
201
+ network: settled.response.network,
202
+ payer: settled.response.payer
203
+ };
204
+ }
205
+ };
206
+ }
207
+ function facilitatorClientRails(client) {
208
+ const dialectRequirements = (paymentHeader, requirement) => {
209
+ const { envelope, version } = inspectPaymentHeader(paymentHeader);
210
+ return { envelope, requirements: version === 2 ? v2Requirements(requirement) : requirement };
211
+ };
212
+ return {
213
+ async verify(paymentHeader, requirement) {
214
+ const { envelope, requirements } = dialectRequirements(paymentHeader, requirement);
215
+ let verified;
216
+ try {
217
+ verified = await client.verify(envelope, requirements);
218
+ } catch (err) {
219
+ classifyTransport(err);
220
+ }
221
+ if (!verified.isValid) {
222
+ throw new GateError("verify_failed", verified.invalidReason ?? "payment verification failed");
223
+ }
224
+ },
225
+ async settle(paymentHeader, requirement) {
226
+ const { envelope, requirements } = dialectRequirements(paymentHeader, requirement);
227
+ let settled;
228
+ try {
229
+ settled = await client.settle(envelope, requirements);
230
+ } catch (err) {
231
+ classifyTransport(err);
232
+ }
233
+ if (!settled.success) {
234
+ throw new GateError("settle_failed", settled.errorReason ?? "payment settlement failed");
235
+ }
236
+ return {
237
+ header: encodeSettlementHeader(settled),
238
+ transaction: settled.transaction,
239
+ network: settled.network,
240
+ payer: settled.payer
241
+ };
242
+ }
243
+ };
244
+ }
245
+ function matchRoute(routes, method, pathname) {
246
+ return routes.find(
247
+ (route) => (route.method === void 0 || route.method.toUpperCase() === method.toUpperCase()) && globMatch(route.path, pathname)
248
+ );
249
+ }
250
+ function routeDecimals(route, defaults) {
251
+ return route.decimals ?? defaults.decimals ?? 6;
252
+ }
253
+ function requirementFor(route, defaults, resourceUrl) {
254
+ const decimals = routeDecimals(route, defaults);
255
+ const extra = {
256
+ ...defaults.extra ?? {},
257
+ ...route.extra ?? {},
258
+ // Non-standard decimals must travel with the quote or payers misprice.
259
+ ...decimals !== 6 ? { decimals } : {}
260
+ };
261
+ return PaymentRequirement.parse({
262
+ scheme: "exact",
263
+ network: route.network ?? defaults.network,
264
+ maxAmountRequired: decimalToAtomic(route.price, decimals),
265
+ resource: resourceUrl,
266
+ description: route.description ?? "",
267
+ mimeType: route.mimeType ?? "application/json",
268
+ payTo: route.payTo ?? defaults.payTo,
269
+ maxTimeoutSeconds: route.maxTimeoutSeconds ?? defaults.maxTimeoutSeconds ?? 300,
270
+ asset: route.asset ?? defaults.asset,
271
+ extra: Object.keys(extra).length > 0 ? extra : void 0
272
+ });
273
+ }
274
+
275
+ // src/stores.ts
276
+ var InMemoryGateStore = class {
277
+ seenPayments = /* @__PURE__ */ new Set();
278
+ receiptLog = [];
279
+ quotedCount = 0;
280
+ refusedCount = 0;
281
+ burnReplay(key) {
282
+ if (this.seenPayments.has(key)) return false;
283
+ this.seenPayments.add(key);
284
+ return true;
285
+ }
286
+ /**
287
+ * Release a burned slot — on the port (optional) since the rails-unreachable
288
+ * path, and still what a persist-then-cache impl uses to leave memory
289
+ * untouched when its own INSERT fails.
290
+ */
291
+ releaseReplay(key) {
292
+ this.seenPayments.delete(key);
293
+ }
294
+ appendReceipt(receipt) {
295
+ this.receiptLog.push(receipt);
296
+ }
297
+ recordQuote() {
298
+ this.quotedCount += 1;
299
+ }
300
+ recordRefusal() {
301
+ this.refusedCount += 1;
302
+ }
303
+ /** Hydration primitive for durable stores: set the resumed counter totals. */
304
+ loadCounters(quoted, refused) {
305
+ this.quotedCount = quoted;
306
+ this.refusedCount = refused;
307
+ }
308
+ receipts() {
309
+ return this.receiptLog;
310
+ }
311
+ quoted() {
312
+ return this.quotedCount;
313
+ }
314
+ refused() {
315
+ return this.refusedCount;
316
+ }
317
+ };
318
+ function validateVelocity(velocity) {
319
+ if (!Number.isFinite(velocity.windowMs) || velocity.windowMs <= 0) {
320
+ throw new TypeError(`velocity.windowMs must be a positive number, got ${velocity.windowMs}`);
321
+ }
322
+ if (velocity.maxPayments === void 0 && velocity.maxAmount === void 0 && velocity.maxAttempts === void 0) {
323
+ throw new TypeError("velocity needs at least one of maxPayments, maxAmount, maxAttempts");
324
+ }
325
+ for (const key of ["maxPayments", "maxAttempts"]) {
326
+ const value = velocity[key];
327
+ if (value !== void 0 && (!Number.isInteger(value) || value <= 0)) {
328
+ throw new TypeError(`velocity.${key} must be a positive integer, got ${value}`);
329
+ }
330
+ }
331
+ if (velocity.maxAmount !== void 0 && !isValidDecimal(velocity.maxAmount)) {
332
+ throw new TypeError(`velocity.maxAmount must be a decimal string, got ${JSON.stringify(velocity.maxAmount)}`);
333
+ }
334
+ }
335
+ var SWEEP_THRESHOLD = 1e4;
336
+ var AttemptWindow = class {
337
+ constructor(windowMs) {
338
+ this.windowMs = windowMs;
339
+ }
340
+ windowMs;
341
+ hits = /* @__PURE__ */ new Map();
342
+ /** Record a presentation at `nowMs`; returns the in-window count (incl. it). */
343
+ record(key, nowMs) {
344
+ if (this.hits.size >= SWEEP_THRESHOLD && !this.hits.has(key)) this.sweep(nowMs);
345
+ const since = nowMs - this.windowMs;
346
+ const kept = (this.hits.get(key) ?? []).filter((at) => at > since);
347
+ kept.push(nowMs);
348
+ this.hits.set(key, kept);
349
+ return kept.length;
350
+ }
351
+ /**
352
+ * Ms until the in-window count drops below `maxAttempts` — i.e. until enough
353
+ * of the oldest hits expire that one more presentation would be admitted.
354
+ */
355
+ msUntilSlot(key, nowMs, maxAttempts) {
356
+ const since = nowMs - this.windowMs;
357
+ const inWindow = (this.hits.get(key) ?? []).filter((at) => at > since);
358
+ const gatingHit = inWindow[inWindow.length - maxAttempts];
359
+ if (gatingHit === void 0) return 0;
360
+ return Math.max(1, gatingHit + this.windowMs - nowMs);
361
+ }
362
+ /** Drop keys with no in-window hits (unbounded-payer protection). */
363
+ sweep(nowMs) {
364
+ const since = nowMs - this.windowMs;
365
+ for (const [key, times] of this.hits) {
366
+ const kept = times.filter((at) => at > since);
367
+ if (kept.length === 0) this.hits.delete(key);
368
+ else this.hits.set(key, kept);
369
+ }
370
+ }
371
+ };
372
+ function payerReceiptsSince(receipts, payer, sinceMs) {
373
+ const key = payer.toLowerCase();
374
+ return receipts.filter((r) => r.payer.toLowerCase() === key && r.at.getTime() >= sinceMs);
375
+ }
376
+
377
+ // src/gate.ts
378
+ var Gate = class {
379
+ routes;
380
+ rails;
381
+ defaults;
382
+ allowPayers;
383
+ denyPayers;
384
+ screenCheck;
385
+ advertiseV2;
386
+ velocity;
387
+ attempts;
388
+ retryPolicy;
389
+ now;
390
+ bus = new EventEmitter();
391
+ store;
392
+ constructor(options) {
393
+ this.routes = options.routes;
394
+ this.rails = options.rails;
395
+ this.defaults = {
396
+ payTo: options.payTo,
397
+ network: options.network,
398
+ asset: options.asset,
399
+ decimals: options.decimals,
400
+ maxTimeoutSeconds: options.maxTimeoutSeconds,
401
+ extra: options.extra
402
+ };
403
+ this.allowPayers = options.screen?.allowPayers ? new Set(options.screen.allowPayers.map((a) => a.toLowerCase())) : void 0;
404
+ this.denyPayers = new Set((options.screen?.denyPayers ?? []).map((a) => a.toLowerCase()));
405
+ this.screenCheck = options.screen?.check;
406
+ this.advertiseV2 = options.advertiseV2 ?? false;
407
+ if (options.velocity) validateVelocity(options.velocity);
408
+ this.velocity = options.velocity;
409
+ this.attempts = options.velocity?.maxAttempts ? new AttemptWindow(options.velocity.windowMs) : void 0;
410
+ this.retryPolicy = {
411
+ attempts: options.retry?.attempts ?? 2,
412
+ backoffMs: options.retry?.backoffMs ?? 250
413
+ };
414
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
415
+ this.store = options.store ?? new InMemoryGateStore();
416
+ }
417
+ onEvent(handler) {
418
+ this.bus.on("event", handler);
419
+ }
420
+ get receipts() {
421
+ return this.store.receipts();
422
+ }
423
+ /** Drain trailing telemetry writes; throws the first failure (durable stores). */
424
+ async flush() {
425
+ await this.store.flush?.();
426
+ }
427
+ /** Telemetry writes may trail (cache-then-persist) — a store failure,
428
+ * rejected OR thrown synchronously, must surface through flush() and can
429
+ * never alter a payment outcome (a receipt write turning a SETTLED payment
430
+ * into a 500 would charge the payer and serve nothing). */
431
+ fire(write) {
432
+ try {
433
+ void Promise.resolve(write()).catch(() => void 0);
434
+ } catch {
435
+ }
436
+ }
437
+ /** The requirement a given request would be quoted (or undefined if free). */
438
+ quoteFor(method, url) {
439
+ const route = matchRoute(this.routes, method, new URL(url).pathname);
440
+ return route && requirementFor(route, this.defaults, url);
441
+ }
442
+ async handle(request) {
443
+ const url = new URL(request.url);
444
+ const method = (request.method || "GET").toUpperCase();
445
+ const route = matchRoute(this.routes, method, url.pathname);
446
+ if (!route) return { kind: "open" };
447
+ const requirement = requirementFor(route, this.defaults, request.url);
448
+ const amount = atomicToDecimal(
449
+ requirement.maxAmountRequired,
450
+ routeDecimals(route, this.defaults)
451
+ );
452
+ if (request.payment === null) {
453
+ this.fire(() => this.store.recordQuote());
454
+ this.emit({
455
+ type: "gate.quoted",
456
+ at: this.now(),
457
+ resource: url.pathname,
458
+ method,
459
+ amount,
460
+ asset: requirement.asset,
461
+ network: requirement.network
462
+ });
463
+ return {
464
+ kind: "quote",
465
+ status: 402,
466
+ body: { x402Version: 1, accepts: [requirement], error: "X-PAYMENT header is required" },
467
+ ...this.v2Quote(requirement, "PAYMENT-SIGNATURE header is required")
468
+ };
469
+ }
470
+ let payer;
471
+ try {
472
+ const payment = inspectPaymentHeader(request.payment);
473
+ payer = payment.payer;
474
+ this.checkRate(payment.payer);
475
+ this.checkConsistency(payment, requirement);
476
+ this.screenPayer(payment.payer);
477
+ this.checkVelocity(payment.payer, requirement, amount);
478
+ await this.burnReplay(request.payment);
479
+ const settlement = await this.settleThroughRails(request.payment, requirement);
480
+ const receipt = GateReceipt.parse({
481
+ id: newId("grc"),
482
+ at: this.now(),
483
+ route: route.path,
484
+ resource: url.pathname,
485
+ method,
486
+ payer: payment.payer,
487
+ payTo: requirement.payTo,
488
+ amount,
489
+ amountAtomic: requirement.maxAmountRequired,
490
+ asset: requirement.asset,
491
+ network: requirement.network,
492
+ transaction: settlement.transaction
493
+ });
494
+ this.fire(() => this.store.appendReceipt(receipt));
495
+ this.emit({ type: "gate.settled", at: receipt.at, receipt });
496
+ return { kind: "paid", receipt, settlementHeader: settlement.header };
497
+ } catch (err) {
498
+ if (!(err instanceof GateError)) throw err;
499
+ return this.refuse(err, url.pathname, requirement, payer);
500
+ }
501
+ }
502
+ stats() {
503
+ const receipts = this.store.receipts();
504
+ const revenue = {};
505
+ const routes = {};
506
+ const payers = {};
507
+ for (const receipt of receipts) {
508
+ (revenue[receipt.asset] ??= []).push(receipt.amount);
509
+ const routeLine = routes[receipt.route] ??= { settled: 0, amounts: [] };
510
+ routeLine.settled += 1;
511
+ routeLine.amounts.push(receipt.amount);
512
+ const payerLine = payers[receipt.payer.toLowerCase()] ??= { settled: 0, amounts: [] };
513
+ payerLine.settled += 1;
514
+ payerLine.amounts.push(receipt.amount);
515
+ }
516
+ const sumLines = (lines) => Object.fromEntries(
517
+ Object.entries(lines).map(([key, line]) => [
518
+ key,
519
+ { settled: line.settled, revenue: sumDecimal(line.amounts) }
520
+ ])
521
+ );
522
+ return {
523
+ quoted: this.store.quoted(),
524
+ settled: receipts.length,
525
+ refused: this.store.refused(),
526
+ revenue: Object.fromEntries(
527
+ Object.entries(revenue).map(([asset, amounts]) => [asset, sumDecimal(amounts)])
528
+ ),
529
+ routes: sumLines(routes),
530
+ payers: sumLines(payers)
531
+ };
532
+ }
533
+ emit(event) {
534
+ this.bus.emit("event", event);
535
+ }
536
+ checkConsistency(payment, requirement) {
537
+ if (payment.scheme.toLowerCase() !== requirement.scheme.toLowerCase()) {
538
+ throw new GateError(
539
+ "scheme_mismatch",
540
+ `payment scheme "${payment.scheme}" does not match the quoted "${requirement.scheme}"`
541
+ );
542
+ }
543
+ if (!sameNetwork(payment.network, requirement.network)) {
544
+ throw new GateError(
545
+ "network_mismatch",
546
+ `payment is on "${payment.network}" but the quote wants "${requirement.network}"`
547
+ );
548
+ }
549
+ if (payment.value !== requirement.maxAmountRequired) {
550
+ throw new GateError(
551
+ "amount_mismatch",
552
+ `payment of ${payment.value} does not match the quoted ${requirement.maxAmountRequired}`
553
+ );
554
+ }
555
+ if (payment.to.toLowerCase() !== requirement.payTo.toLowerCase()) {
556
+ throw new GateError(
557
+ "recipient_mismatch",
558
+ `payment pays "${payment.to}" but the quote pays "${requirement.payTo}"`
559
+ );
560
+ }
561
+ }
562
+ /**
563
+ * Presentation rate limit — first check after decode, so a hammering payer
564
+ * is shed before any further work. Every presentation counts, including
565
+ * ones that would go on to fail consistency or screening.
566
+ */
567
+ checkRate(payer) {
568
+ const max = this.velocity?.maxAttempts;
569
+ if (!max || !this.attempts) return;
570
+ const key = payer.toLowerCase();
571
+ const nowMs = this.now().getTime();
572
+ if (this.attempts.record(key, nowMs) > max) {
573
+ const retryAfterSeconds = Math.ceil(this.attempts.msUntilSlot(key, nowMs, max) / 1e3);
574
+ throw new GateError(
575
+ "rate_limited",
576
+ `payer ${payer} exceeded ${max} payment presentations per ${this.velocity.windowMs}ms`,
577
+ { retryAfterSeconds }
578
+ );
579
+ }
580
+ }
581
+ /**
582
+ * Settled-spend velocity caps, derived from receipts at check time (restart
583
+ * -safe on durable stores). Runs BEFORE the replay burn on purpose: a
584
+ * velocity refusal is temporal, not a payment defect, so the same signed
585
+ * header may come back once the window clears.
586
+ */
587
+ checkVelocity(payer, requirement, amount) {
588
+ const v = this.velocity;
589
+ if (!v || v.maxPayments === void 0 && v.maxAmount === void 0) return;
590
+ const nowMs = this.now().getTime();
591
+ const recent = payerReceiptsSince(this.store.receipts(), payer, nowMs - v.windowMs);
592
+ const oldest = recent[0];
593
+ if (v.maxPayments !== void 0 && oldest !== void 0 && recent.length >= v.maxPayments) {
594
+ const retryAfterSeconds = secondsUntil(oldest.at.getTime() + v.windowMs, nowMs);
595
+ throw new GateError(
596
+ "velocity_exceeded",
597
+ `payer ${payer} already settled ${recent.length} payments in the last ${v.windowMs}ms; the cap is ${v.maxPayments}`,
598
+ { retryAfterSeconds }
599
+ );
600
+ }
601
+ if (v.maxAmount !== void 0) {
602
+ const sameAsset = recent.filter((r) => r.asset === requirement.asset);
603
+ const oldestSameAsset = sameAsset[0];
604
+ const wouldBe = sumDecimal([...sameAsset.map((r) => r.amount), amount]);
605
+ if (gt(wouldBe, v.maxAmount)) {
606
+ const retryAfterSeconds = oldestSameAsset !== void 0 ? secondsUntil(oldestSameAsset.at.getTime() + v.windowMs, nowMs) : void 0;
607
+ throw new GateError(
608
+ "velocity_exceeded",
609
+ `settling ${amount} ${requirement.asset} would put payer ${payer} at ${wouldBe} in the last ${v.windowMs}ms; the cap is ${v.maxAmount}`,
610
+ { retryAfterSeconds }
611
+ );
612
+ }
613
+ }
614
+ }
615
+ /**
616
+ * Drive both rails legs under the retry policy (see GateRails for the error
617
+ * taxonomy). GateErrors are semantic verdicts and pass straight through.
618
+ * Transport failures:
619
+ *
620
+ * - verify (read-only): every transport failure is retried; exhausted ->
621
+ * `rails_unavailable`, and the slot is released (settle never ran).
622
+ * - settle: only RailsUnreachableError (provably never sent) is retried;
623
+ * exhausted -> `rails_unavailable` + release (still provably unsettled).
624
+ * Anything else is ambiguous — the money MAY have moved — so it refuses
625
+ * `settle_unknown` immediately and the slot STAYS burned: on real rails a
626
+ * re-present would misreport (nonce already used -> settle_failed), and on
627
+ * the mock ledger it would double-spend. Vendors reconcile ambiguous
628
+ * settles against transaction records (e.g. the on-chain indexer).
629
+ */
630
+ async settleThroughRails(paymentHeader, requirement) {
631
+ await this.railsLeg(
632
+ "verify",
633
+ paymentHeader,
634
+ () => this.rails.verify(paymentHeader, requirement)
635
+ );
636
+ return this.railsLeg(
637
+ "settle",
638
+ paymentHeader,
639
+ () => this.rails.settle(paymentHeader, requirement)
640
+ );
641
+ }
642
+ async railsLeg(leg, paymentHeader, fn) {
643
+ const { attempts, backoffMs } = this.retryPolicy;
644
+ let lastErr;
645
+ for (let attempt = 0; attempt <= attempts; attempt++) {
646
+ if (attempt > 0 && backoffMs > 0) {
647
+ await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
648
+ }
649
+ try {
650
+ return await fn();
651
+ } catch (err) {
652
+ if (err instanceof GateError) throw err;
653
+ if (leg === "settle" && !(err instanceof RailsUnreachableError)) {
654
+ throw new GateError(
655
+ "settle_unknown",
656
+ `settlement fate unknown (${messageOf2(err)}); do not re-pay before reconciling`
657
+ );
658
+ }
659
+ lastErr = err;
660
+ }
661
+ }
662
+ try {
663
+ await this.store.releaseReplay?.(replayKey(paymentHeader));
664
+ } catch {
665
+ }
666
+ throw new GateError(
667
+ "rails_unavailable",
668
+ `payment rails unreachable after ${attempts + 1} attempts (${messageOf2(lastErr)}); the payment was NOT settled`
669
+ );
670
+ }
671
+ screenPayer(payer) {
672
+ const key = payer.toLowerCase();
673
+ if (this.denyPayers.has(key)) {
674
+ throw new GateError("payer_denied", `payer ${payer} is blocked by this gate`);
675
+ }
676
+ if (this.allowPayers && !this.allowPayers.has(key)) {
677
+ throw new GateError("payer_not_allowed", `payer ${payer} is not on this gate's allowlist`);
678
+ }
679
+ const reason = this.screenCheck?.(payer);
680
+ if (reason) {
681
+ throw new GateError("payer_denied", reason);
682
+ }
683
+ }
684
+ /**
685
+ * Burn the replay slot. The store's check-and-set is sync at call time
686
+ * (concurrent copies cannot both pass); the await is the durability barrier
687
+ * — on a durable store the burn is on disk before verify/settle run, so a
688
+ * crash mid-settle cannot resurrect the slot on restart. A store WRITE
689
+ * failure is not a GateError: it escapes as a 500, because "we cannot
690
+ * guarantee replay protection" must never settle a payment.
691
+ */
692
+ async burnReplay(paymentHeader) {
693
+ if (!await this.store.burnReplay(replayKey(paymentHeader))) {
694
+ throw new GateError(
695
+ "payment_replayed",
696
+ "this exact payment was already presented; one settlement per payment"
697
+ );
698
+ }
699
+ }
700
+ refuse(err, resource, requirement, payer) {
701
+ this.fire(() => this.store.recordRefusal());
702
+ this.emit({
703
+ type: "gate.refused",
704
+ at: this.now(),
705
+ code: err.code,
706
+ reason: err.message,
707
+ resource,
708
+ payer
709
+ });
710
+ const retry = err.retryAfterSeconds !== void 0 ? { retryAfterSeconds: err.retryAfterSeconds } : {};
711
+ if (err.code === "payer_denied" || err.code === "payer_not_allowed") {
712
+ return {
713
+ kind: "refused",
714
+ status: 403,
715
+ code: err.code,
716
+ reason: err.message,
717
+ body: { error: "refused", code: err.code, reason: err.message }
718
+ };
719
+ }
720
+ if (err.code === "rate_limited" || err.code === "velocity_exceeded") {
721
+ return {
722
+ kind: "refused",
723
+ status: 429,
724
+ code: err.code,
725
+ reason: err.message,
726
+ body: { error: "refused", code: err.code, reason: err.message, ...retry },
727
+ ...retry
728
+ };
729
+ }
730
+ if (err.code === "rails_unavailable" || err.code === "settle_unknown") {
731
+ return {
732
+ kind: "refused",
733
+ status: 503,
734
+ code: err.code,
735
+ reason: err.message,
736
+ body: {
737
+ error: "refused",
738
+ code: err.code,
739
+ reason: err.message,
740
+ retriable: err.code === "rails_unavailable"
741
+ }
742
+ };
743
+ }
744
+ return {
745
+ kind: "refused",
746
+ status: 402,
747
+ code: err.code,
748
+ reason: err.message,
749
+ body: { x402Version: 1, accepts: [requirement], error: err.message },
750
+ ...this.v2Quote(requirement, err.message)
751
+ };
752
+ }
753
+ /** The v2 half of a dual-stack 402 (see advertiseV2), or nothing. */
754
+ v2Quote(requirement, error) {
755
+ if (!this.advertiseV2) return {};
756
+ return {
757
+ paymentRequiredHeader: encodeBase64Json(buildPaymentRequiredV2(requirement, error))
758
+ };
759
+ }
760
+ };
761
+ function createGate(options) {
762
+ return new Gate(options);
763
+ }
764
+ function replayKey(paymentHeader) {
765
+ return createHash("sha256").update(paymentHeader).digest("hex");
766
+ }
767
+ function messageOf2(err) {
768
+ return err instanceof Error ? err.message : String(err);
769
+ }
770
+ function secondsUntil(atMs, nowMs) {
771
+ return Math.max(1, Math.ceil((atMs - nowMs) / 1e3));
772
+ }
773
+
774
+ // src/node.ts
775
+ function gateMiddleware(gate, options = {}) {
776
+ return (req, res, next) => {
777
+ void (async () => {
778
+ const origin = options.origin ?? `http://${req.headers.host ?? "localhost"}`;
779
+ const url = new URL(req.url ?? "/", origin).toString();
780
+ const raw = req.headers["payment-signature"] ?? req.headers["x-payment"];
781
+ const payment = (Array.isArray(raw) ? raw[0] : raw) ?? null;
782
+ const outcome = await gate.handle({ method: req.method ?? "GET", url, payment });
783
+ options.onOutcome?.(outcome, req);
784
+ if (outcome.kind === "open") return next();
785
+ if (outcome.kind === "paid") {
786
+ res.setHeader("X-PAYMENT-RESPONSE", outcome.settlementHeader);
787
+ res.setHeader("PAYMENT-RESPONSE", outcome.settlementHeader);
788
+ return next();
789
+ }
790
+ res.writeHead(outcome.status, {
791
+ "content-type": "application/json",
792
+ ...outcome.paymentRequiredHeader !== void 0 ? { "payment-required": outcome.paymentRequiredHeader } : {},
793
+ ...outcome.kind === "refused" && outcome.retryAfterSeconds !== void 0 ? { "retry-after": String(outcome.retryAfterSeconds) } : {}
794
+ });
795
+ res.end(JSON.stringify(outcome.body));
796
+ })().catch((err) => {
797
+ const message = err instanceof Error ? err.message : String(err);
798
+ if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" });
799
+ res.end(JSON.stringify({ error: "internal_error", message }));
800
+ });
801
+ };
802
+ }
803
+
804
+ // src/fetch.ts
805
+ var defaultServe = () => new Response(JSON.stringify({ ok: true }), {
806
+ status: 200,
807
+ headers: { "content-type": "application/json" }
808
+ });
809
+ function createGatedFetch(gate, options = {}) {
810
+ const serve = options.serve ?? defaultServe;
811
+ return async (input, init) => {
812
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
813
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
814
+ const headers = new Headers(
815
+ init?.headers ?? (input instanceof Request ? input.headers : void 0)
816
+ );
817
+ const outcome = await gate.handle({
818
+ method,
819
+ url,
820
+ // Dual-stack: v2 payers send PAYMENT-SIGNATURE, v1 payers X-PAYMENT.
821
+ payment: headers.get("PAYMENT-SIGNATURE") ?? headers.get("X-PAYMENT")
822
+ });
823
+ if (outcome.kind === "open") return serve({ url, method });
824
+ if (outcome.kind === "paid") {
825
+ const response = await serve({ url, method });
826
+ const merged = new Headers(response.headers);
827
+ merged.set("X-PAYMENT-RESPONSE", outcome.settlementHeader);
828
+ merged.set("PAYMENT-RESPONSE", outcome.settlementHeader);
829
+ return new Response(response.body, { status: response.status, headers: merged });
830
+ }
831
+ return new Response(JSON.stringify(outcome.body), {
832
+ status: outcome.status,
833
+ headers: {
834
+ "content-type": "application/json",
835
+ ...outcome.paymentRequiredHeader !== void 0 ? { "payment-required": outcome.paymentRequiredHeader } : {},
836
+ ...outcome.kind === "refused" && outcome.retryAfterSeconds !== void 0 ? { "retry-after": String(outcome.retryAfterSeconds) } : {}
837
+ }
838
+ });
839
+ };
840
+ }
841
+
842
+ export { Gate, GateError, InMemoryGateStore, RailsUnreachableError, buildPaymentRequiredV2, caip2Of, createGate, createGatedFetch, encodeBase64Json, facilitatorClientRails, gateMiddleware, inspectPaymentHeader, matchRoute, mockFacilitatorRails, requirementFor, routeDecimals, sameNetwork, v2Requirements, validateVelocity };
843
+ //# sourceMappingURL=index.js.map
844
+ //# sourceMappingURL=index.js.map