@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,157 @@
1
+ import http from "node:http";
2
+ import { Gateway, type GatewayOptions } from "../gateway.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Reverse-proxy mode.
6
+ //
7
+ // Put a paywall in front of an origin you do not want to modify — a legacy API,
8
+ // a model server, an MCP server, a static dataset. The origin stays untouched
9
+ // and never learns that it is being monetized.
10
+ //
11
+ // furlpay-gateway expose http://localhost:8000 --price 0.01
12
+ //
13
+ // The origin MUST NOT be publicly reachable, or agents will simply route around
14
+ // the paywall. Bind it to loopback, or firewall it to this process.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface ProxyOptions extends GatewayOptions {
18
+ /** Origin to forward paid requests to, e.g. "http://localhost:8000". */
19
+ origin: string;
20
+ port?: number;
21
+ hostname?: string;
22
+ /** Paths served free (health checks, docs, openapi.json). Exact or prefix*. */
23
+ freePaths?: string[];
24
+ /** Public base URL, when behind a TLS terminator. Resource identity — and so
25
+ * the quote binding — is derived from this, and it must match what the agent
26
+ * actually requested or every binding check fails. */
27
+ publicUrl?: string;
28
+ }
29
+
30
+ /** Headers that are meaningful only for a single hop and must not be forwarded. */
31
+ const HOP_BY_HOP = new Set([
32
+ "connection",
33
+ "keep-alive",
34
+ "proxy-authenticate",
35
+ "proxy-authorization",
36
+ "te",
37
+ "trailer",
38
+ "transfer-encoding",
39
+ "upgrade",
40
+ "host",
41
+ "content-length",
42
+ ]);
43
+
44
+ function isFree(pathname: string, freePaths: string[]): boolean {
45
+ return freePaths.some((p) =>
46
+ p.endsWith("*") ? pathname.startsWith(p.slice(0, -1)) : pathname === p
47
+ );
48
+ }
49
+
50
+ function readRequestBody(req: http.IncomingMessage): Promise<Buffer> {
51
+ return new Promise((resolve, reject) => {
52
+ const chunks: Buffer[] = [];
53
+ req.on("data", (c: Buffer) => chunks.push(c));
54
+ req.on("end", () => resolve(Buffer.concat(chunks)));
55
+ req.on("error", reject);
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Build (but do not start) the paywalling reverse proxy.
61
+ * Returns a standard `http.Server` — call `.listen()` yourself, or use
62
+ * `startProxy` which does it for you.
63
+ */
64
+ export function createProxy(opts: ProxyOptions): http.Server {
65
+ const gateway = new Gateway(opts);
66
+ const origin = opts.origin.replace(/\/+$/, "");
67
+ const freePaths = opts.freePaths ?? [];
68
+
69
+ return http.createServer(async (req, res) => {
70
+ const send = (status: number, body: unknown, headers: Record<string, string> = {}) => {
71
+ const payload = JSON.stringify(body);
72
+ res.writeHead(status, {
73
+ "Content-Type": "application/json",
74
+ "Content-Length": Buffer.byteLength(payload),
75
+ ...headers,
76
+ });
77
+ res.end(payload);
78
+ };
79
+
80
+ try {
81
+ const base = opts.publicUrl ?? `http://${req.headers.host ?? "localhost"}`;
82
+ const url = new URL(req.url ?? "/", base);
83
+ const method = (req.method ?? "GET").toUpperCase();
84
+ const body = await readRequestBody(req);
85
+
86
+ if (!isFree(url.pathname, freePaths)) {
87
+ const decision = await gateway.authorize({
88
+ method,
89
+ url,
90
+ headers: req.headers as Record<string, string | undefined>,
91
+ body: body.length ? body : undefined,
92
+ });
93
+ if (decision.kind !== "allow") {
94
+ send(decision.status, decision.body, decision.headers);
95
+ return;
96
+ }
97
+ // Both dialects' receipts, plus the cache guards.
98
+ for (const [k, v] of Object.entries(decision.headers)) res.setHeader(k, v);
99
+ }
100
+
101
+ // Forward to the origin.
102
+ const forwardHeaders: Record<string, string> = {};
103
+ for (const [k, v] of Object.entries(req.headers)) {
104
+ const key = k.toLowerCase();
105
+ if (HOP_BY_HOP.has(key)) continue;
106
+ // The origin has no use for the payment credential, in either dialect.
107
+ if (key === "x-payment" || key === "payment-signature") continue;
108
+ if (typeof v === "string") forwardHeaders[k] = v;
109
+ else if (Array.isArray(v)) forwardHeaders[k] = v.join(", ");
110
+ }
111
+
112
+ const upstream = await fetch(`${origin}${url.pathname}${url.search}`, {
113
+ method,
114
+ headers: forwardHeaders,
115
+ body: method === "GET" || method === "HEAD" ? undefined : body,
116
+ });
117
+
118
+ upstream.headers.forEach((v, k) => {
119
+ const key = k.toLowerCase();
120
+ if (HOP_BY_HOP.has(key)) return;
121
+ // Never let the origin's cache policy override ours on a paid response.
122
+ if (key === "cache-control" || key === "vary") return;
123
+ res.setHeader(k, v);
124
+ });
125
+ res.writeHead(upstream.status);
126
+ const buf = Buffer.from(await upstream.arrayBuffer());
127
+ res.end(buf);
128
+ } catch (e) {
129
+ send(502, { error: "origin_unreachable", detail: (e as Error).message });
130
+ }
131
+ });
132
+ }
133
+
134
+ export interface RunningProxy {
135
+ server: http.Server;
136
+ port: number;
137
+ close(): Promise<void>;
138
+ }
139
+
140
+ export function startProxy(opts: ProxyOptions): Promise<RunningProxy> {
141
+ const server = createProxy(opts);
142
+ const port = opts.port ?? 4402;
143
+ const hostname = opts.hostname ?? "0.0.0.0";
144
+
145
+ return new Promise((resolve, reject) => {
146
+ server.once("error", reject);
147
+ server.listen(port, hostname, () => {
148
+ const addr = server.address();
149
+ const actual = typeof addr === "object" && addr ? addr.port : port;
150
+ resolve({
151
+ server,
152
+ port: actual,
153
+ close: () => new Promise<void>((r) => server.close(() => r())),
154
+ });
155
+ });
156
+ });
157
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import { startProxy } from "./adapters/proxy.js";
4
+ import { NETWORK_USDC } from "./protocol.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // furlpay-gateway — put a stablecoin paywall in front of anything, in one line.
8
+ //
9
+ // furlpay-gateway expose http://localhost:8000 --price 0.01 --pay-to 0xabc...
10
+ // furlpay-gateway secret
11
+ // ---------------------------------------------------------------------------
12
+
13
+ interface Flags {
14
+ [key: string]: string | boolean;
15
+ }
16
+
17
+ function parseFlags(argv: string[]): { positional: string[]; flags: Flags } {
18
+ const positional: string[] = [];
19
+ const flags: Flags = {};
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (arg.startsWith("--")) {
23
+ const key = arg.slice(2);
24
+ const next = argv[i + 1];
25
+ if (next === undefined || next.startsWith("--")) {
26
+ flags[key] = true;
27
+ } else {
28
+ flags[key] = next;
29
+ i++;
30
+ }
31
+ } else {
32
+ positional.push(arg);
33
+ }
34
+ }
35
+ return { positional, flags };
36
+ }
37
+
38
+ const USAGE = `
39
+ furlpay-gateway — x402 monetization gateway
40
+
41
+ USAGE
42
+ furlpay-gateway expose <origin> [options]
43
+ furlpay-gateway secret
44
+
45
+ COMMANDS
46
+ expose <origin> Paywall an origin and serve it on a public port.
47
+ secret Generate a quote secret.
48
+
49
+ OPTIONS (expose)
50
+ --price <usd> Price per call in USD. (required)
51
+ --pay-to <address> Wallet that receives payment. (required, or PAY_TO)
52
+ --secret <hex> Quote HMAC secret. (or QUOTE_SECRET)
53
+ --network <name> ${Object.keys(NETWORK_USDC).join(", ")} (default: base)
54
+ --port <n> Listen port. (default: 4402)
55
+ --facilitator <url> x402 facilitator. (default: FurlPay)
56
+ --free <paths> Comma-separated free paths, e.g. /health,/docs*
57
+ --public-url <url> Public base URL if behind TLS termination.
58
+ --no-settle Verify only; do not settle on-chain. (unsafe)
59
+
60
+ ENVIRONMENT
61
+ PAY_TO, QUOTE_SECRET, FURLPAY_FACILITATOR
62
+
63
+ EXAMPLE
64
+ furlpay-gateway secret
65
+ export QUOTE_SECRET=<the value it printed>
66
+ furlpay-gateway expose http://localhost:8000 \\
67
+ --price 0.01 --pay-to 0xYourWallet --network base --free /health
68
+
69
+ SECURITY
70
+ The origin must NOT be publicly reachable, or agents will route around the
71
+ paywall. Bind it to loopback or firewall it to this process.
72
+ `;
73
+
74
+ function die(msg: string): never {
75
+ process.stderr.write(`error: ${msg}\n\nRun \`furlpay-gateway --help\` for usage.\n`);
76
+ process.exit(1);
77
+ }
78
+
79
+ async function main(): Promise<void> {
80
+ const { positional, flags } = parseFlags(process.argv.slice(2));
81
+ const command = positional[0];
82
+
83
+ if (!command || flags.help || flags.h) {
84
+ process.stdout.write(USAGE);
85
+ return;
86
+ }
87
+
88
+ if (command === "secret") {
89
+ process.stdout.write(crypto.randomBytes(32).toString("hex") + "\n");
90
+ return;
91
+ }
92
+
93
+ if (command !== "expose") die(`unknown command "${command}"`);
94
+
95
+ const origin = positional[1];
96
+ if (!origin) die("expose requires an origin, e.g. http://localhost:8000");
97
+ if (!/^https?:\/\//.test(origin)) die(`origin must start with http:// or https:// (got "${origin}")`);
98
+
99
+ const price = Number(flags.price);
100
+ if (!Number.isFinite(price) || price <= 0) die("--price must be a positive number of USD");
101
+
102
+ const payTo = (flags["pay-to"] as string) || process.env.PAY_TO;
103
+ if (!payTo) die("--pay-to (or PAY_TO) is required — nobody gets paid otherwise");
104
+
105
+ const quoteSecret = (flags.secret as string) || process.env.QUOTE_SECRET;
106
+ if (!quoteSecret) {
107
+ die("--secret (or QUOTE_SECRET) is required. Generate one: furlpay-gateway secret");
108
+ }
109
+
110
+ const network = (flags.network as string) || "base";
111
+ if (!NETWORK_USDC[network]) {
112
+ die(`unknown network "${network}" — one of: ${Object.keys(NETWORK_USDC).join(", ")}`);
113
+ }
114
+
115
+ const freePaths = typeof flags.free === "string" ? flags.free.split(",").map((s) => s.trim()) : [];
116
+ const port = flags.port ? Number(flags.port) : 4402;
117
+ const facilitator = (flags.facilitator as string) || process.env.FURLPAY_FACILITATOR;
118
+
119
+ const running = await startProxy({
120
+ origin,
121
+ port,
122
+ price,
123
+ payTo,
124
+ quoteSecret,
125
+ network,
126
+ freePaths,
127
+ settle: flags["no-settle"] !== true,
128
+ publicUrl: flags["public-url"] as string | undefined,
129
+ ...(facilitator ? { facilitator } : {}),
130
+ onSettled: (r) => {
131
+ process.stdout.write(
132
+ `paid $${(Number(r.amountAtomic) / 1e6).toFixed(6)} ${r.payer.slice(0, 10)}… ${r.resource}\n`
133
+ );
134
+ },
135
+ });
136
+
137
+ const lines = [
138
+ ``,
139
+ ` FurlPay Gateway`,
140
+ ``,
141
+ ` paywalling ${origin}`,
142
+ ` listening http://localhost:${running.port}`,
143
+ ` price $${price.toFixed(6)} per call`,
144
+ ` pay to ${payTo}`,
145
+ ` network ${network} (USDC ${NETWORK_USDC[network].slice(0, 10)}…)`,
146
+ ` facilitator ${facilitator ?? "furlpay.com (default)"}`,
147
+ freePaths.length ? ` free ${freePaths.join(", ")}` : ``,
148
+ flags["no-settle"] === true ? ` WARNING --no-settle: payments are verified but NOT collected` : ``,
149
+ ``,
150
+ ` Replay protection is in-memory: correct for ONE instance only.`,
151
+ ` Run more than one and pass a shared claim store (see README).`,
152
+ ``,
153
+ ].filter(Boolean);
154
+ process.stdout.write(lines.join("\n") + "\n");
155
+
156
+ const shutdown = async () => {
157
+ await running.close();
158
+ process.exit(0);
159
+ };
160
+ process.on("SIGINT", shutdown);
161
+ process.on("SIGTERM", shutdown);
162
+ }
163
+
164
+ main().catch((e) => die((e as Error).message));
@@ -0,0 +1,133 @@
1
+ import type {
2
+ PaymentPayloadV1,
3
+ PaymentPayloadV2,
4
+ PaymentRequirementsV1,
5
+ PaymentRequirementsV2,
6
+ } from "./protocol.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Facilitator client.
10
+ //
11
+ // The facilitator is the only party that touches a chain: it verifies the
12
+ // EIP-3009 authorization, submits it, and pays the gas. Keeping it behind an
13
+ // interface is the whole point — the gateway needs no private key, no RPC
14
+ // endpoint and no gas balance, which is what makes it safe to self-host.
15
+ //
16
+ // Default: FurlPay's facilitator. Any x402-compliant facilitator works; point
17
+ // `baseUrl` at Coinbase's and settlement goes through Coinbase, unchanged.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const FURLPAY_FACILITATOR = "https://furlpay.com/api/x402/facilitator";
21
+
22
+ /** The envelope is versioned: v1 and v2 carry different inner shapes. */
23
+ export type FacilitatorRequest =
24
+ | { x402Version: 1; paymentPayload: PaymentPayloadV1; paymentRequirements: PaymentRequirementsV1 }
25
+ | { x402Version: 2; paymentPayload: PaymentPayloadV2; paymentRequirements: PaymentRequirementsV2 };
26
+
27
+ export interface VerifyResult {
28
+ isValid: boolean;
29
+ payer?: string;
30
+ invalidReason?: string;
31
+ invalidMessage?: string;
32
+ }
33
+
34
+ export interface SettleResult {
35
+ success: boolean;
36
+ transaction?: string;
37
+ network: string;
38
+ payer?: string;
39
+ errorReason?: string;
40
+ errorMessage?: string;
41
+ /** Actual amount settled. v2's `upto` scheme can settle LESS than the
42
+ * authorized maximum, so the receipt must report this, not the authorization. */
43
+ amount?: string;
44
+ confirmations?: number;
45
+ requiredConfirmations?: number;
46
+ }
47
+
48
+ export interface SupportedKind {
49
+ x402Version: number;
50
+ scheme: string;
51
+ network: string;
52
+ }
53
+
54
+ export interface FacilitatorClient {
55
+ verify(req: FacilitatorRequest): Promise<VerifyResult>;
56
+ settle(req: FacilitatorRequest): Promise<SettleResult>;
57
+ supported(): Promise<SupportedKind[]>;
58
+ }
59
+
60
+ export interface HttpFacilitatorOptions {
61
+ apiKey?: string;
62
+ /** A hung facilitator must not hang the origin. */
63
+ timeoutMs?: number;
64
+ fetchImpl?: typeof fetch;
65
+ }
66
+
67
+ /**
68
+ * HTTP facilitator client.
69
+ *
70
+ * Every failure path fails CLOSED: a timeout, a non-2xx, or an unparseable body
71
+ * resolves to "not valid" / "not settled", never to a released resource. A
72
+ * facilitator you cannot reach must cost you a sale, not the resource.
73
+ */
74
+ export function httpFacilitator(
75
+ baseUrl: string = FURLPAY_FACILITATOR,
76
+ opts: HttpFacilitatorOptions = {}
77
+ ): FacilitatorClient {
78
+ const base = baseUrl.replace(/\/+$/, "");
79
+ const timeoutMs = opts.timeoutMs ?? 10_000;
80
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
81
+
82
+ if (typeof doFetch !== "function") {
83
+ throw new Error("No fetch implementation available — pass `fetchImpl` (Node >=18 has a global fetch).");
84
+ }
85
+
86
+ async function post<T>(path: string, body: unknown, onError: (reason: string) => T): Promise<T> {
87
+ const ctl = new AbortController();
88
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
89
+ try {
90
+ const res = await doFetch(`${base}${path}`, {
91
+ method: "POST",
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ ...(opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {}),
95
+ },
96
+ body: JSON.stringify(body),
97
+ signal: ctl.signal,
98
+ });
99
+ if (!res.ok) return onError(`facilitator_http_${res.status}`);
100
+ return (await res.json()) as T;
101
+ } catch (e) {
102
+ const reason = (e as Error)?.name === "AbortError" ? "facilitator_timeout" : "facilitator_unreachable";
103
+ return onError(reason);
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ return {
110
+ verify(req) {
111
+ return post<VerifyResult>("/verify", req, (invalidReason) => ({ isValid: false, invalidReason }));
112
+ },
113
+ settle(req) {
114
+ const network =
115
+ req.x402Version === 2 ? req.paymentRequirements.network : req.paymentRequirements.network;
116
+ return post<SettleResult>("/settle", req, (errorReason) => ({ success: false, network, errorReason }));
117
+ },
118
+ async supported() {
119
+ const ctl = new AbortController();
120
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
121
+ try {
122
+ const res = await doFetch(`${base}/supported`, { signal: ctl.signal });
123
+ if (!res.ok) return [];
124
+ const body = (await res.json()) as { kinds?: SupportedKind[] } | SupportedKind[];
125
+ return Array.isArray(body) ? body : (body.kinds ?? []);
126
+ } catch {
127
+ return [];
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ },
132
+ };
133
+ }