@furlpay/gateway 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/src/gateway.ts ADDED
@@ -0,0 +1,514 @@
1
+ import crypto from "node:crypto";
2
+ import {
3
+ DEFAULT_QUOTE_TTL_SECONDS,
4
+ HEADER_V1_RECEIPT,
5
+ HEADER_V2_RECEIPT,
6
+ HEADER_V2_REQUIRED,
7
+ LATEST_X402_VERSION,
8
+ NETWORK_USDC,
9
+ REPLAY_CLAIM_TTL_SECONDS,
10
+ USDC_DECIMALS,
11
+ atomicAmount,
12
+ b64encode,
13
+ bindingMac,
14
+ decodePayment,
15
+ detectVersion,
16
+ encodeReceiptV1,
17
+ encodeReceiptV2,
18
+ hashBody,
19
+ noStoreHeaders,
20
+ requiredConfirmations,
21
+ timingSafeEqualHex,
22
+ type HeaderLookup,
23
+ type PaymentRequiredV1,
24
+ type PaymentRequiredV2,
25
+ type PaymentRequirementsV1,
26
+ type PaymentRequirementsV2,
27
+ type QuoteExtra,
28
+ type ResourceInfo,
29
+ type SettlementReceipt,
30
+ type X402Version,
31
+ } from "./protocol.js";
32
+ import {
33
+ FURLPAY_FACILITATOR,
34
+ httpFacilitator,
35
+ type FacilitatorClient,
36
+ type FacilitatorRequest,
37
+ } from "./facilitator.js";
38
+ import { MemoryClaimStore, type ClaimStore } from "./store.js";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // The gateway.
42
+ //
43
+ // One rule governs this file: NOTHING the client sends is trusted. The client
44
+ // echoes back the quote it claims to be paying — in v1 as `extra`, in v2 as the
45
+ // full `accepted` requirements — and every one of those fields is re-derived
46
+ // from server-side state and compared before a byte of the resource is released.
47
+ // The client's copy is a lookup key, never an assertion.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface RequestContext {
51
+ method: string;
52
+ /** Canonical resource identity — the absolute URL, including query. */
53
+ resource: string;
54
+ url: URL;
55
+ headers: Headers;
56
+ }
57
+
58
+ export type PriceResolver = number | ((ctx: RequestContext) => number | Promise<number>);
59
+
60
+ export interface GatewayOptions {
61
+ price: PriceResolver;
62
+ payTo: string;
63
+ /** HMAC secret binding quotes to resources. No default — a published default
64
+ * would be a forgeable quote for every deployment that didn't override it. */
65
+ quoteSecret: string;
66
+ network?: string;
67
+ asset?: string;
68
+ assetName?: string;
69
+ assetVersion?: string;
70
+ facilitator?: FacilitatorClient | string;
71
+ /** Pin the dialect used to talk to the facilitator. Default: mirror the
72
+ * client. Set to 1 when pointing at a v1-only facilitator. */
73
+ facilitatorVersion?: X402Version;
74
+ claimStore?: ClaimStore;
75
+ quoteTtlSeconds?: number;
76
+ description?: string | ((ctx: RequestContext) => string);
77
+ mimeType?: string;
78
+ /** v2 discovery metadata. These are the protocol's native fields — a
79
+ * facilitator can crawl them, so filling them in is how the resource becomes
80
+ * discoverable without any proprietary index. */
81
+ serviceName?: string;
82
+ tags?: string[];
83
+ iconUrl?: string;
84
+ /** Bind the quote to a hash of the request body (default on for POST/PUT/PATCH).
85
+ * Without it, a payment for {"model":"small"} also unlocks {"model":"large"}. */
86
+ bindBody?: boolean;
87
+ /** Settle on-chain before releasing (default). `false` verifies but does not
88
+ * collect — only correct if you settle out of band. */
89
+ settle?: boolean;
90
+ resource?: (ctx: { method: string; url: URL }) => string;
91
+ onSettled?: (receipt: SettlementReceipt) => void | Promise<void>;
92
+ onRejected?: (info: { reason: string; resource: string; payer?: string }) => void | Promise<void>;
93
+ }
94
+
95
+ export type GatewayDecision =
96
+ | { kind: "allow"; receipt: SettlementReceipt; headers: Record<string, string> }
97
+ | { kind: "challenge"; status: 402; body: PaymentRequiredV1; headers: Record<string, string> }
98
+ | { kind: "reject"; status: 400; body: { error: string }; headers: Record<string, string> };
99
+
100
+ export interface AuthorizeInput {
101
+ method: string;
102
+ url: string | URL;
103
+ headers: Headers | Record<string, string | undefined>;
104
+ /** Raw request body. Required for body binding on POST/PUT/PATCH. */
105
+ body?: string | Buffer;
106
+ }
107
+
108
+ /** The server-side truth about a quote. Both wire dialects render from this. */
109
+ interface MintedQuote {
110
+ method: string;
111
+ resource: string;
112
+ amount: string;
113
+ quoteId: string;
114
+ expiresAt: number;
115
+ bodyHash?: string;
116
+ description: string;
117
+ mimeType: string;
118
+ }
119
+
120
+ const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
121
+
122
+ function lookup(headers: AuthorizeInput["headers"]): HeaderLookup {
123
+ if (typeof (headers as Headers).get === "function") {
124
+ return (name) => (headers as Headers).get(name);
125
+ }
126
+ const rec = headers as Record<string, string | undefined>;
127
+ const lower: Record<string, string> = {};
128
+ for (const [k, v] of Object.entries(rec)) if (v !== undefined) lower[k.toLowerCase()] = v;
129
+ return (name) => lower[name.toLowerCase()] ?? null;
130
+ }
131
+
132
+ export class Gateway {
133
+ private readonly facilitator: FacilitatorClient;
134
+ private readonly claims: ClaimStore;
135
+ private readonly network: string;
136
+ private readonly asset: string;
137
+ private readonly ttl: number;
138
+
139
+ constructor(private readonly opts: GatewayOptions) {
140
+ if (!opts.quoteSecret || opts.quoteSecret.length < 16) {
141
+ throw new Error(
142
+ "@furlpay/gateway: `quoteSecret` is required and must be at least 16 chars. " +
143
+ "Generate one with: npx @furlpay/gateway secret"
144
+ );
145
+ }
146
+ if (!opts.payTo) throw new Error("@furlpay/gateway: `payTo` is required.");
147
+
148
+ this.network = opts.network ?? "base";
149
+ const asset = opts.asset ?? NETWORK_USDC[this.network];
150
+ if (!asset) {
151
+ throw new Error(
152
+ `@furlpay/gateway: no canonical USDC address known for network "${this.network}" — pass \`asset\` explicitly.`
153
+ );
154
+ }
155
+ this.asset = asset;
156
+ this.ttl = opts.quoteTtlSeconds ?? DEFAULT_QUOTE_TTL_SECONDS;
157
+ this.claims = opts.claimStore ?? new MemoryClaimStore();
158
+ this.facilitator =
159
+ typeof opts.facilitator === "string"
160
+ ? httpFacilitator(opts.facilitator)
161
+ : (opts.facilitator ?? httpFacilitator(FURLPAY_FACILITATOR));
162
+ }
163
+
164
+ /** True when replay claims are shared across instances. Assert this at boot in
165
+ * any multi-instance deployment. */
166
+ get replayProtectionDurable(): boolean {
167
+ return this.claims.isDurable;
168
+ }
169
+
170
+ // ── quoting ─────────────────────────────────────────────────────────────
171
+
172
+ private resourceId(method: string, url: URL): string {
173
+ if (this.opts.resource) return this.opts.resource({ method, url });
174
+ return `${url.origin}${url.pathname}${url.search}`;
175
+ }
176
+
177
+ private context(input: AuthorizeInput): RequestContext {
178
+ const url = input.url instanceof URL ? input.url : new URL(input.url);
179
+ const method = input.method.toUpperCase();
180
+ return {
181
+ method,
182
+ resource: this.resourceId(method, url),
183
+ url,
184
+ headers: input.headers instanceof Headers ? input.headers : new Headers(),
185
+ };
186
+ }
187
+
188
+ private async priceFor(ctx: RequestContext): Promise<number> {
189
+ return typeof this.opts.price === "function" ? await this.opts.price(ctx) : this.opts.price;
190
+ }
191
+
192
+ private describe(ctx: RequestContext): string {
193
+ if (typeof this.opts.description === "function") return this.opts.description(ctx);
194
+ return this.opts.description ?? `${ctx.method} ${ctx.url.pathname} (per call)`;
195
+ }
196
+
197
+ private shouldBindBody(method: string, body?: string | Buffer): boolean {
198
+ if (this.opts.bindBody === false) return false;
199
+ if (!BODY_METHODS.has(method)) return false;
200
+ if (body === undefined || body === null) return false;
201
+ return body.length > 0;
202
+ }
203
+
204
+ /** Mint a fresh quote for this request. */
205
+ private async mint(input: AuthorizeInput): Promise<MintedQuote> {
206
+ const ctx = this.context(input);
207
+ const amount = atomicAmount(await this.priceFor(ctx), USDC_DECIMALS);
208
+ return {
209
+ method: ctx.method,
210
+ resource: ctx.resource,
211
+ amount,
212
+ quoteId: crypto.randomBytes(16).toString("hex"),
213
+ expiresAt: Math.floor(Date.now() / 1000) + this.ttl,
214
+ bodyHash: this.shouldBindBody(ctx.method, input.body) ? hashBody(input.body!) : undefined,
215
+ description: this.describe(ctx),
216
+ mimeType: this.opts.mimeType ?? "application/json",
217
+ };
218
+ }
219
+
220
+ private extraFor(q: MintedQuote): QuoteExtra {
221
+ return {
222
+ name: this.opts.assetName ?? "USD Coin",
223
+ version: this.opts.assetVersion ?? "2",
224
+ quoteId: q.quoteId,
225
+ expiresAt: q.expiresAt,
226
+ binding: bindingMac(this.opts.quoteSecret, {
227
+ method: q.method,
228
+ resource: q.resource,
229
+ amount: q.amount,
230
+ quoteId: q.quoteId,
231
+ expiresAt: q.expiresAt,
232
+ bodyHash: q.bodyHash,
233
+ }),
234
+ ...(q.bodyHash ? { bodyHash: q.bodyHash } : {}),
235
+ };
236
+ }
237
+
238
+ private toV1(q: MintedQuote): PaymentRequirementsV1 {
239
+ return {
240
+ scheme: "exact",
241
+ network: this.network,
242
+ maxAmountRequired: q.amount,
243
+ resource: q.resource,
244
+ description: q.description,
245
+ mimeType: q.mimeType,
246
+ payTo: this.opts.payTo,
247
+ maxTimeoutSeconds: this.ttl,
248
+ asset: this.asset,
249
+ extra: this.extraFor(q),
250
+ };
251
+ }
252
+
253
+ private toV2(q: MintedQuote): PaymentRequirementsV2 {
254
+ return {
255
+ scheme: "exact",
256
+ network: this.network,
257
+ asset: this.asset,
258
+ amount: q.amount,
259
+ payTo: this.opts.payTo,
260
+ maxTimeoutSeconds: this.ttl,
261
+ extra: this.extraFor(q),
262
+ };
263
+ }
264
+
265
+ private resourceInfo(q: MintedQuote): ResourceInfo {
266
+ return {
267
+ url: q.resource,
268
+ description: q.description,
269
+ mimeType: q.mimeType,
270
+ ...(this.opts.serviceName ? { serviceName: this.opts.serviceName } : {}),
271
+ ...(this.opts.tags?.length ? { tags: this.opts.tags } : {}),
272
+ ...(this.opts.iconUrl ? { iconUrl: this.opts.iconUrl } : {}),
273
+ };
274
+ }
275
+
276
+ /** Public quote surface — the v2 402 payload for this request. */
277
+ async quote(input: AuthorizeInput): Promise<PaymentRequiredV2> {
278
+ const q = await this.mint(input);
279
+ return { x402Version: 2, resource: this.resourceInfo(q), accepts: [this.toV2(q)] };
280
+ }
281
+
282
+ /**
283
+ * A 402 advertises BOTH dialects: v2 in the PAYMENT-REQUIRED header, v1 in the
284
+ * body. v2 moving payment data out of the body is exactly what makes this
285
+ * possible — a v2 client reads the header, a v1 client reads the body, and
286
+ * neither needs to know the other exists.
287
+ */
288
+ private async challenge(input: AuthorizeInput, error: string): Promise<GatewayDecision> {
289
+ const q = await this.mint(input);
290
+ const v2: PaymentRequiredV2 = {
291
+ x402Version: 2,
292
+ error,
293
+ resource: this.resourceInfo(q),
294
+ accepts: [this.toV2(q)],
295
+ };
296
+ const v1: PaymentRequiredV1 = { x402Version: 1, error, accepts: [this.toV1(q)] };
297
+ return {
298
+ kind: "challenge",
299
+ status: 402,
300
+ body: v1,
301
+ headers: {
302
+ ...noStoreHeaders,
303
+ "Content-Type": "application/json",
304
+ [HEADER_V2_REQUIRED]: b64encode(v2),
305
+ },
306
+ };
307
+ }
308
+
309
+ private reject(error: string): GatewayDecision {
310
+ return {
311
+ kind: "reject",
312
+ status: 400,
313
+ body: { error },
314
+ headers: { ...noStoreHeaders, "Content-Type": "application/json" },
315
+ };
316
+ }
317
+
318
+ // ── the gate ────────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Returns `allow` only when a payment has been verified against a
322
+ * server-derived quote, burned as single-use, and settled to the confirmation
323
+ * depth its value requires.
324
+ */
325
+ async authorize(input: AuthorizeInput): Promise<GatewayDecision> {
326
+ const ctx = this.context(input);
327
+ const get = lookup(input.headers);
328
+ const clientVersion = detectVersion(get);
329
+
330
+ if (clientVersion === null) return this.challenge(input, "Payment required");
331
+
332
+ const decoded = decodePayment(get);
333
+ if (!decoded.ok) {
334
+ // Malformed is the client's bug, not a pricing failure — a 402 here would
335
+ // invite the agent to pay again against a payload we could not even read.
336
+ await this.opts.onRejected?.({ reason: decoded.error, resource: ctx.resource });
337
+ return this.reject(decoded.error);
338
+ }
339
+ const p = decoded.payment;
340
+ const auth = p.authorization;
341
+
342
+ const fail = async (reason: string): Promise<GatewayDecision> => {
343
+ await this.opts.onRejected?.({ reason, resource: ctx.resource, payer: auth.from });
344
+ return this.challenge(input, reason);
345
+ };
346
+
347
+ // ── re-derive the expectation; the client's echo is never the source ──
348
+ const expectedAmount = atomicAmount(await this.priceFor(ctx), USDC_DECIMALS);
349
+
350
+ if (p.scheme !== "exact") return fail("Unsupported scheme");
351
+ if (p.network !== this.network) return fail(`Wrong network (expected ${this.network})`);
352
+ if (auth.to.toLowerCase() !== this.opts.payTo.toLowerCase()) {
353
+ return fail("Payment not addressed to this recipient");
354
+ }
355
+
356
+ // Integer compare — "9000" > "10000" lexicographically.
357
+ try {
358
+ if (BigInt(auth.value) < BigInt(expectedAmount)) {
359
+ return fail(`Insufficient amount (expected ${expectedAmount} atomic units)`);
360
+ }
361
+ } catch {
362
+ return fail("Invalid amount");
363
+ }
364
+
365
+ // ── binding: this resource, this price, this body? ──
366
+ const { quoteId, binding, expiresAt } = p;
367
+ if (!quoteId || !binding || typeof expiresAt !== "number") {
368
+ return fail("Missing quote binding (re-request the 402 quote)");
369
+ }
370
+
371
+ let bodyHash: string | undefined;
372
+ if (this.shouldBindBody(ctx.method, input.body)) {
373
+ bodyHash = hashBody(input.body!);
374
+ if (!p.bodyHash) return fail("Quote is not body-bound (re-request the 402 quote)");
375
+ if (!timingSafeEqualHex(p.bodyHash, bodyHash)) {
376
+ return fail("Body does not match the body this quote was issued for");
377
+ }
378
+ }
379
+
380
+ const expectedMac = bindingMac(this.opts.quoteSecret, {
381
+ method: ctx.method,
382
+ resource: ctx.resource,
383
+ amount: expectedAmount,
384
+ quoteId,
385
+ expiresAt,
386
+ bodyHash,
387
+ });
388
+ if (!timingSafeEqualHex(expectedMac, binding)) {
389
+ // The most important line in this file: a payment minted for a cheap
390
+ // resource dies here when presented to an expensive one.
391
+ return fail("Quote binding mismatch (payment bound to a different resource)");
392
+ }
393
+
394
+ // ── freshness ──
395
+ const now = Math.floor(Date.now() / 1000);
396
+ if (now > expiresAt) return fail("Quote expired (re-request the 402 quote)");
397
+ const validAfter = Number(auth.validAfter);
398
+ const validBefore = Number(auth.validBefore);
399
+ if (Number.isFinite(validAfter) && now < validAfter) return fail("Authorization not yet valid");
400
+ if (Number.isFinite(validBefore) && now > validBefore) return fail("Authorization expired");
401
+
402
+ // ── single-use. Nonce first: it is the money-level token, so burning it
403
+ // before the quote means a racing replay never reaches settlement. ──
404
+ if (!(await this.claims.claim(`x402:nonce:${auth.nonce}`, REPLAY_CLAIM_TTL_SECONDS))) {
405
+ return fail("Authorization already used (replay)");
406
+ }
407
+ if (!(await this.claims.claim(`x402:quote:${quoteId}`, REPLAY_CLAIM_TTL_SECONDS))) {
408
+ return fail("Quote already redeemed (replay)");
409
+ }
410
+
411
+ // ── verify + settle, in the dialect the facilitator speaks ──
412
+ const q: MintedQuote = {
413
+ method: ctx.method,
414
+ resource: ctx.resource,
415
+ amount: expectedAmount,
416
+ quoteId,
417
+ expiresAt,
418
+ bodyHash,
419
+ description: this.describe(ctx),
420
+ mimeType: this.opts.mimeType ?? "application/json",
421
+ };
422
+ const fReq = this.facilitatorRequest(q, p, clientVersion);
423
+
424
+ if (this.opts.settle === false) {
425
+ const verdict = await this.facilitator.verify(fReq);
426
+ if (!verdict.isValid) return fail(verdict.invalidReason ?? "Payment verification failed");
427
+ return this.allow(clientVersion, {
428
+ payer: verdict.payer ?? auth.from,
429
+ transaction: "",
430
+ network: this.network,
431
+ asset: this.asset,
432
+ amountAtomic: auth.value,
433
+ resource: ctx.resource,
434
+ settledAt: new Date().toISOString(),
435
+ });
436
+ }
437
+
438
+ const settled = await this.facilitator.settle(fReq);
439
+ if (!settled.success) {
440
+ // The nonce stays burned. A settle that errored may STILL land on-chain,
441
+ // so freeing it would reopen replay against a payment that does confirm.
442
+ // An honest retry signs a fresh nonce, so this never blocks a real payer.
443
+ return fail(settled.errorReason ?? "Settlement failed");
444
+ }
445
+
446
+ const needed = requiredConfirmations(auth.value);
447
+ if (typeof settled.confirmations === "number" && settled.confirmations < needed) {
448
+ return fail(`Insufficient confirmations (${settled.confirmations}/${needed})`);
449
+ }
450
+
451
+ return this.allow(clientVersion, {
452
+ payer: settled.payer ?? auth.from,
453
+ transaction: settled.transaction ?? "",
454
+ network: settled.network ?? this.network,
455
+ asset: this.asset,
456
+ // v2's `upto` scheme can settle less than the authorized maximum.
457
+ amountAtomic: settled.amount ?? auth.value,
458
+ resource: ctx.resource,
459
+ confirmations: settled.confirmations,
460
+ settledAt: new Date().toISOString(),
461
+ });
462
+ }
463
+
464
+ /** Rebuild the requirements the quote was minted with, so the facilitator
465
+ * checks the payment against the SERVER's terms, not the client's copy. */
466
+ private facilitatorRequest(
467
+ q: MintedQuote,
468
+ payment: { signature: string; authorization: import("./protocol.js").EIP3009Authorization },
469
+ clientVersion: X402Version
470
+ ): FacilitatorRequest {
471
+ const version = this.opts.facilitatorVersion ?? clientVersion;
472
+
473
+ if (version === 2) {
474
+ return {
475
+ x402Version: 2,
476
+ paymentPayload: {
477
+ x402Version: 2,
478
+ resource: this.resourceInfo(q),
479
+ accepted: this.toV2(q),
480
+ payload: { signature: payment.signature, authorization: payment.authorization },
481
+ },
482
+ paymentRequirements: this.toV2(q),
483
+ };
484
+ }
485
+ return {
486
+ x402Version: 1,
487
+ paymentPayload: {
488
+ x402Version: 1,
489
+ scheme: "exact",
490
+ network: this.network,
491
+ payload: { signature: payment.signature, authorization: payment.authorization },
492
+ extra: this.extraFor(q),
493
+ },
494
+ paymentRequirements: this.toV1(q),
495
+ };
496
+ }
497
+
498
+ /** Answer in the dialect the client spoke — and, harmlessly, the other one too,
499
+ * so a proxy or SDK expecting either finds its receipt. */
500
+ private async allow(version: X402Version, receipt: SettlementReceipt): Promise<GatewayDecision> {
501
+ await this.opts.onSettled?.(receipt);
502
+ return {
503
+ kind: "allow",
504
+ receipt,
505
+ headers: {
506
+ ...noStoreHeaders,
507
+ [HEADER_V1_RECEIPT]: encodeReceiptV1(receipt),
508
+ [HEADER_V2_RECEIPT]: encodeReceiptV2(receipt),
509
+ },
510
+ };
511
+ }
512
+ }
513
+
514
+ export { LATEST_X402_VERSION };
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ export {
2
+ LATEST_X402_VERSION,
3
+ HEADER_V1_PAYMENT,
4
+ HEADER_V1_RECEIPT,
5
+ HEADER_V2_PAYMENT,
6
+ HEADER_V2_RECEIPT,
7
+ HEADER_V2_REQUIRED,
8
+ MAX_HEADER_BYTES,
9
+ DEFAULT_QUOTE_TTL_SECONDS,
10
+ REPLAY_CLAIM_TTL_SECONDS,
11
+ NETWORK_USDC,
12
+ USDC_DECIMALS,
13
+ noStoreHeaders,
14
+ requiredConfirmations,
15
+ atomicAmount,
16
+ bindingMac,
17
+ hashBody,
18
+ timingSafeEqualHex,
19
+ b64encode,
20
+ b64decode,
21
+ detectVersion,
22
+ decodePayment,
23
+ encodeReceiptV1,
24
+ encodeReceiptV2,
25
+ decodeReceipt,
26
+ type X402Version,
27
+ type EIP3009Authorization,
28
+ type QuoteExtra,
29
+ type ResourceInfo,
30
+ type PaymentRequirementsV1,
31
+ type PaymentRequiredV1,
32
+ type PaymentPayloadV1,
33
+ type PaymentRequirementsV2,
34
+ type PaymentRequiredV2,
35
+ type PaymentPayloadV2,
36
+ type NormalizedPayment,
37
+ type SettlementReceipt,
38
+ type BindingInput,
39
+ type DecodedPayment,
40
+ type HeaderLookup,
41
+ } from "./protocol.js";
42
+
43
+ export {
44
+ FURLPAY_FACILITATOR,
45
+ httpFacilitator,
46
+ type FacilitatorClient,
47
+ type FacilitatorRequest,
48
+ type VerifyResult,
49
+ type SettleResult,
50
+ type SupportedKind,
51
+ type HttpFacilitatorOptions,
52
+ } from "./facilitator.js";
53
+
54
+ export {
55
+ MemoryClaimStore,
56
+ claimStoreFrom,
57
+ upstashClaimStore,
58
+ type ClaimStore,
59
+ type UpstashClaimStoreOptions,
60
+ } from "./store.js";
61
+
62
+ export {
63
+ Gateway,
64
+ type GatewayOptions,
65
+ type GatewayDecision,
66
+ type AuthorizeInput,
67
+ type RequestContext,
68
+ type PriceResolver,
69
+ } from "./gateway.js";
70
+
71
+ export {
72
+ paywall,
73
+ createGateway,
74
+ type FetchHandler,
75
+ type PaidHandler,
76
+ } from "./adapters/fetch.js";