@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.
@@ -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
+ }
@@ -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
+ }