@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/dist/protocol.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
/** Default dialect for freshly-minted quotes when the client hasn't spoken yet. */
|
|
3
|
+
export const LATEST_X402_VERSION = 2;
|
|
4
|
+
// ── headers ───────────────────────────────────────────────────────────────
|
|
5
|
+
export const HEADER_V1_PAYMENT = "x-payment";
|
|
6
|
+
export const HEADER_V1_RECEIPT = "X-PAYMENT-RESPONSE";
|
|
7
|
+
export const HEADER_V2_PAYMENT = "payment-signature";
|
|
8
|
+
export const HEADER_V2_RECEIPT = "PAYMENT-RESPONSE";
|
|
9
|
+
export const HEADER_V2_REQUIRED = "PAYMENT-REQUIRED";
|
|
10
|
+
/** Web-layer hardening: cap the header before we spend cycles parsing it. */
|
|
11
|
+
export const MAX_HEADER_BYTES = 8192;
|
|
12
|
+
export const DEFAULT_QUOTE_TTL_SECONDS = 300;
|
|
13
|
+
/** Must outlive any EIP-3009 validity window, or a late replay could re-claim a
|
|
14
|
+
* freed key. */
|
|
15
|
+
export const REPLAY_CLAIM_TTL_SECONDS = 86_400;
|
|
16
|
+
/** Canonical (native-issuance, not bridged) USDC per network. */
|
|
17
|
+
export const NETWORK_USDC = {
|
|
18
|
+
arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
19
|
+
"arbitrum-sepolia": "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
|
|
20
|
+
base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
21
|
+
"base-sepolia": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
22
|
+
polygon: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
23
|
+
solana: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
24
|
+
};
|
|
25
|
+
export const USDC_DECIMALS = 6;
|
|
26
|
+
// ── cache hygiene ─────────────────────────────────────────────────────────
|
|
27
|
+
/** A paid 200 that a CDN caches is a paid resource served free to the next
|
|
28
|
+
* unpaid caller. `Vary` covers both dialects' request headers. */
|
|
29
|
+
export const noStoreHeaders = {
|
|
30
|
+
"Cache-Control": "no-store, private, max-age=0",
|
|
31
|
+
Vary: "X-PAYMENT, PAYMENT-SIGNATURE",
|
|
32
|
+
};
|
|
33
|
+
// ── value-scaled settlement depth ─────────────────────────────────────────
|
|
34
|
+
export function requiredConfirmations(amountAtomic) {
|
|
35
|
+
let usd;
|
|
36
|
+
try {
|
|
37
|
+
usd = Number(BigInt(amountAtomic)) / 10 ** USDC_DECIMALS;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return 12; // unparseable → demand the strongest gate
|
|
41
|
+
}
|
|
42
|
+
if (usd < 1)
|
|
43
|
+
return 3;
|
|
44
|
+
if (usd < 10)
|
|
45
|
+
return 6;
|
|
46
|
+
return 12;
|
|
47
|
+
}
|
|
48
|
+
export function atomicAmount(priceUsd, decimals = USDC_DECIMALS) {
|
|
49
|
+
if (!Number.isFinite(priceUsd) || priceUsd <= 0) {
|
|
50
|
+
throw new RangeError(`price must be a positive finite number (got ${priceUsd})`);
|
|
51
|
+
}
|
|
52
|
+
return String(Math.round(priceUsd * 10 ** decimals));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* HMAC binding a quote to exactly one (method, resource, amount) — and, when a
|
|
56
|
+
* body is present, exactly one body.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately version-independent: the same MAC validates a v1 or a v2 payment,
|
|
59
|
+
* because both dialects carry the quote in `extra`. It is also byte-identical to
|
|
60
|
+
* apps/web/src/lib/x402.ts, so quotes stay interchangeable with FurlPay's
|
|
61
|
+
* first-party resources.
|
|
62
|
+
*/
|
|
63
|
+
export function bindingMac(secret, input) {
|
|
64
|
+
const base = `${input.method} ${input.resource} ${input.amount} ${input.quoteId} ${input.expiresAt}`;
|
|
65
|
+
const message = input.bodyHash ? `${base} ${input.bodyHash}` : base;
|
|
66
|
+
return crypto.createHmac("sha256", secret).update(message).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
export function hashBody(body) {
|
|
69
|
+
return crypto.createHash("sha256").update(body).digest("hex");
|
|
70
|
+
}
|
|
71
|
+
export function timingSafeEqualHex(a, b) {
|
|
72
|
+
const ba = Buffer.from(a, "utf8");
|
|
73
|
+
const bb = Buffer.from(b, "utf8");
|
|
74
|
+
if (ba.length !== bb.length)
|
|
75
|
+
return false;
|
|
76
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
77
|
+
}
|
|
78
|
+
// ── codec ─────────────────────────────────────────────────────────────────
|
|
79
|
+
export function b64encode(value) {
|
|
80
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
81
|
+
}
|
|
82
|
+
export function b64decode(value) {
|
|
83
|
+
return JSON.parse(Buffer.from(value, "base64").toString("utf8"));
|
|
84
|
+
}
|
|
85
|
+
/** Which dialect did the client speak? `null` when it presented no payment. */
|
|
86
|
+
export function detectVersion(get) {
|
|
87
|
+
if (get(HEADER_V2_PAYMENT))
|
|
88
|
+
return 2;
|
|
89
|
+
if (get(HEADER_V1_PAYMENT))
|
|
90
|
+
return 1;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function validAuthorization(auth) {
|
|
94
|
+
if (!auth || typeof auth !== "object")
|
|
95
|
+
return false;
|
|
96
|
+
const a = auth;
|
|
97
|
+
return ["from", "to", "value", "validAfter", "validBefore", "nonce"].every((f) => typeof a[f] === "string" && a[f].length > 0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Strict parse of whichever payment header is present, normalized to one shape.
|
|
101
|
+
* Malformed input is an error, never a silently-released resource.
|
|
102
|
+
*/
|
|
103
|
+
export function decodePayment(get) {
|
|
104
|
+
const version = detectVersion(get);
|
|
105
|
+
if (version === null)
|
|
106
|
+
return { ok: false, error: "Payment required" };
|
|
107
|
+
const raw = version === 2 ? get(HEADER_V2_PAYMENT) : get(HEADER_V1_PAYMENT);
|
|
108
|
+
if (raw.length > MAX_HEADER_BYTES)
|
|
109
|
+
return { ok: false, error: "Payment header too large" };
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = b64decode(raw);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { ok: false, error: "Payment header is not valid base64 JSON" };
|
|
116
|
+
}
|
|
117
|
+
if (version === 2) {
|
|
118
|
+
const p = parsed;
|
|
119
|
+
if (p?.x402Version !== 2)
|
|
120
|
+
return { ok: false, error: "Unsupported x402 version" };
|
|
121
|
+
if (!p.accepted || typeof p.accepted !== "object") {
|
|
122
|
+
return { ok: false, error: "Malformed v2 payload (missing `accepted`)" };
|
|
123
|
+
}
|
|
124
|
+
if (typeof p.payload?.signature !== "string" || !validAuthorization(p.payload?.authorization)) {
|
|
125
|
+
return { ok: false, error: "Malformed v2 payload (bad signature/authorization)" };
|
|
126
|
+
}
|
|
127
|
+
const extra = (p.accepted.extra ?? {});
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
payment: {
|
|
131
|
+
version: 2,
|
|
132
|
+
scheme: p.accepted.scheme,
|
|
133
|
+
network: p.accepted.network,
|
|
134
|
+
signature: p.payload.signature,
|
|
135
|
+
authorization: p.payload.authorization,
|
|
136
|
+
quoteId: extra.quoteId,
|
|
137
|
+
binding: extra.binding,
|
|
138
|
+
expiresAt: extra.expiresAt,
|
|
139
|
+
bodyHash: extra.bodyHash,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const p = parsed;
|
|
144
|
+
if (p?.x402Version !== 1)
|
|
145
|
+
return { ok: false, error: "Unsupported x402 version" };
|
|
146
|
+
if (typeof p.payload?.signature !== "string" || !validAuthorization(p.payload?.authorization)) {
|
|
147
|
+
return { ok: false, error: "Malformed v1 payload (bad signature/authorization)" };
|
|
148
|
+
}
|
|
149
|
+
const extra = (p.extra ?? {});
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
payment: {
|
|
153
|
+
version: 1,
|
|
154
|
+
scheme: p.scheme,
|
|
155
|
+
network: p.network,
|
|
156
|
+
signature: p.payload.signature,
|
|
157
|
+
authorization: p.payload.authorization,
|
|
158
|
+
quoteId: extra.quoteId,
|
|
159
|
+
binding: extra.binding,
|
|
160
|
+
expiresAt: extra.expiresAt,
|
|
161
|
+
bodyHash: extra.bodyHash,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** v1 receipt — FurlPay's own X-PAYMENT-RESPONSE shape. */
|
|
166
|
+
export function encodeReceiptV1(receipt) {
|
|
167
|
+
return b64encode(receipt);
|
|
168
|
+
}
|
|
169
|
+
/** v2 receipt — the reference SDK's SettleResponse shape. */
|
|
170
|
+
export function encodeReceiptV2(receipt) {
|
|
171
|
+
return b64encode({
|
|
172
|
+
success: true,
|
|
173
|
+
transaction: receipt.transaction,
|
|
174
|
+
network: receipt.network,
|
|
175
|
+
payer: receipt.payer,
|
|
176
|
+
amount: receipt.amountAtomic,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function decodeReceipt(header) {
|
|
180
|
+
try {
|
|
181
|
+
return b64decode(header);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface ClaimStore {
|
|
2
|
+
/**
|
|
3
|
+
* Atomically claim `key`. Returns true iff THIS caller is the first to claim
|
|
4
|
+
* it. Must be a genuine compare-and-set — a get-then-set is a race, and the
|
|
5
|
+
* race is the vulnerability.
|
|
6
|
+
*/
|
|
7
|
+
claim(key: string, ttlSeconds: number): Promise<boolean>;
|
|
8
|
+
/** False if claims are not shared across instances. */
|
|
9
|
+
readonly isDurable: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* In-memory claim store. Single instance only.
|
|
13
|
+
*
|
|
14
|
+
* Bounded so a flood of distinct quoteIds cannot grow it without limit; eviction
|
|
15
|
+
* is oldest-expiry-first, and expired entries are dropped lazily on access.
|
|
16
|
+
*/
|
|
17
|
+
export declare class MemoryClaimStore implements ClaimStore {
|
|
18
|
+
private readonly maxEntries;
|
|
19
|
+
readonly isDurable = false;
|
|
20
|
+
private readonly entries;
|
|
21
|
+
constructor(maxEntries?: number);
|
|
22
|
+
claim(key: string, ttlSeconds: number): Promise<boolean>;
|
|
23
|
+
private evict;
|
|
24
|
+
/** Test/introspection helper. */
|
|
25
|
+
get size(): number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Wrap any atomic set-if-not-exists primitive as a ClaimStore, without this
|
|
29
|
+
* package taking a Redis dependency.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* import { Redis } from "@upstash/redis";
|
|
33
|
+
* const redis = Redis.fromEnv();
|
|
34
|
+
* const store = claimStoreFrom(
|
|
35
|
+
* (key, ttl) => redis.set(key, 1, { nx: true, ex: ttl }).then((r) => r === "OK")
|
|
36
|
+
* );
|
|
37
|
+
*/
|
|
38
|
+
export declare function claimStoreFrom(setNx: (key: string, ttlSeconds: number) => Promise<boolean>, isDurable?: boolean): ClaimStore;
|
|
39
|
+
export interface UpstashClaimStoreOptions {
|
|
40
|
+
/** Upstash REST endpoint, e.g. https://eu2-xxx.upstash.io */
|
|
41
|
+
url?: string;
|
|
42
|
+
token?: string;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
fetchImpl?: typeof fetch;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Durable claim store backed by Upstash Redis over its REST API.
|
|
48
|
+
*
|
|
49
|
+
* Uses `SET key value NX EX ttl`, which is a genuine atomic compare-and-set on
|
|
50
|
+
* the Redis server — the burn is linearized across every instance, which is the
|
|
51
|
+
* whole property that makes it safe on serverless. Reads `UPSTASH_REDIS_REST_URL`
|
|
52
|
+
* and `UPSTASH_REDIS_REST_TOKEN` from the environment when not passed.
|
|
53
|
+
*
|
|
54
|
+
* Zero dependencies: it is one HTTP call, so it needs no Redis client library.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* import { paywall, upstashClaimStore } from "@furlpay/gateway";
|
|
58
|
+
* const pay = paywall({ price: 0.01, payTo, quoteSecret, claimStore: upstashClaimStore() });
|
|
59
|
+
*/
|
|
60
|
+
export declare function upstashClaimStore(opts?: UpstashClaimStoreOptions): ClaimStore;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
* In-memory claim store. Single instance only.
|
|
21
|
+
*
|
|
22
|
+
* Bounded so a flood of distinct quoteIds cannot grow it without limit; eviction
|
|
23
|
+
* is oldest-expiry-first, and expired entries are dropped lazily on access.
|
|
24
|
+
*/
|
|
25
|
+
export class MemoryClaimStore {
|
|
26
|
+
maxEntries;
|
|
27
|
+
isDurable = false;
|
|
28
|
+
entries = new Map(); // key → expiry (ms)
|
|
29
|
+
constructor(maxEntries = 100_000) {
|
|
30
|
+
this.maxEntries = maxEntries;
|
|
31
|
+
}
|
|
32
|
+
async claim(key, ttlSeconds) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const existing = this.entries.get(key);
|
|
35
|
+
if (existing !== undefined && existing > now)
|
|
36
|
+
return false; // already burned
|
|
37
|
+
if (existing !== undefined)
|
|
38
|
+
this.entries.delete(key); // expired → reclaimable
|
|
39
|
+
if (this.entries.size >= this.maxEntries)
|
|
40
|
+
this.evict(now);
|
|
41
|
+
this.entries.set(key, now + ttlSeconds * 1000);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
evict(now) {
|
|
45
|
+
for (const [k, expiry] of this.entries) {
|
|
46
|
+
if (expiry <= now)
|
|
47
|
+
this.entries.delete(k);
|
|
48
|
+
}
|
|
49
|
+
// Still full of live entries — drop the soonest-to-expire to stay bounded.
|
|
50
|
+
if (this.entries.size >= this.maxEntries) {
|
|
51
|
+
const oldest = [...this.entries.entries()].sort((a, b) => a[1] - b[1]);
|
|
52
|
+
const drop = Math.ceil(this.maxEntries * 0.1);
|
|
53
|
+
for (let i = 0; i < drop && i < oldest.length; i++)
|
|
54
|
+
this.entries.delete(oldest[i][0]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Test/introspection helper. */
|
|
58
|
+
get size() {
|
|
59
|
+
return this.entries.size;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wrap any atomic set-if-not-exists primitive as a ClaimStore, without this
|
|
64
|
+
* package taking a Redis dependency.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* import { Redis } from "@upstash/redis";
|
|
68
|
+
* const redis = Redis.fromEnv();
|
|
69
|
+
* const store = claimStoreFrom(
|
|
70
|
+
* (key, ttl) => redis.set(key, 1, { nx: true, ex: ttl }).then((r) => r === "OK")
|
|
71
|
+
* );
|
|
72
|
+
*/
|
|
73
|
+
export function claimStoreFrom(setNx, isDurable = true) {
|
|
74
|
+
return {
|
|
75
|
+
isDurable,
|
|
76
|
+
async claim(key, ttlSeconds) {
|
|
77
|
+
try {
|
|
78
|
+
return await setNx(key, ttlSeconds);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A claim store we cannot reach cannot prove single-use. Refusing the
|
|
82
|
+
// payment loses a sale; granting it risks unbounded duplicate delivery
|
|
83
|
+
// on a single authorization. Refuse.
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Durable claim store backed by Upstash Redis over its REST API.
|
|
91
|
+
*
|
|
92
|
+
* Uses `SET key value NX EX ttl`, which is a genuine atomic compare-and-set on
|
|
93
|
+
* the Redis server — the burn is linearized across every instance, which is the
|
|
94
|
+
* whole property that makes it safe on serverless. Reads `UPSTASH_REDIS_REST_URL`
|
|
95
|
+
* and `UPSTASH_REDIS_REST_TOKEN` from the environment when not passed.
|
|
96
|
+
*
|
|
97
|
+
* Zero dependencies: it is one HTTP call, so it needs no Redis client library.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* import { paywall, upstashClaimStore } from "@furlpay/gateway";
|
|
101
|
+
* const pay = paywall({ price: 0.01, payTo, quoteSecret, claimStore: upstashClaimStore() });
|
|
102
|
+
*/
|
|
103
|
+
export function upstashClaimStore(opts = {}) {
|
|
104
|
+
const url = (opts.url ?? process.env.UPSTASH_REDIS_REST_URL ?? "").replace(/\/+$/, "");
|
|
105
|
+
const token = opts.token ?? process.env.UPSTASH_REDIS_REST_TOKEN ?? "";
|
|
106
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
107
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
108
|
+
if (!url || !token) {
|
|
109
|
+
throw new Error("@furlpay/gateway: upstashClaimStore() needs UPSTASH_REDIS_REST_URL and " +
|
|
110
|
+
"UPSTASH_REDIS_REST_TOKEN (or explicit url/token). Refusing to start with " +
|
|
111
|
+
"undurable replay protection that looks durable.");
|
|
112
|
+
}
|
|
113
|
+
return claimStoreFrom(async (key, ttlSeconds) => {
|
|
114
|
+
const ctl = new AbortController();
|
|
115
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
116
|
+
try {
|
|
117
|
+
// Pipeline-free single command: ["SET", key, "1", "NX", "EX", ttl]
|
|
118
|
+
const res = await doFetch(url, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify(["SET", key, "1", "NX", "EX", String(ttlSeconds)]),
|
|
122
|
+
signal: ctl.signal,
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
return false; // fail closed
|
|
126
|
+
const body = (await res.json());
|
|
127
|
+
// Redis returns "OK" when the key was set, null when it already existed.
|
|
128
|
+
return body?.result === "OK";
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
}, true);
|
|
134
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@furlpay/gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-hostable x402 monetization gateway — paywall any API, MCP tool, dataset or model endpoint with stablecoin pay-per-call. Framework-agnostic, zero runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"furlpay-gateway": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": ["dist", "src", "LICENSE", "SECURITY.md"],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./express": {
|
|
19
|
+
"types": "./dist/adapters/express.d.ts",
|
|
20
|
+
"import": "./dist/adapters/express.js"
|
|
21
|
+
},
|
|
22
|
+
"./proxy": {
|
|
23
|
+
"types": "./dist/adapters/proxy.d.ts",
|
|
24
|
+
"import": "./dist/adapters/proxy.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"test": "npm run build && node --test test/protocol.test.js test/gateway.test.js"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"x402",
|
|
33
|
+
"monetization",
|
|
34
|
+
"paywall",
|
|
35
|
+
"agentic-payments",
|
|
36
|
+
"stablecoin",
|
|
37
|
+
"usdc",
|
|
38
|
+
"mcp",
|
|
39
|
+
"api-monetization",
|
|
40
|
+
"pay-per-call",
|
|
41
|
+
"facilitator"
|
|
42
|
+
],
|
|
43
|
+
"repository": { "type": "git", "url": "https://github.com/furlpay/furlpay-gateway" },
|
|
44
|
+
"homepage": "https://furlpay.com",
|
|
45
|
+
"engines": { "node": ">=18" },
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"typescript": "5.5.3",
|
|
48
|
+
"@types/node": "20.14.10"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": { "access": "public" }
|
|
51
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Gateway, type GatewayOptions } from "../gateway.js";
|
|
2
|
+
import type { SettlementReceipt } from "../protocol.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Express / Connect adapter.
|
|
6
|
+
//
|
|
7
|
+
// Structurally typed against Express rather than importing it, so this package
|
|
8
|
+
// keeps its zero-runtime-dependency promise and works with Connect, Polka, and
|
|
9
|
+
// anything else with the same middleware shape.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
interface ExpressLikeRequest {
|
|
13
|
+
method: string;
|
|
14
|
+
originalUrl?: string;
|
|
15
|
+
url: string;
|
|
16
|
+
headers: Record<string, string | string[] | undefined>;
|
|
17
|
+
protocol?: string;
|
|
18
|
+
secure?: boolean;
|
|
19
|
+
get?(name: string): string | undefined;
|
|
20
|
+
/** Raw body bytes — see `captureRawBody`. */
|
|
21
|
+
rawBody?: Buffer | string;
|
|
22
|
+
/** Set by this middleware once payment is confirmed. */
|
|
23
|
+
payment?: SettlementReceipt;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ExpressLikeResponse {
|
|
27
|
+
status(code: number): ExpressLikeResponse;
|
|
28
|
+
set(field: string, value: string): ExpressLikeResponse;
|
|
29
|
+
json(body: unknown): unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Next = (err?: unknown) => void;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `express.json()` discards the raw bytes, but body binding needs to hash
|
|
36
|
+
* exactly what the client sent — a re-serialized `req.body` is a different byte
|
|
37
|
+
* string (key order, whitespace) and would hash differently every time.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* app.use(express.json({ verify: captureRawBody }));
|
|
41
|
+
*/
|
|
42
|
+
export function captureRawBody(req: ExpressLikeRequest, _res: unknown, buf: Buffer): void {
|
|
43
|
+
req.rawBody = buf;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function absoluteUrl(req: ExpressLikeRequest): string {
|
|
47
|
+
const headerHost = req.get?.("host") ?? (req.headers.host as string | undefined);
|
|
48
|
+
const host = headerHost ?? "localhost";
|
|
49
|
+
const proto = req.secure ? "https" : (req.protocol ?? "http");
|
|
50
|
+
return `${proto}://${host}${req.originalUrl ?? req.url}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function flatHeaders(req: ExpressLikeRequest): Record<string, string> {
|
|
54
|
+
const out: Record<string, string> = {};
|
|
55
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
56
|
+
if (typeof v === "string") out[k.toLowerCase()] = v;
|
|
57
|
+
else if (Array.isArray(v)) out[k.toLowerCase()] = v[0] ?? "";
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Express middleware that paywalls everything mounted after it.
|
|
66
|
+
*
|
|
67
|
+
* On success `req.payment` holds the settlement receipt, so the route handler
|
|
68
|
+
* can meter, log, or attribute the call to the paying agent.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* app.get("/api/weather", expressPaywall({ ... }), (req, res) => {
|
|
72
|
+
* res.json({ tempC: 17, paidBy: req.payment.payer });
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export function expressPaywall(opts: GatewayOptions) {
|
|
76
|
+
const gateway = new Gateway(opts);
|
|
77
|
+
const bindBody = opts.bindBody !== false;
|
|
78
|
+
|
|
79
|
+
return async function middleware(
|
|
80
|
+
req: ExpressLikeRequest,
|
|
81
|
+
res: ExpressLikeResponse,
|
|
82
|
+
next: Next
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const method = req.method.toUpperCase();
|
|
86
|
+
|
|
87
|
+
// Fail closed rather than silently downgrade: if body binding is on and we
|
|
88
|
+
// cannot see the raw bytes, a payment for one body would unlock any other.
|
|
89
|
+
if (bindBody && BODY_METHODS.has(method) && req.rawBody === undefined) {
|
|
90
|
+
res
|
|
91
|
+
.status(500)
|
|
92
|
+
.set("Cache-Control", "no-store")
|
|
93
|
+
.json({
|
|
94
|
+
error:
|
|
95
|
+
"gateway_misconfigured: body binding is enabled but req.rawBody is unset. " +
|
|
96
|
+
"Add `app.use(express.json({ verify: captureRawBody }))`, or pass `bindBody: false` " +
|
|
97
|
+
"to accept that a payment for one body unlocks any other.",
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const decision = await gateway.authorize({
|
|
103
|
+
method,
|
|
104
|
+
url: absoluteUrl(req),
|
|
105
|
+
headers: flatHeaders(req),
|
|
106
|
+
body: req.rawBody,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (decision.kind !== "allow") {
|
|
110
|
+
for (const [k, v] of Object.entries(decision.headers)) res.set(k, v);
|
|
111
|
+
res.status(decision.status).json(decision.body);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [k, v] of Object.entries(decision.headers)) res.set(k, v);
|
|
116
|
+
req.payment = decision.receipt;
|
|
117
|
+
next();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
next(err);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Gateway, type GatewayDecision, type GatewayOptions } from "../gateway.js";
|
|
2
|
+
import type { SettlementReceipt } from "../protocol.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Web-standard adapter — Next.js App Router, Hono, Cloudflare Workers, Deno,
|
|
6
|
+
// Bun, and anything else that speaks Request → Response.
|
|
7
|
+
//
|
|
8
|
+
// On Workers, node:crypto requires the `nodejs_compat` compatibility flag.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export type FetchHandler<Ctx = unknown> = (
|
|
12
|
+
req: Request,
|
|
13
|
+
ctx: Ctx
|
|
14
|
+
) => Response | Promise<Response>;
|
|
15
|
+
|
|
16
|
+
/** A handler that also receives the settlement receipt for the request. */
|
|
17
|
+
export type PaidHandler<Ctx = unknown> = (
|
|
18
|
+
req: Request,
|
|
19
|
+
ctx: Ctx,
|
|
20
|
+
receipt: SettlementReceipt
|
|
21
|
+
) => Response | Promise<Response>;
|
|
22
|
+
|
|
23
|
+
function toResponse(decision: Exclude<GatewayDecision, { kind: "allow" }>): Response {
|
|
24
|
+
return new Response(JSON.stringify(decision.body), {
|
|
25
|
+
status: decision.status,
|
|
26
|
+
headers: decision.headers,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read the body without consuming it for the wrapped handler. */
|
|
31
|
+
async function readBody(req: Request): Promise<string | undefined> {
|
|
32
|
+
if (!req.body) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
return await req.clone().text();
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a fetch-style handler so it only runs after payment.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* // app/api/weather/route.ts
|
|
45
|
+
* import { paywall } from "@furlpay/gateway";
|
|
46
|
+
*
|
|
47
|
+
* const pay = paywall({
|
|
48
|
+
* price: 0.01,
|
|
49
|
+
* payTo: process.env.MERCHANT_WALLET!,
|
|
50
|
+
* quoteSecret: process.env.QUOTE_SECRET!,
|
|
51
|
+
* network: "base",
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* export const GET = pay(async () => Response.json({ tempC: 17 }));
|
|
55
|
+
*/
|
|
56
|
+
export function paywall(opts: GatewayOptions) {
|
|
57
|
+
const gateway = new Gateway(opts);
|
|
58
|
+
|
|
59
|
+
function wrap<Ctx = unknown>(handler: PaidHandler<Ctx>): FetchHandler<Ctx> {
|
|
60
|
+
return async (req: Request, ctx: Ctx): Promise<Response> => {
|
|
61
|
+
const decision = await gateway.authorize({
|
|
62
|
+
method: req.method,
|
|
63
|
+
url: req.url,
|
|
64
|
+
headers: req.headers,
|
|
65
|
+
body: await readBody(req),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (decision.kind !== "allow") return toResponse(decision);
|
|
69
|
+
|
|
70
|
+
const res = await handler(req, ctx, decision.receipt);
|
|
71
|
+
|
|
72
|
+
// Stamp the receipt and no-store onto whatever the handler returned. A
|
|
73
|
+
// paid 200 that a CDN caches is a paid resource served free to the next
|
|
74
|
+
// unpaid caller, so this is not optional.
|
|
75
|
+
const out = new Response(res.body, res);
|
|
76
|
+
for (const [k, v] of Object.entries(decision.headers)) out.headers.set(k, v);
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
wrap.gateway = gateway;
|
|
82
|
+
return wrap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Escape hatch for hand-rolled routing: get the decision, do what you like. */
|
|
86
|
+
export function createGateway(opts: GatewayOptions): Gateway {
|
|
87
|
+
return new Gateway(opts);
|
|
88
|
+
}
|