@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/LICENSE +21 -0
- package/README.md +246 -0
- package/SECURITY.md +65 -0
- package/dist/adapters/express.d.ts +43 -0
- package/dist/adapters/express.js +81 -0
- package/dist/adapters/fetch.d.ts +27 -0
- package/dist/adapters/fetch.js +63 -0
- package/dist/adapters/proxy.d.ts +26 -0
- package/dist/adapters/proxy.js +119 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +141 -0
- package/dist/facilitator.d.ts +55 -0
- package/dist/facilitator.js +78 -0
- package/dist/gateway.d.ts +122 -0
- package/dist/gateway.js +369 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/protocol.d.ts +173 -0
- package/dist/protocol.js +186 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +134 -0
- package/package.json +51 -0
- package/src/adapters/express.ts +122 -0
- package/src/adapters/fetch.ts +88 -0
- package/src/adapters/proxy.ts +157 -0
- package/src/cli.ts +164 -0
- package/src/facilitator.ts +133 -0
- package/src/gateway.ts +514 -0
- package/src/index.ts +76 -0
- package/src/protocol.ts +371 -0
- package/src/store.ts +157 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// x402 wire protocol — v1 AND v2.
|
|
5
|
+
//
|
|
6
|
+
// v2 shipped Dec 2025 and is not just a header rename. The shapes changed:
|
|
7
|
+
//
|
|
8
|
+
// v1 v2
|
|
9
|
+
// ───────────────────────────────── ─────────────────────────────────
|
|
10
|
+
// X-PAYMENT (request) PAYMENT-SIGNATURE (request)
|
|
11
|
+
// X-PAYMENT-RESPONSE (response) PAYMENT-RESPONSE (response)
|
|
12
|
+
// 402 data in the BODY 402 data in PAYMENT-REQUIRED header
|
|
13
|
+
// requirements.maxAmountRequired requirements.amount
|
|
14
|
+
// requirements.resource/description top-level resource: ResourceInfo
|
|
15
|
+
// payload.extra (quote echo) payload.accepted (full requirements)
|
|
16
|
+
//
|
|
17
|
+
// Both are supported here, and the version is negotiated per request: we answer
|
|
18
|
+
// in whatever dialect the client spoke. A 402 advertises BOTH (v2 in the
|
|
19
|
+
// PAYMENT-REQUIRED header, v1 in the body), which is exactly what v2's move of
|
|
20
|
+
// payment data out of the body makes possible.
|
|
21
|
+
//
|
|
22
|
+
// The payment model is identical across versions and is the thing people get
|
|
23
|
+
// wrong: the client signs an EIP-3009 authorization OFF-CHAIN. It never submits
|
|
24
|
+
// a transaction and never holds gas. The facilitator submits and pays the gas.
|
|
25
|
+
// Any design that asks the payer for a transaction hash is not x402.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export type X402Version = 1 | 2;
|
|
29
|
+
|
|
30
|
+
/** Default dialect for freshly-minted quotes when the client hasn't spoken yet. */
|
|
31
|
+
export const LATEST_X402_VERSION: X402Version = 2;
|
|
32
|
+
|
|
33
|
+
// ── headers ───────────────────────────────────────────────────────────────
|
|
34
|
+
export const HEADER_V1_PAYMENT = "x-payment";
|
|
35
|
+
export const HEADER_V1_RECEIPT = "X-PAYMENT-RESPONSE";
|
|
36
|
+
export const HEADER_V2_PAYMENT = "payment-signature";
|
|
37
|
+
export const HEADER_V2_RECEIPT = "PAYMENT-RESPONSE";
|
|
38
|
+
export const HEADER_V2_REQUIRED = "PAYMENT-REQUIRED";
|
|
39
|
+
|
|
40
|
+
/** Web-layer hardening: cap the header before we spend cycles parsing it. */
|
|
41
|
+
export const MAX_HEADER_BYTES = 8192;
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_QUOTE_TTL_SECONDS = 300;
|
|
44
|
+
|
|
45
|
+
/** Must outlive any EIP-3009 validity window, or a late replay could re-claim a
|
|
46
|
+
* freed key. */
|
|
47
|
+
export const REPLAY_CLAIM_TTL_SECONDS = 86_400;
|
|
48
|
+
|
|
49
|
+
/** Canonical (native-issuance, not bridged) USDC per network. */
|
|
50
|
+
export const NETWORK_USDC: Record<string, string> = {
|
|
51
|
+
arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
52
|
+
"arbitrum-sepolia": "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
|
|
53
|
+
base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
54
|
+
"base-sepolia": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
55
|
+
polygon: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
56
|
+
solana: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const USDC_DECIMALS = 6;
|
|
60
|
+
|
|
61
|
+
// ── shared ────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface EIP3009Authorization {
|
|
64
|
+
from: string;
|
|
65
|
+
to: string;
|
|
66
|
+
value: string;
|
|
67
|
+
validAfter: string;
|
|
68
|
+
validBefore: string;
|
|
69
|
+
nonce: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The quote fields we carry in `extra` — identical across v1 and v2, which is
|
|
73
|
+
* what lets one binding scheme serve both. */
|
|
74
|
+
export interface QuoteExtra {
|
|
75
|
+
name: string;
|
|
76
|
+
version: string;
|
|
77
|
+
quoteId: string;
|
|
78
|
+
expiresAt: number;
|
|
79
|
+
binding: string;
|
|
80
|
+
bodyHash?: string;
|
|
81
|
+
[k: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── v1 ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export interface PaymentRequirementsV1 {
|
|
87
|
+
scheme: "exact";
|
|
88
|
+
network: string;
|
|
89
|
+
maxAmountRequired: string;
|
|
90
|
+
resource: string;
|
|
91
|
+
description: string;
|
|
92
|
+
mimeType: string;
|
|
93
|
+
outputSchema?: Record<string, unknown>;
|
|
94
|
+
payTo: string;
|
|
95
|
+
maxTimeoutSeconds: number;
|
|
96
|
+
asset: string;
|
|
97
|
+
extra: QuoteExtra;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface PaymentRequiredV1 {
|
|
101
|
+
x402Version: 1;
|
|
102
|
+
error: string;
|
|
103
|
+
accepts: PaymentRequirementsV1[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface PaymentPayloadV1 {
|
|
107
|
+
x402Version: 1;
|
|
108
|
+
scheme: string;
|
|
109
|
+
network: string;
|
|
110
|
+
payload: { signature: string; authorization: EIP3009Authorization };
|
|
111
|
+
extra?: Partial<QuoteExtra>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── v2 ────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** v2 hoists resource metadata out of each requirement. `tags` / `serviceName`
|
|
117
|
+
* / `iconUrl` are the protocol's native discovery fields — a facilitator can
|
|
118
|
+
* crawl these, which is why a proprietary discovery index is the wrong build. */
|
|
119
|
+
export interface ResourceInfo {
|
|
120
|
+
url: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
mimeType?: string;
|
|
123
|
+
serviceName?: string;
|
|
124
|
+
tags?: string[];
|
|
125
|
+
iconUrl?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PaymentRequirementsV2 {
|
|
129
|
+
scheme: "exact";
|
|
130
|
+
network: string;
|
|
131
|
+
asset: string;
|
|
132
|
+
amount: string; // v1's maxAmountRequired
|
|
133
|
+
payTo: string;
|
|
134
|
+
maxTimeoutSeconds: number;
|
|
135
|
+
extra: QuoteExtra;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PaymentRequiredV2 {
|
|
139
|
+
x402Version: 2;
|
|
140
|
+
error?: string;
|
|
141
|
+
resource: ResourceInfo;
|
|
142
|
+
accepts: PaymentRequirementsV2[];
|
|
143
|
+
extensions?: Record<string, unknown>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface PaymentPayloadV2 {
|
|
147
|
+
x402Version: 2;
|
|
148
|
+
resource?: ResourceInfo;
|
|
149
|
+
/** The client echoes the FULL requirements it is paying against. Still not
|
|
150
|
+
* trusted — every field is re-derived server-side and compared. */
|
|
151
|
+
accepted: PaymentRequirementsV2;
|
|
152
|
+
payload: { signature: string; authorization: EIP3009Authorization };
|
|
153
|
+
extensions?: Record<string, unknown>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── normalized view ───────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** One internal shape both dialects collapse to, so the gate is written once. */
|
|
159
|
+
export interface NormalizedPayment {
|
|
160
|
+
version: X402Version;
|
|
161
|
+
scheme: string;
|
|
162
|
+
network: string;
|
|
163
|
+
signature: string;
|
|
164
|
+
authorization: EIP3009Authorization;
|
|
165
|
+
/** Quote echo — the lookup key, never an assertion. */
|
|
166
|
+
quoteId?: string;
|
|
167
|
+
binding?: string;
|
|
168
|
+
expiresAt?: number;
|
|
169
|
+
bodyHash?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── cache hygiene ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** A paid 200 that a CDN caches is a paid resource served free to the next
|
|
175
|
+
* unpaid caller. `Vary` covers both dialects' request headers. */
|
|
176
|
+
export const noStoreHeaders: Record<string, string> = {
|
|
177
|
+
"Cache-Control": "no-store, private, max-age=0",
|
|
178
|
+
Vary: "X-PAYMENT, PAYMENT-SIGNATURE",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// ── value-scaled settlement depth ─────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export function requiredConfirmations(amountAtomic: string): number {
|
|
184
|
+
let usd: number;
|
|
185
|
+
try {
|
|
186
|
+
usd = Number(BigInt(amountAtomic)) / 10 ** USDC_DECIMALS;
|
|
187
|
+
} catch {
|
|
188
|
+
return 12; // unparseable → demand the strongest gate
|
|
189
|
+
}
|
|
190
|
+
if (usd < 1) return 3;
|
|
191
|
+
if (usd < 10) return 6;
|
|
192
|
+
return 12;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function atomicAmount(priceUsd: number, decimals: number = USDC_DECIMALS): string {
|
|
196
|
+
if (!Number.isFinite(priceUsd) || priceUsd <= 0) {
|
|
197
|
+
throw new RangeError(`price must be a positive finite number (got ${priceUsd})`);
|
|
198
|
+
}
|
|
199
|
+
return String(Math.round(priceUsd * 10 ** decimals));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── binding ───────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export interface BindingInput {
|
|
205
|
+
method: string;
|
|
206
|
+
resource: string;
|
|
207
|
+
amount: string;
|
|
208
|
+
quoteId: string;
|
|
209
|
+
expiresAt: number;
|
|
210
|
+
bodyHash?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* HMAC binding a quote to exactly one (method, resource, amount) — and, when a
|
|
215
|
+
* body is present, exactly one body.
|
|
216
|
+
*
|
|
217
|
+
* Deliberately version-independent: the same MAC validates a v1 or a v2 payment,
|
|
218
|
+
* because both dialects carry the quote in `extra`. It is also byte-identical to
|
|
219
|
+
* apps/web/src/lib/x402.ts, so quotes stay interchangeable with FurlPay's
|
|
220
|
+
* first-party resources.
|
|
221
|
+
*/
|
|
222
|
+
export function bindingMac(secret: string | Buffer, input: BindingInput): string {
|
|
223
|
+
const base = `${input.method} ${input.resource} ${input.amount} ${input.quoteId} ${input.expiresAt}`;
|
|
224
|
+
const message = input.bodyHash ? `${base} ${input.bodyHash}` : base;
|
|
225
|
+
return crypto.createHmac("sha256", secret).update(message).digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function hashBody(body: string | Buffer): string {
|
|
229
|
+
return crypto.createHash("sha256").update(body).digest("hex");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function timingSafeEqualHex(a: string, b: string): boolean {
|
|
233
|
+
const ba = Buffer.from(a, "utf8");
|
|
234
|
+
const bb = Buffer.from(b, "utf8");
|
|
235
|
+
if (ba.length !== bb.length) return false;
|
|
236
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── codec ─────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export function b64encode(value: unknown): string {
|
|
242
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function b64decode<T>(value: string): T {
|
|
246
|
+
return JSON.parse(Buffer.from(value, "base64").toString("utf8")) as T;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type HeaderLookup = (name: string) => string | null;
|
|
250
|
+
|
|
251
|
+
/** Which dialect did the client speak? `null` when it presented no payment. */
|
|
252
|
+
export function detectVersion(get: HeaderLookup): X402Version | null {
|
|
253
|
+
if (get(HEADER_V2_PAYMENT)) return 2;
|
|
254
|
+
if (get(HEADER_V1_PAYMENT)) return 1;
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type DecodedPayment =
|
|
259
|
+
| { ok: true; payment: NormalizedPayment }
|
|
260
|
+
| { ok: false; error: string };
|
|
261
|
+
|
|
262
|
+
function validAuthorization(auth: unknown): auth is EIP3009Authorization {
|
|
263
|
+
if (!auth || typeof auth !== "object") return false;
|
|
264
|
+
const a = auth as Record<string, unknown>;
|
|
265
|
+
return (["from", "to", "value", "validAfter", "validBefore", "nonce"] as const).every(
|
|
266
|
+
(f) => typeof a[f] === "string" && (a[f] as string).length > 0
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Strict parse of whichever payment header is present, normalized to one shape.
|
|
272
|
+
* Malformed input is an error, never a silently-released resource.
|
|
273
|
+
*/
|
|
274
|
+
export function decodePayment(get: HeaderLookup): DecodedPayment {
|
|
275
|
+
const version = detectVersion(get);
|
|
276
|
+
if (version === null) return { ok: false, error: "Payment required" };
|
|
277
|
+
|
|
278
|
+
const raw = version === 2 ? get(HEADER_V2_PAYMENT)! : get(HEADER_V1_PAYMENT)!;
|
|
279
|
+
if (raw.length > MAX_HEADER_BYTES) return { ok: false, error: "Payment header too large" };
|
|
280
|
+
|
|
281
|
+
let parsed: unknown;
|
|
282
|
+
try {
|
|
283
|
+
parsed = b64decode(raw);
|
|
284
|
+
} catch {
|
|
285
|
+
return { ok: false, error: "Payment header is not valid base64 JSON" };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (version === 2) {
|
|
289
|
+
const p = parsed as PaymentPayloadV2;
|
|
290
|
+
if (p?.x402Version !== 2) return { ok: false, error: "Unsupported x402 version" };
|
|
291
|
+
if (!p.accepted || typeof p.accepted !== "object") {
|
|
292
|
+
return { ok: false, error: "Malformed v2 payload (missing `accepted`)" };
|
|
293
|
+
}
|
|
294
|
+
if (typeof p.payload?.signature !== "string" || !validAuthorization(p.payload?.authorization)) {
|
|
295
|
+
return { ok: false, error: "Malformed v2 payload (bad signature/authorization)" };
|
|
296
|
+
}
|
|
297
|
+
const extra = (p.accepted.extra ?? {}) as Partial<QuoteExtra>;
|
|
298
|
+
return {
|
|
299
|
+
ok: true,
|
|
300
|
+
payment: {
|
|
301
|
+
version: 2,
|
|
302
|
+
scheme: p.accepted.scheme,
|
|
303
|
+
network: p.accepted.network,
|
|
304
|
+
signature: p.payload.signature,
|
|
305
|
+
authorization: p.payload.authorization,
|
|
306
|
+
quoteId: extra.quoteId,
|
|
307
|
+
binding: extra.binding,
|
|
308
|
+
expiresAt: extra.expiresAt,
|
|
309
|
+
bodyHash: extra.bodyHash,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const p = parsed as PaymentPayloadV1;
|
|
315
|
+
if (p?.x402Version !== 1) return { ok: false, error: "Unsupported x402 version" };
|
|
316
|
+
if (typeof p.payload?.signature !== "string" || !validAuthorization(p.payload?.authorization)) {
|
|
317
|
+
return { ok: false, error: "Malformed v1 payload (bad signature/authorization)" };
|
|
318
|
+
}
|
|
319
|
+
const extra = (p.extra ?? {}) as Partial<QuoteExtra>;
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
payment: {
|
|
323
|
+
version: 1,
|
|
324
|
+
scheme: p.scheme,
|
|
325
|
+
network: p.network,
|
|
326
|
+
signature: p.payload.signature,
|
|
327
|
+
authorization: p.payload.authorization,
|
|
328
|
+
quoteId: extra.quoteId,
|
|
329
|
+
binding: extra.binding,
|
|
330
|
+
expiresAt: extra.expiresAt,
|
|
331
|
+
bodyHash: extra.bodyHash,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── receipts ──────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
export interface SettlementReceipt {
|
|
339
|
+
payer: string;
|
|
340
|
+
transaction: string;
|
|
341
|
+
network: string;
|
|
342
|
+
asset: string;
|
|
343
|
+
amountAtomic: string;
|
|
344
|
+
resource: string;
|
|
345
|
+
confirmations?: number;
|
|
346
|
+
settledAt: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** v1 receipt — FurlPay's own X-PAYMENT-RESPONSE shape. */
|
|
350
|
+
export function encodeReceiptV1(receipt: SettlementReceipt): string {
|
|
351
|
+
return b64encode(receipt);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** v2 receipt — the reference SDK's SettleResponse shape. */
|
|
355
|
+
export function encodeReceiptV2(receipt: SettlementReceipt): string {
|
|
356
|
+
return b64encode({
|
|
357
|
+
success: true,
|
|
358
|
+
transaction: receipt.transaction,
|
|
359
|
+
network: receipt.network,
|
|
360
|
+
payer: receipt.payer,
|
|
361
|
+
amount: receipt.amountAtomic,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function decodeReceipt(header: string): SettlementReceipt | null {
|
|
366
|
+
try {
|
|
367
|
+
return b64decode<SettlementReceipt>(header);
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Single-use claim store.
|
|
3
|
+
//
|
|
4
|
+
// Two tokens must be burned exactly once per payment: the EIP-3009 nonce (the
|
|
5
|
+
// money-level token) and the quoteId (the resource-level token). "Exactly once"
|
|
6
|
+
// has to hold ACROSS PROCESSES, which is the part that is easy to get wrong.
|
|
7
|
+
//
|
|
8
|
+
// The classic serverless bug: a `Set` in module scope looks like replay
|
|
9
|
+
// protection and passes every local test, because in dev there is one process.
|
|
10
|
+
// In production there are N lambdas, each with its own empty Set, so the same
|
|
11
|
+
// X-PAYMENT replayed N times clears N different Sets and grants the resource N
|
|
12
|
+
// times — one payment, N deliveries. The fix is an ATOMIC claim in shared state
|
|
13
|
+
// (Redis SET NX), which linearizes the burn across every instance.
|
|
14
|
+
//
|
|
15
|
+
// MemoryClaimStore is therefore correct on exactly one instance and is labelled
|
|
16
|
+
// as such. `isDurable` exists so a deployment can assert on it at boot rather
|
|
17
|
+
// than discover it in an incident.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface ClaimStore {
|
|
21
|
+
/**
|
|
22
|
+
* Atomically claim `key`. Returns true iff THIS caller is the first to claim
|
|
23
|
+
* it. Must be a genuine compare-and-set — a get-then-set is a race, and the
|
|
24
|
+
* race is the vulnerability.
|
|
25
|
+
*/
|
|
26
|
+
claim(key: string, ttlSeconds: number): Promise<boolean>;
|
|
27
|
+
/** False if claims are not shared across instances. */
|
|
28
|
+
readonly isDurable: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-memory claim store. Single instance only.
|
|
33
|
+
*
|
|
34
|
+
* Bounded so a flood of distinct quoteIds cannot grow it without limit; eviction
|
|
35
|
+
* is oldest-expiry-first, and expired entries are dropped lazily on access.
|
|
36
|
+
*/
|
|
37
|
+
export class MemoryClaimStore implements ClaimStore {
|
|
38
|
+
readonly isDurable = false;
|
|
39
|
+
private readonly entries = new Map<string, number>(); // key → expiry (ms)
|
|
40
|
+
|
|
41
|
+
constructor(private readonly maxEntries = 100_000) {}
|
|
42
|
+
|
|
43
|
+
async claim(key: string, ttlSeconds: number): Promise<boolean> {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const existing = this.entries.get(key);
|
|
46
|
+
if (existing !== undefined && existing > now) return false; // already burned
|
|
47
|
+
if (existing !== undefined) this.entries.delete(key); // expired → reclaimable
|
|
48
|
+
|
|
49
|
+
if (this.entries.size >= this.maxEntries) this.evict(now);
|
|
50
|
+
this.entries.set(key, now + ttlSeconds * 1000);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private evict(now: number): void {
|
|
55
|
+
for (const [k, expiry] of this.entries) {
|
|
56
|
+
if (expiry <= now) this.entries.delete(k);
|
|
57
|
+
}
|
|
58
|
+
// Still full of live entries — drop the soonest-to-expire to stay bounded.
|
|
59
|
+
if (this.entries.size >= this.maxEntries) {
|
|
60
|
+
const oldest = [...this.entries.entries()].sort((a, b) => a[1] - b[1]);
|
|
61
|
+
const drop = Math.ceil(this.maxEntries * 0.1);
|
|
62
|
+
for (let i = 0; i < drop && i < oldest.length; i++) this.entries.delete(oldest[i][0]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Test/introspection helper. */
|
|
67
|
+
get size(): number {
|
|
68
|
+
return this.entries.size;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wrap any atomic set-if-not-exists primitive as a ClaimStore, without this
|
|
74
|
+
* package taking a Redis dependency.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* import { Redis } from "@upstash/redis";
|
|
78
|
+
* const redis = Redis.fromEnv();
|
|
79
|
+
* const store = claimStoreFrom(
|
|
80
|
+
* (key, ttl) => redis.set(key, 1, { nx: true, ex: ttl }).then((r) => r === "OK")
|
|
81
|
+
* );
|
|
82
|
+
*/
|
|
83
|
+
export function claimStoreFrom(
|
|
84
|
+
setNx: (key: string, ttlSeconds: number) => Promise<boolean>,
|
|
85
|
+
isDurable = true
|
|
86
|
+
): ClaimStore {
|
|
87
|
+
return {
|
|
88
|
+
isDurable,
|
|
89
|
+
async claim(key, ttlSeconds) {
|
|
90
|
+
try {
|
|
91
|
+
return await setNx(key, ttlSeconds);
|
|
92
|
+
} catch {
|
|
93
|
+
// A claim store we cannot reach cannot prove single-use. Refusing the
|
|
94
|
+
// payment loses a sale; granting it risks unbounded duplicate delivery
|
|
95
|
+
// on a single authorization. Refuse.
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface UpstashClaimStoreOptions {
|
|
103
|
+
/** Upstash REST endpoint, e.g. https://eu2-xxx.upstash.io */
|
|
104
|
+
url?: string;
|
|
105
|
+
token?: string;
|
|
106
|
+
timeoutMs?: number;
|
|
107
|
+
fetchImpl?: typeof fetch;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Durable claim store backed by Upstash Redis over its REST API.
|
|
112
|
+
*
|
|
113
|
+
* Uses `SET key value NX EX ttl`, which is a genuine atomic compare-and-set on
|
|
114
|
+
* the Redis server — the burn is linearized across every instance, which is the
|
|
115
|
+
* whole property that makes it safe on serverless. Reads `UPSTASH_REDIS_REST_URL`
|
|
116
|
+
* and `UPSTASH_REDIS_REST_TOKEN` from the environment when not passed.
|
|
117
|
+
*
|
|
118
|
+
* Zero dependencies: it is one HTTP call, so it needs no Redis client library.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* import { paywall, upstashClaimStore } from "@furlpay/gateway";
|
|
122
|
+
* const pay = paywall({ price: 0.01, payTo, quoteSecret, claimStore: upstashClaimStore() });
|
|
123
|
+
*/
|
|
124
|
+
export function upstashClaimStore(opts: UpstashClaimStoreOptions = {}): ClaimStore {
|
|
125
|
+
const url = (opts.url ?? process.env.UPSTASH_REDIS_REST_URL ?? "").replace(/\/+$/, "");
|
|
126
|
+
const token = opts.token ?? process.env.UPSTASH_REDIS_REST_TOKEN ?? "";
|
|
127
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
128
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
129
|
+
|
|
130
|
+
if (!url || !token) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"@furlpay/gateway: upstashClaimStore() needs UPSTASH_REDIS_REST_URL and " +
|
|
133
|
+
"UPSTASH_REDIS_REST_TOKEN (or explicit url/token). Refusing to start with " +
|
|
134
|
+
"undurable replay protection that looks durable."
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return claimStoreFrom(async (key, ttlSeconds) => {
|
|
139
|
+
const ctl = new AbortController();
|
|
140
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
141
|
+
try {
|
|
142
|
+
// Pipeline-free single command: ["SET", key, "1", "NX", "EX", ttl]
|
|
143
|
+
const res = await doFetch(url, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
146
|
+
body: JSON.stringify(["SET", key, "1", "NX", "EX", String(ttlSeconds)]),
|
|
147
|
+
signal: ctl.signal,
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) return false; // fail closed
|
|
150
|
+
const body = (await res.json()) as { result?: string | null };
|
|
151
|
+
// Redis returns "OK" when the key was set, null when it already existed.
|
|
152
|
+
return body?.result === "OK";
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
}, true);
|
|
157
|
+
}
|