create-pay-gate 0.1.1
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/dist/index.js +134 -0
- package/package.json +37 -0
- package/template/src/config.ts +105 -0
- package/template/src/gate.ts +89 -0
- package/template/src/heartbeat.ts +88 -0
- package/template/src/index.ts +345 -0
- package/template/src/response.ts +160 -0
- package/template/src/types.ts +127 -0
- package/template/src/verify.ts +74 -0
- package/template/tsconfig.json +16 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin, stdout, argv } from "node:process";
|
|
4
|
+
import { mkdirSync, writeFileSync, copyFileSync, readdirSync, existsSync } from "node:fs";
|
|
5
|
+
import { join, dirname, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TEMPLATE_DIR = join(__dirname, "..", "template");
|
|
9
|
+
async function main() {
|
|
10
|
+
const projectName = argv[2] || "";
|
|
11
|
+
console.log();
|
|
12
|
+
console.log(" pay-gate — x402 payment gateway for Cloudflare Workers");
|
|
13
|
+
console.log();
|
|
14
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
15
|
+
const name = projectName || await rl.question(" Project name: ");
|
|
16
|
+
const providerAddress = await rl.question(" Provider wallet address (0x...): ");
|
|
17
|
+
const proxyTarget = await rl.question(" Origin URL (e.g. https://api.example.com): ");
|
|
18
|
+
const defaultRoute = await rl.question(" Paid route glob (e.g. /api/v1/*): ");
|
|
19
|
+
const priceStr = await rl.question(" Price per request in USD (e.g. 0.01): ");
|
|
20
|
+
rl.close();
|
|
21
|
+
if (!name) {
|
|
22
|
+
console.error(" Error: project name is required.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
if (!providerAddress.startsWith("0x") || providerAddress.length !== 42) {
|
|
26
|
+
console.error(" Error: provider address must be a 42-character hex address (0x...).");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
if (!proxyTarget.startsWith("http")) {
|
|
30
|
+
console.error(" Error: origin URL must start with http:// or https://.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const price = parseFloat(priceStr) || 0.01;
|
|
34
|
+
const settlement = price <= 1.0 ? "tab" : "direct";
|
|
35
|
+
const outDir = resolve(name);
|
|
36
|
+
if (existsSync(outDir)) {
|
|
37
|
+
console.error(` Error: directory "${name}" already exists.`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
// Create project
|
|
41
|
+
mkdirSync(join(outDir, "src"), { recursive: true });
|
|
42
|
+
// Copy template source files
|
|
43
|
+
const templateSrc = join(TEMPLATE_DIR, "src");
|
|
44
|
+
for (const file of readdirSync(templateSrc)) {
|
|
45
|
+
copyFileSync(join(templateSrc, file), join(outDir, "src", file));
|
|
46
|
+
}
|
|
47
|
+
// Generate wrangler.toml
|
|
48
|
+
writeFileSync(join(outDir, "wrangler.toml"), wranglerToml(name, providerAddress, proxyTarget));
|
|
49
|
+
// Generate package.json
|
|
50
|
+
writeFileSync(join(outDir, "package.json"), packageJson(name));
|
|
51
|
+
// Copy tsconfig.json
|
|
52
|
+
copyFileSync(join(TEMPLATE_DIR, "tsconfig.json"), join(outDir, "tsconfig.json"));
|
|
53
|
+
// Generate routes.json (initial KV data)
|
|
54
|
+
writeFileSync(join(outDir, "routes.json"), routesJson(defaultRoute, price.toString(), settlement));
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(` Created ${name}/`);
|
|
57
|
+
console.log();
|
|
58
|
+
console.log(" Next steps:");
|
|
59
|
+
console.log(` cd ${name}`);
|
|
60
|
+
console.log(" npm install");
|
|
61
|
+
console.log(" # Create KV namespace: npx wrangler kv namespace create ROUTES");
|
|
62
|
+
console.log(" # Update wrangler.toml with the KV namespace ID");
|
|
63
|
+
console.log(" # Upload routes: npx wrangler kv bulk put routes.json --namespace-id <id>");
|
|
64
|
+
console.log(" npx wrangler dev # local development");
|
|
65
|
+
console.log(" npx wrangler deploy # deploy to Cloudflare");
|
|
66
|
+
console.log();
|
|
67
|
+
}
|
|
68
|
+
function wranglerToml(name, provider, target) {
|
|
69
|
+
return `name = "${name}"
|
|
70
|
+
main = "src/index.ts"
|
|
71
|
+
compatibility_date = "2026-04-01"
|
|
72
|
+
|
|
73
|
+
# Heartbeat cron — sends discovery heartbeat hourly.
|
|
74
|
+
# Only fires if DISCOVERY_BASE_URL is set.
|
|
75
|
+
[triggers]
|
|
76
|
+
crons = ["0 * * * *"]
|
|
77
|
+
|
|
78
|
+
[vars]
|
|
79
|
+
PROVIDER_ADDRESS = "${provider}"
|
|
80
|
+
PROXY_TARGET = "${target}"
|
|
81
|
+
DEFAULT_ACTION = "passthrough"
|
|
82
|
+
FAIL_MODE = "closed"
|
|
83
|
+
LOG_LEVEL = "info"
|
|
84
|
+
|
|
85
|
+
# IMPORTANT: FACILITATOR_URL controls which network your gate accepts payments on.
|
|
86
|
+
# Mainnet (REAL money): https://pay-skill.com/x402 (default if omitted)
|
|
87
|
+
# Testnet (WORTHLESS tokens): https://testnet.pay-skill.com/x402
|
|
88
|
+
# Setting testnet in production means accepting worthless tokens for real API calls.
|
|
89
|
+
FACILITATOR_URL = "https://pay-skill.com/x402"
|
|
90
|
+
|
|
91
|
+
# Optional: discovery config — set these to appear in \`pay discover\`.
|
|
92
|
+
# DISCOVERY_BASE_URL = "${target}"
|
|
93
|
+
# DISCOVERY_NAME = "${name}"
|
|
94
|
+
# DISCOVERY_DESCRIPTION = "Short description of your API"
|
|
95
|
+
# DISCOVERY_KEYWORDS = "keyword1,keyword2"
|
|
96
|
+
# DISCOVERY_CATEGORY = "data"
|
|
97
|
+
|
|
98
|
+
[[kv_namespaces]]
|
|
99
|
+
binding = "ROUTES"
|
|
100
|
+
id = "REPLACE_WITH_KV_NAMESPACE_ID"
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
function packageJson(name) {
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
name,
|
|
106
|
+
version: "0.1.0",
|
|
107
|
+
private: true,
|
|
108
|
+
type: "module",
|
|
109
|
+
scripts: {
|
|
110
|
+
dev: "wrangler dev",
|
|
111
|
+
deploy: "wrangler deploy",
|
|
112
|
+
typecheck: "tsc --noEmit",
|
|
113
|
+
},
|
|
114
|
+
dependencies: {
|
|
115
|
+
hono: "^4.4.0",
|
|
116
|
+
},
|
|
117
|
+
devDependencies: {
|
|
118
|
+
"@cloudflare/workers-types": "^4.20240620.0",
|
|
119
|
+
typescript: "^5.5.0",
|
|
120
|
+
wrangler: "^3.60.0",
|
|
121
|
+
},
|
|
122
|
+
}, null, 2) + "\n";
|
|
123
|
+
}
|
|
124
|
+
function routesJson(path, price, settlement) {
|
|
125
|
+
const routes = [
|
|
126
|
+
{ path: path || "/api/*", price, settlement },
|
|
127
|
+
{ path: "/health", free: true },
|
|
128
|
+
];
|
|
129
|
+
return JSON.stringify(routes, null, 2) + "\n";
|
|
130
|
+
}
|
|
131
|
+
main().catch((err) => {
|
|
132
|
+
console.error(err);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-pay-gate",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Create a pay-gate x402 payment gateway for Cloudflare Workers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-pay-gate": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"x402",
|
|
19
|
+
"pay",
|
|
20
|
+
"payment",
|
|
21
|
+
"gateway",
|
|
22
|
+
"cloudflare",
|
|
23
|
+
"workers"
|
|
24
|
+
],
|
|
25
|
+
"author": "pay-skill",
|
|
26
|
+
"license": "BSL-1.1",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/pay-skill/gate.git",
|
|
30
|
+
"directory": "create-pay-gate"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://pay-skill.com/docs/gate/",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^6.0.2",
|
|
35
|
+
"@types/node": "^25.5.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Env, RouteConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
4
|
+
const URL_RE = /^https?:\/\/.+/;
|
|
5
|
+
|
|
6
|
+
/** Load and validate route config from KV (or fallback to empty). */
|
|
7
|
+
export async function loadRoutes(env: Env): Promise<RouteConfig[]> {
|
|
8
|
+
const raw = await env.ROUTES.get("routes", "json");
|
|
9
|
+
if (!raw || !Array.isArray(raw)) return [];
|
|
10
|
+
return validateRoutes(raw as RouteConfig[]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Validate an array of route configs. Throws on invalid. */
|
|
14
|
+
export function validateRoutes(routes: RouteConfig[]): RouteConfig[] {
|
|
15
|
+
for (const r of routes) {
|
|
16
|
+
if (!r.path || typeof r.path !== "string") {
|
|
17
|
+
throw new Error(`Route missing 'path'`);
|
|
18
|
+
}
|
|
19
|
+
if (r.free) continue;
|
|
20
|
+
if (r.price_endpoint && !URL_RE.test(r.price_endpoint)) {
|
|
21
|
+
throw new Error(`Route ${r.path}: invalid price_endpoint URL`);
|
|
22
|
+
}
|
|
23
|
+
if (!r.price && !r.price_endpoint) {
|
|
24
|
+
throw new Error(`Route ${r.path}: must have 'price' or 'price_endpoint'`);
|
|
25
|
+
}
|
|
26
|
+
if (r.price) validatePrice(r.price, r.path);
|
|
27
|
+
if (r.settlement && r.settlement !== "direct" && r.settlement !== "tab") {
|
|
28
|
+
throw new Error(`Route ${r.path}: settlement must be 'direct' or 'tab'`);
|
|
29
|
+
}
|
|
30
|
+
if (r.method) {
|
|
31
|
+
const m = r.method.toUpperCase();
|
|
32
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(m)) {
|
|
33
|
+
throw new Error(`Route ${r.path}: invalid method '${r.method}'`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return routes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Validate a price string: positive decimal, not "0.00". */
|
|
41
|
+
function validatePrice(price: string, path: string): void {
|
|
42
|
+
const n = parseFloat(price);
|
|
43
|
+
if (isNaN(n) || n <= 0) {
|
|
44
|
+
throw new Error(`Route ${path}: price must be a positive number, got '${price}'`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Validate top-level env config. Throws on invalid. */
|
|
49
|
+
export function validateEnv(env: Env): void {
|
|
50
|
+
if (!ETH_ADDRESS_RE.test(env.PROVIDER_ADDRESS)) {
|
|
51
|
+
throw new Error(`Invalid PROVIDER_ADDRESS: '${env.PROVIDER_ADDRESS}'`);
|
|
52
|
+
}
|
|
53
|
+
if (!URL_RE.test(env.PROXY_TARGET)) {
|
|
54
|
+
throw new Error(`Invalid PROXY_TARGET: '${env.PROXY_TARGET}'`);
|
|
55
|
+
}
|
|
56
|
+
if (env.DEFAULT_ACTION !== "passthrough" && env.DEFAULT_ACTION !== "block") {
|
|
57
|
+
throw new Error(`DEFAULT_ACTION must be 'passthrough' or 'block', got '${env.DEFAULT_ACTION}'`);
|
|
58
|
+
}
|
|
59
|
+
if (env.FAIL_MODE !== "closed" && env.FAIL_MODE !== "open") {
|
|
60
|
+
throw new Error(`FAIL_MODE must be 'closed' or 'open', got '${env.FAIL_MODE}'`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Get facilitator URL (mainnet default). */
|
|
65
|
+
export function facilitatorUrl(env: Env): string {
|
|
66
|
+
return env.FACILITATOR_URL || "https://pay-skill.com/x402";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse global allowlist from comma-separated env var. */
|
|
70
|
+
export function globalAllowlist(env: Env): string[] {
|
|
71
|
+
const raw = env.GLOBAL_ALLOWLIST;
|
|
72
|
+
if (!raw) return [];
|
|
73
|
+
return raw.split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Convert dollar price string to micro-USDC string (6 decimals). */
|
|
77
|
+
export function priceToMicroUsdc(price: string): string {
|
|
78
|
+
const dollars = parseFloat(price);
|
|
79
|
+
const micro = Math.round(dollars * 1_000_000);
|
|
80
|
+
return micro.toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Auto-select settlement mode based on price. */
|
|
84
|
+
export function autoSettlement(price: string): "direct" | "tab" {
|
|
85
|
+
const dollars = parseFloat(price);
|
|
86
|
+
return dollars <= 1.0 ? "tab" : "direct";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Derive chain ID from facilitator URL. */
|
|
90
|
+
export function chainId(env: Env): number {
|
|
91
|
+
const url = facilitatorUrl(env);
|
|
92
|
+
return url.includes("testnet") ? 84532 : 8453;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** USDC contract address for a given chain. */
|
|
96
|
+
export function usdcAddress(chain: number): string {
|
|
97
|
+
return chain === 8453
|
|
98
|
+
? "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
99
|
+
: "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** CAIP-2 network identifier. */
|
|
103
|
+
export function caip2Network(chain: number): string {
|
|
104
|
+
return `eip155:${chain}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { RouteConfig, RouteMatch } from "./types";
|
|
2
|
+
import { autoSettlement, globalAllowlist } from "./config";
|
|
3
|
+
import type { Env } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Match a request against route config. First match wins.
|
|
7
|
+
* Returns the match result (paid, free, allowlisted, passthrough, or blocked).
|
|
8
|
+
*/
|
|
9
|
+
export function matchRoute(
|
|
10
|
+
path: string,
|
|
11
|
+
method: string,
|
|
12
|
+
routes: RouteConfig[],
|
|
13
|
+
env: Env,
|
|
14
|
+
agentAddress?: string,
|
|
15
|
+
): RouteMatch {
|
|
16
|
+
// Check global allowlist first
|
|
17
|
+
if (agentAddress) {
|
|
18
|
+
const global = globalAllowlist(env);
|
|
19
|
+
if (global.includes(agentAddress.toLowerCase())) {
|
|
20
|
+
return { kind: "allowlisted", agent: agentAddress };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const route of routes) {
|
|
25
|
+
if (!pathMatches(route.path, path)) continue;
|
|
26
|
+
if (route.method && route.method.toUpperCase() !== method.toUpperCase()) continue;
|
|
27
|
+
|
|
28
|
+
// Per-route allowlist check
|
|
29
|
+
if (agentAddress && route.allowlist) {
|
|
30
|
+
const lower = route.allowlist.map((a) => a.toLowerCase());
|
|
31
|
+
if (lower.includes(agentAddress.toLowerCase())) {
|
|
32
|
+
return { kind: "allowlisted", agent: agentAddress };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (route.free) return { kind: "free", route };
|
|
37
|
+
|
|
38
|
+
const price = route.price || "0";
|
|
39
|
+
const settlement = route.settlement || autoSettlement(price);
|
|
40
|
+
return { kind: "paid", route, price, settlement };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// No route matched — use default action
|
|
44
|
+
return env.DEFAULT_ACTION === "passthrough"
|
|
45
|
+
? { kind: "passthrough" }
|
|
46
|
+
: { kind: "blocked" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Glob-style path matching. Supports * (one segment) and ** (any). */
|
|
50
|
+
export function pathMatches(pattern: string, path: string): boolean {
|
|
51
|
+
// Exact match
|
|
52
|
+
if (pattern === path) return true;
|
|
53
|
+
|
|
54
|
+
// Convert glob to regex
|
|
55
|
+
const parts = pattern.split("/");
|
|
56
|
+
let regex = "^";
|
|
57
|
+
for (let i = 0; i < parts.length; i++) {
|
|
58
|
+
const part = parts[i]!;
|
|
59
|
+
if (part === "**") {
|
|
60
|
+
regex += "/.*";
|
|
61
|
+
} else if (part === "*") {
|
|
62
|
+
regex += "/[^/]+";
|
|
63
|
+
} else if (part.endsWith("*")) {
|
|
64
|
+
// e.g. "premium*" → match any segment starting with "premium"
|
|
65
|
+
const prefix = escapeRegex(part.slice(0, -1));
|
|
66
|
+
regex += "/" + prefix + "[^/]*";
|
|
67
|
+
} else if (part === "") {
|
|
68
|
+
// leading slash
|
|
69
|
+
continue;
|
|
70
|
+
} else {
|
|
71
|
+
regex += "/" + escapeRegex(part);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
regex += "$";
|
|
75
|
+
|
|
76
|
+
return new RegExp(regex).test(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function escapeRegex(s: string): string {
|
|
80
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract agent address from request.
|
|
85
|
+
* Checks X-Pay-Agent header (for allowlist lookups on unpaid requests).
|
|
86
|
+
*/
|
|
87
|
+
export function extractAgentAddress(headers: Headers): string | undefined {
|
|
88
|
+
return headers.get("x-pay-agent") || undefined;
|
|
89
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Env, HeartbeatPayload, HeartbeatRoute, RouteConfig } from "./types";
|
|
2
|
+
import { facilitatorUrl, loadRoutes, autoSettlement } from "./config";
|
|
3
|
+
|
|
4
|
+
/** Send a heartbeat to the facilitator's discover endpoint. */
|
|
5
|
+
export async function sendHeartbeat(env: Env): Promise<void> {
|
|
6
|
+
if (!env.DISCOVERY_BASE_URL || !env.DISCOVERY_NAME) return;
|
|
7
|
+
|
|
8
|
+
const baseUrl = env.DISCOVERY_BASE_URL.trim();
|
|
9
|
+
if (!baseUrl.startsWith("https://")) {
|
|
10
|
+
console.warn("DISCOVERY_BASE_URL must use HTTPS, skipping heartbeat");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const domain = extractDomain(baseUrl);
|
|
15
|
+
if (!domain) {
|
|
16
|
+
console.warn("Could not extract domain from DISCOVERY_BASE_URL, skipping heartbeat");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let routes: RouteConfig[];
|
|
21
|
+
try {
|
|
22
|
+
routes = await loadRoutes(env);
|
|
23
|
+
} catch {
|
|
24
|
+
console.warn("Could not load routes for heartbeat");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const heartbeatRoutes = buildRoutes(routes);
|
|
29
|
+
const settlementMode = heartbeatRoutes.some((r) => r.settlement === "tab") ? "tab" : "direct";
|
|
30
|
+
|
|
31
|
+
const payload: HeartbeatPayload = {
|
|
32
|
+
domain,
|
|
33
|
+
base_url: baseUrl,
|
|
34
|
+
provider_address: env.PROVIDER_ADDRESS,
|
|
35
|
+
name: env.DISCOVERY_NAME,
|
|
36
|
+
description: env.DISCOVERY_DESCRIPTION || "",
|
|
37
|
+
keywords: env.DISCOVERY_KEYWORDS ? env.DISCOVERY_KEYWORDS.split(",").map((k) => k.trim()).filter(Boolean) : [],
|
|
38
|
+
category: env.DISCOVERY_CATEGORY || "other",
|
|
39
|
+
website: env.DISCOVERY_WEBSITE || undefined,
|
|
40
|
+
docs_url: env.DISCOVERY_DOCS_URL || undefined,
|
|
41
|
+
routes: heartbeatRoutes,
|
|
42
|
+
pricing: {},
|
|
43
|
+
settlement_mode: settlementMode,
|
|
44
|
+
gate_version: "0.1.0",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const facUrl = facilitatorUrl(env);
|
|
48
|
+
const url = facUrl.replace("/x402", "/api/v1/discover") + "/heartbeat";
|
|
49
|
+
|
|
50
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
const resp = await fetch(url, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify(payload),
|
|
56
|
+
});
|
|
57
|
+
if (resp.ok) {
|
|
58
|
+
console.log(`Discovery heartbeat sent to ${url}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.warn(`Heartbeat rejected (attempt ${attempt + 1}): ${resp.status}`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.warn(`Heartbeat failed (attempt ${attempt + 1}):`, err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.error("Discovery heartbeat failed after 3 attempts");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildRoutes(routes: RouteConfig[]): HeartbeatRoute[] {
|
|
71
|
+
return routes
|
|
72
|
+
.filter((r) => !r.free)
|
|
73
|
+
.map((r) => ({
|
|
74
|
+
path: r.path,
|
|
75
|
+
method: r.method || "*",
|
|
76
|
+
price: r.price,
|
|
77
|
+
settlement: r.settlement || (r.price ? autoSettlement(r.price) : "auto"),
|
|
78
|
+
hint: r.hint,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractDomain(url: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(url).hostname;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env, RouteConfig } from "./types";
|
|
3
|
+
import { loadRoutes, validateEnv, facilitatorUrl, priceToMicroUsdc, autoSettlement, chainId, usdcAddress } from "./config";
|
|
4
|
+
import { matchRoute, extractAgentAddress } from "./gate";
|
|
5
|
+
import { verifyPayment, checkFacilitatorHealth } from "./verify";
|
|
6
|
+
import { make402Response, make403Response, make429Response, make503Response, buildRequirements, buildSettlementResponse } from "./response";
|
|
7
|
+
import { sendHeartbeat } from "./heartbeat";
|
|
8
|
+
|
|
9
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
10
|
+
|
|
11
|
+
// In-memory rate limit counters (per-isolate, reset on deploy)
|
|
12
|
+
const rateCounts = new Map<string, { count: number; resetAt: number }>();
|
|
13
|
+
|
|
14
|
+
// ── Health endpoint ─────────────────────────────────────────────
|
|
15
|
+
app.get("/__pay/health", async (c) => {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
const url = facilitatorUrl(c.env);
|
|
18
|
+
const chain = chainId(c.env);
|
|
19
|
+
const network = chain === 8453 ? "mainnet" : "testnet";
|
|
20
|
+
const reachable = await checkFacilitatorHealth(url);
|
|
21
|
+
return c.json({
|
|
22
|
+
status: reachable ? "ok" : "degraded",
|
|
23
|
+
facilitator: reachable ? "reachable" : "unreachable",
|
|
24
|
+
network,
|
|
25
|
+
chain_id: chain,
|
|
26
|
+
version: "0.1.0",
|
|
27
|
+
uptime: Math.floor((Date.now() - start) / 1000),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── Sidecar check endpoint ──────────────────────────────────────
|
|
32
|
+
app.post("/__pay/check", async (c) => {
|
|
33
|
+
const originalUri = c.req.header("x-original-uri") || "/";
|
|
34
|
+
const originalMethod = c.req.header("x-original-method") || "GET";
|
|
35
|
+
const paymentSig = c.req.header("payment-signature");
|
|
36
|
+
|
|
37
|
+
let routes: RouteConfig[];
|
|
38
|
+
try {
|
|
39
|
+
validateEnv(c.env);
|
|
40
|
+
routes = await loadRoutes(c.env);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return c.json({ error: "config_error", message: String(err) }, 500);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const agentAddr = extractAgentAddress(c.req.raw.headers);
|
|
46
|
+
const match = matchRoute(originalUri, originalMethod, routes, c.env, agentAddr);
|
|
47
|
+
|
|
48
|
+
if (match.kind === "free") {
|
|
49
|
+
return new Response(null, {
|
|
50
|
+
status: 200,
|
|
51
|
+
headers: { "X-Pay-Verified": "free" },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (match.kind === "passthrough") {
|
|
56
|
+
return new Response(null, { status: 200 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (match.kind === "blocked") {
|
|
60
|
+
return make403Response();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (match.kind === "allowlisted") {
|
|
64
|
+
return new Response(null, {
|
|
65
|
+
status: 200,
|
|
66
|
+
headers: {
|
|
67
|
+
"X-Pay-Verified": "allowlisted",
|
|
68
|
+
"X-Pay-From": match.agent,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Paid route
|
|
74
|
+
return handlePaidRequest(c.env, match, paymentSig, c.req.header("accept"), originalUri);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── CORS preflight — always pass through ────────────────────────
|
|
78
|
+
app.options("*", () => {
|
|
79
|
+
return new Response(null, { status: 204 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── Gate middleware for all other routes ─────────────────────────
|
|
83
|
+
app.all("*", async (c) => {
|
|
84
|
+
let routes: RouteConfig[];
|
|
85
|
+
try {
|
|
86
|
+
validateEnv(c.env);
|
|
87
|
+
routes = await loadRoutes(c.env);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return c.json({ error: "config_error", message: String(err) }, 500);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const path = new URL(c.req.url).pathname;
|
|
93
|
+
const method = c.req.method;
|
|
94
|
+
const agentAddr = extractAgentAddress(c.req.raw.headers);
|
|
95
|
+
const match = matchRoute(path, method, routes, c.env, agentAddr);
|
|
96
|
+
|
|
97
|
+
// Free route — proxy directly
|
|
98
|
+
if (match.kind === "free") {
|
|
99
|
+
return proxyToOrigin(c.env, c.req.raw, undefined, match.route);
|
|
100
|
+
}
|
|
101
|
+
if (match.kind === "passthrough") {
|
|
102
|
+
return proxyToOrigin(c.env, c.req.raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Blocked
|
|
106
|
+
if (match.kind === "blocked") {
|
|
107
|
+
return make403Response();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Allowlisted agent — proxy with headers
|
|
111
|
+
if (match.kind === "allowlisted") {
|
|
112
|
+
return proxyToOrigin(c.env, c.req.raw, {
|
|
113
|
+
"X-Pay-Verified": "allowlisted",
|
|
114
|
+
"X-Pay-From": match.agent,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Paid route — check for dynamic pricing first
|
|
119
|
+
let price = match.price;
|
|
120
|
+
let settlement = match.settlement;
|
|
121
|
+
|
|
122
|
+
if (match.route.price_endpoint) {
|
|
123
|
+
const dynamic = await fetchDynamicPrice(match.route.price_endpoint, path, method, c.req.raw.headers);
|
|
124
|
+
if (dynamic) {
|
|
125
|
+
price = dynamic;
|
|
126
|
+
settlement = match.route.settlement || autoSettlement(dynamic);
|
|
127
|
+
} else if (!match.route.price) {
|
|
128
|
+
return make503Response();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Rate limit check (per source IP)
|
|
133
|
+
const clientIp = c.req.header("cf-connecting-ip") || c.req.header("x-forwarded-for") || "unknown";
|
|
134
|
+
if (isRateLimited(clientIp, c.env)) {
|
|
135
|
+
return make429Response();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const paymentSig = c.req.header("payment-signature");
|
|
139
|
+
const chain = chainId(c.env);
|
|
140
|
+
const asset = usdcAddress(chain);
|
|
141
|
+
const facUrl = facilitatorUrl(c.env);
|
|
142
|
+
const amount = priceToMicroUsdc(price);
|
|
143
|
+
const reqs = buildRequirements(amount, settlement, c.env.PROVIDER_ADDRESS, facUrl, chain, asset);
|
|
144
|
+
|
|
145
|
+
if (!paymentSig) {
|
|
146
|
+
return make402Response(reqs, path, price, c.req.header("accept"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Verify payment with facilitator
|
|
150
|
+
const result = await verifyPayment(facUrl, paymentSig, reqs);
|
|
151
|
+
|
|
152
|
+
// Facilitator unreachable
|
|
153
|
+
if (!result) {
|
|
154
|
+
if (c.env.FAIL_MODE === "open") {
|
|
155
|
+
console.warn("Facilitator unreachable, fail_mode=open, passing through");
|
|
156
|
+
return proxyToOrigin(c.env, c.req.raw, undefined, match.route);
|
|
157
|
+
}
|
|
158
|
+
return make503Response();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Verification failed
|
|
162
|
+
if (!result.isValid) {
|
|
163
|
+
return make402Response(reqs, path, price, c.req.header("accept"), result.invalidReason);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Verification succeeded — proxy with payment headers
|
|
167
|
+
const extraHeaders: Record<string, string> = {
|
|
168
|
+
"X-Pay-Verified": "true",
|
|
169
|
+
"X-Pay-Amount": amount,
|
|
170
|
+
"X-Pay-Settlement": settlement,
|
|
171
|
+
};
|
|
172
|
+
if (result.payer) extraHeaders["X-Pay-From"] = result.payer;
|
|
173
|
+
|
|
174
|
+
const resp = await proxyToOrigin(c.env, c.req.raw, extraHeaders, match.route);
|
|
175
|
+
|
|
176
|
+
// Add v2 settlement response as PAYMENT-RESPONSE header
|
|
177
|
+
const receipt = buildSettlementResponse(result.payer, chain);
|
|
178
|
+
const newResp = new Response(resp.body, resp);
|
|
179
|
+
newResp.headers.set("PAYMENT-RESPONSE", receipt);
|
|
180
|
+
return newResp;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
async function handlePaidRequest(
|
|
186
|
+
env: Env,
|
|
187
|
+
match: Extract<import("./types").RouteMatch, { kind: "paid" }>,
|
|
188
|
+
paymentSig: string | null | undefined,
|
|
189
|
+
accept: string | null | undefined,
|
|
190
|
+
requestUrl: string,
|
|
191
|
+
): Promise<Response> {
|
|
192
|
+
const chain = chainId(env);
|
|
193
|
+
const asset = usdcAddress(chain);
|
|
194
|
+
const facUrl = facilitatorUrl(env);
|
|
195
|
+
const amount = priceToMicroUsdc(match.price);
|
|
196
|
+
const reqs = buildRequirements(amount, match.settlement, env.PROVIDER_ADDRESS, facUrl, chain, asset);
|
|
197
|
+
|
|
198
|
+
if (!paymentSig) {
|
|
199
|
+
return make402Response(reqs, requestUrl, match.price, accept);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const result = await verifyPayment(facUrl, paymentSig, reqs);
|
|
203
|
+
|
|
204
|
+
if (!result) {
|
|
205
|
+
return env.FAIL_MODE === "open"
|
|
206
|
+
? new Response(null, { status: 200, headers: { "X-Pay-Verified": "free" } })
|
|
207
|
+
: make503Response();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!result.isValid) {
|
|
211
|
+
return make402Response(reqs, requestUrl, match.price, accept, result.invalidReason);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const headers: Record<string, string> = {
|
|
215
|
+
"X-Pay-Verified": "true",
|
|
216
|
+
"X-Pay-Amount": amount,
|
|
217
|
+
"X-Pay-Settlement": match.settlement,
|
|
218
|
+
};
|
|
219
|
+
if (result.payer) headers["X-Pay-From"] = result.payer;
|
|
220
|
+
|
|
221
|
+
const receipt = buildSettlementResponse(result.payer, chain);
|
|
222
|
+
headers["PAYMENT-RESPONSE"] = receipt;
|
|
223
|
+
|
|
224
|
+
return new Response(null, { status: 200, headers });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function proxyToOrigin(
|
|
228
|
+
env: Env,
|
|
229
|
+
req: Request,
|
|
230
|
+
extraHeaders?: Record<string, string>,
|
|
231
|
+
route?: import("./types").RouteConfig,
|
|
232
|
+
): Promise<Response> {
|
|
233
|
+
const url = new URL(req.url);
|
|
234
|
+
const target = new URL(env.PROXY_TARGET);
|
|
235
|
+
url.protocol = target.protocol;
|
|
236
|
+
url.hostname = target.hostname;
|
|
237
|
+
url.port = target.port;
|
|
238
|
+
|
|
239
|
+
// Apply route-level path rewrite (e.g. /weather → /v1/forecast.json)
|
|
240
|
+
if (route?.proxy_rewrite) {
|
|
241
|
+
url.pathname = route.proxy_rewrite;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Inject route-level default query params (e.g. key=xxx&days=3)
|
|
245
|
+
if (route?.proxy_params) {
|
|
246
|
+
for (const [k, v] of Object.entries(route.proxy_params)) {
|
|
247
|
+
if (!url.searchParams.has(k)) {
|
|
248
|
+
url.searchParams.set(k, v);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const headers = new Headers(req.headers);
|
|
254
|
+
headers.delete("host");
|
|
255
|
+
headers.set("host", target.hostname);
|
|
256
|
+
|
|
257
|
+
if (extraHeaders) {
|
|
258
|
+
for (const [k, v] of Object.entries(extraHeaders)) {
|
|
259
|
+
headers.set(k, v);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Strip payment headers — don't forward to origin
|
|
264
|
+
headers.delete("payment-signature");
|
|
265
|
+
|
|
266
|
+
const proxyReq = new Request(url.toString(), {
|
|
267
|
+
method: req.method,
|
|
268
|
+
headers,
|
|
269
|
+
body: req.body,
|
|
270
|
+
redirect: "manual",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
return await fetch(proxyReq);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error("Origin proxy error:", err);
|
|
277
|
+
return new Response(
|
|
278
|
+
JSON.stringify({ error: "bad_gateway", message: "Origin server unreachable." }),
|
|
279
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function fetchDynamicPrice(
|
|
285
|
+
endpoint: string,
|
|
286
|
+
path: string,
|
|
287
|
+
method: string,
|
|
288
|
+
headers: Headers,
|
|
289
|
+
): Promise<string | null> {
|
|
290
|
+
try {
|
|
291
|
+
const controller = new AbortController();
|
|
292
|
+
const timeout = setTimeout(() => controller.abort(), 3_000);
|
|
293
|
+
const resp = await fetch(endpoint, {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "Content-Type": "application/json" },
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
method,
|
|
298
|
+
path,
|
|
299
|
+
headers: Object.fromEntries(headers.entries()),
|
|
300
|
+
}),
|
|
301
|
+
signal: controller.signal,
|
|
302
|
+
});
|
|
303
|
+
clearTimeout(timeout);
|
|
304
|
+
|
|
305
|
+
if (!resp.ok) return null;
|
|
306
|
+
const body = (await resp.json()) as { price?: string };
|
|
307
|
+
return body.price || null;
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isRateLimited(key: string, env: Env): boolean {
|
|
314
|
+
const limitStr = env.RATE_LIMIT_PER_AGENT || "1000";
|
|
315
|
+
const limit = parseInt(limitStr, 10) || 1000;
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
const entry = rateCounts.get(key);
|
|
318
|
+
|
|
319
|
+
if (!entry || now >= entry.resetAt) {
|
|
320
|
+
rateCounts.set(key, { count: 1, resetAt: now + 60_000 });
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
entry.count++;
|
|
325
|
+
return entry.count > limit;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function logNetworkWarning(env: Env): void {
|
|
329
|
+
const chain = chainId(env);
|
|
330
|
+
if (chain !== 8453) {
|
|
331
|
+
console.warn("========================================================");
|
|
332
|
+
console.warn(" TESTNET MODE — payments use worthless test USDC.");
|
|
333
|
+
console.warn(" Set FACILITATOR_URL to https://pay-skill.com/x402");
|
|
334
|
+
console.warn(" for production (mainnet).");
|
|
335
|
+
console.warn("========================================================");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export default {
|
|
340
|
+
fetch: app.fetch,
|
|
341
|
+
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
|
|
342
|
+
logNetworkWarning(env);
|
|
343
|
+
await sendHeartbeat(env);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { PaymentRequired, PaymentRequirementsV2, SettlementResponse, GateError } from "./types";
|
|
2
|
+
import { caip2Network } from "./config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a base64-encoded v2 PAYMENT-REQUIRED header value.
|
|
6
|
+
*/
|
|
7
|
+
export function buildPaymentRequiredHeader(pr: PaymentRequired): string {
|
|
8
|
+
return btoa(JSON.stringify(pr));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a v2 PaymentRequired object.
|
|
13
|
+
*/
|
|
14
|
+
export function buildPaymentRequired(
|
|
15
|
+
reqs: PaymentRequirementsV2,
|
|
16
|
+
requestUrl: string,
|
|
17
|
+
): PaymentRequired {
|
|
18
|
+
return {
|
|
19
|
+
x402Version: 2,
|
|
20
|
+
resource: {
|
|
21
|
+
url: requestUrl,
|
|
22
|
+
description: "Paid API endpoint",
|
|
23
|
+
mimeType: "application/json",
|
|
24
|
+
},
|
|
25
|
+
accepts: [reqs],
|
|
26
|
+
extensions: {},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a v2 PaymentRequirementsV2 object.
|
|
32
|
+
*/
|
|
33
|
+
export function buildRequirements(
|
|
34
|
+
amount: string,
|
|
35
|
+
settlement: "direct" | "tab",
|
|
36
|
+
providerAddress: string,
|
|
37
|
+
facilitatorUrl: string,
|
|
38
|
+
chain: number,
|
|
39
|
+
asset: string,
|
|
40
|
+
): PaymentRequirementsV2 {
|
|
41
|
+
return {
|
|
42
|
+
scheme: "exact",
|
|
43
|
+
network: caip2Network(chain),
|
|
44
|
+
amount,
|
|
45
|
+
asset,
|
|
46
|
+
payTo: providerAddress,
|
|
47
|
+
maxTimeoutSeconds: 60,
|
|
48
|
+
extra: {
|
|
49
|
+
name: "USDC",
|
|
50
|
+
version: "2",
|
|
51
|
+
facilitator: facilitatorUrl,
|
|
52
|
+
settlement,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a base64-encoded v2 SettlementResponse for the PAYMENT-RESPONSE header.
|
|
59
|
+
*/
|
|
60
|
+
export function buildSettlementResponse(payer: string | undefined, chain: number): string {
|
|
61
|
+
const resp: SettlementResponse = {
|
|
62
|
+
success: true,
|
|
63
|
+
transaction: "",
|
|
64
|
+
network: caip2Network(chain),
|
|
65
|
+
payer,
|
|
66
|
+
extensions: {},
|
|
67
|
+
};
|
|
68
|
+
return btoa(JSON.stringify(resp));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a 402 JSON response body.
|
|
73
|
+
*/
|
|
74
|
+
export function build402JsonBody(price: string, reason?: string): GateError {
|
|
75
|
+
const body: GateError = {
|
|
76
|
+
error: "payment_required",
|
|
77
|
+
message: `This endpoint requires payment. $${price} per request.`,
|
|
78
|
+
docs: "https://pay-skill.com/gate",
|
|
79
|
+
};
|
|
80
|
+
if (reason) body.reason = reason;
|
|
81
|
+
return body;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a 402 HTML response body for browser clients.
|
|
86
|
+
*/
|
|
87
|
+
export function build402Html(price: string): string {
|
|
88
|
+
return [
|
|
89
|
+
"<html><head><title>Payment Required</title></head><body>",
|
|
90
|
+
"<h1>Payment Required</h1>",
|
|
91
|
+
`<p>This endpoint requires a payment of $${price} per request.</p>`,
|
|
92
|
+
"<p>Use an x402-compatible agent or SDK to access this API.</p>",
|
|
93
|
+
'<p><a href="https://pay-skill.com/gate">Learn more about pay-gate</a></p>',
|
|
94
|
+
"</body></html>",
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if request accepts HTML (browser detection).
|
|
100
|
+
*/
|
|
101
|
+
export function wantsHtml(accept: string | null | undefined): boolean {
|
|
102
|
+
if (!accept) return false;
|
|
103
|
+
return accept.includes("text/html") && !accept.includes("application/json");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build a full 402 Response with v2 PAYMENT-REQUIRED header.
|
|
108
|
+
*/
|
|
109
|
+
export function make402Response(
|
|
110
|
+
reqs: PaymentRequirementsV2,
|
|
111
|
+
requestUrl: string,
|
|
112
|
+
price: string,
|
|
113
|
+
accept: string | null | undefined,
|
|
114
|
+
reason?: string,
|
|
115
|
+
): Response {
|
|
116
|
+
const pr = buildPaymentRequired(reqs, requestUrl);
|
|
117
|
+
const header = buildPaymentRequiredHeader(pr);
|
|
118
|
+
|
|
119
|
+
if (wantsHtml(accept)) {
|
|
120
|
+
return new Response(build402Html(price), {
|
|
121
|
+
status: 402,
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
124
|
+
"PAYMENT-REQUIRED": header,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Response(JSON.stringify(build402JsonBody(price, reason)), {
|
|
130
|
+
status: 402,
|
|
131
|
+
headers: {
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
"PAYMENT-REQUIRED": header,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build a 403 Forbidden response (blocked by default_action). */
|
|
139
|
+
export function make403Response(): Response {
|
|
140
|
+
return new Response(
|
|
141
|
+
JSON.stringify({ error: "forbidden", message: "This endpoint is not available." }),
|
|
142
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Build a 429 Too Many Requests response. */
|
|
147
|
+
export function make429Response(): Response {
|
|
148
|
+
return new Response(
|
|
149
|
+
JSON.stringify({ error: "rate_limited", message: "Too many requests." }),
|
|
150
|
+
{ status: 429, headers: { "Content-Type": "application/json" } },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build a 503 Service Unavailable response (facilitator down, fail_mode closed). */
|
|
155
|
+
export function make503Response(): Response {
|
|
156
|
+
return new Response(
|
|
157
|
+
JSON.stringify({ error: "service_unavailable", message: "Payment facilitator is unreachable." }),
|
|
158
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Route configuration — loaded from KV or env. */
|
|
2
|
+
export interface RouteConfig {
|
|
3
|
+
path: string;
|
|
4
|
+
method?: string;
|
|
5
|
+
price?: string;
|
|
6
|
+
settlement?: "direct" | "tab";
|
|
7
|
+
free?: boolean;
|
|
8
|
+
allowlist?: string[];
|
|
9
|
+
price_endpoint?: string;
|
|
10
|
+
/** Free-form usage hint for agents. e.g. "?q={city}" or '{"prompt": "string"}' */
|
|
11
|
+
hint?: string;
|
|
12
|
+
/** Rewrite the path before proxying to origin. e.g. "/v1/forecast.json" */
|
|
13
|
+
proxy_rewrite?: string;
|
|
14
|
+
/** Default query params injected into every proxied request. e.g. {"key": "abc", "days": "3"} */
|
|
15
|
+
proxy_params?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Top-level env bindings for the CF Worker. */
|
|
19
|
+
export interface Env {
|
|
20
|
+
ROUTES: KVNamespace;
|
|
21
|
+
PROVIDER_ADDRESS: string;
|
|
22
|
+
PROXY_TARGET: string;
|
|
23
|
+
DEFAULT_ACTION: string;
|
|
24
|
+
FAIL_MODE: string;
|
|
25
|
+
FACILITATOR_URL?: string;
|
|
26
|
+
LOG_LEVEL?: string;
|
|
27
|
+
RATE_LIMIT_PER_AGENT?: string;
|
|
28
|
+
RATE_LIMIT_VERIFICATION?: string;
|
|
29
|
+
GLOBAL_ALLOWLIST?: string;
|
|
30
|
+
/** Discovery config — set these to register in pay discover catalog. */
|
|
31
|
+
DISCOVERY_BASE_URL?: string;
|
|
32
|
+
DISCOVERY_NAME?: string;
|
|
33
|
+
DISCOVERY_DESCRIPTION?: string;
|
|
34
|
+
DISCOVERY_KEYWORDS?: string;
|
|
35
|
+
DISCOVERY_CATEGORY?: string;
|
|
36
|
+
DISCOVERY_DOCS_URL?: string;
|
|
37
|
+
DISCOVERY_WEBSITE?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Heartbeat payload sent to facilitator for service discovery. */
|
|
41
|
+
export interface HeartbeatPayload {
|
|
42
|
+
domain: string;
|
|
43
|
+
base_url: string;
|
|
44
|
+
provider_address: string;
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
keywords: string[];
|
|
48
|
+
category: string;
|
|
49
|
+
website?: string;
|
|
50
|
+
docs_url?: string;
|
|
51
|
+
routes: HeartbeatRoute[];
|
|
52
|
+
pricing: Record<string, unknown>;
|
|
53
|
+
settlement_mode: string;
|
|
54
|
+
gate_version: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface HeartbeatRoute {
|
|
58
|
+
path: string;
|
|
59
|
+
method: string;
|
|
60
|
+
price?: string;
|
|
61
|
+
settlement: string;
|
|
62
|
+
hint?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** x402 v2 top-level 402 response (base64-encoded in PAYMENT-REQUIRED header). */
|
|
66
|
+
export interface PaymentRequired {
|
|
67
|
+
x402Version: 2;
|
|
68
|
+
resource: { url: string; description?: string; mimeType?: string };
|
|
69
|
+
accepts: PaymentRequirementsV2[];
|
|
70
|
+
extensions: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** x402 v2 payment requirements (one entry in the `accepts` array). */
|
|
74
|
+
export interface PaymentRequirementsV2 {
|
|
75
|
+
scheme: "exact";
|
|
76
|
+
network: string;
|
|
77
|
+
amount: string;
|
|
78
|
+
asset: string;
|
|
79
|
+
payTo: string;
|
|
80
|
+
maxTimeoutSeconds: number;
|
|
81
|
+
extra?: {
|
|
82
|
+
name?: string;
|
|
83
|
+
version?: string;
|
|
84
|
+
facilitator?: string;
|
|
85
|
+
settlement?: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** x402 v2 facilitator /verify request body. */
|
|
90
|
+
export interface VerifyRequestV2 {
|
|
91
|
+
x402Version: 2;
|
|
92
|
+
paymentPayload: unknown;
|
|
93
|
+
paymentRequirements: PaymentRequirementsV2;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** x402 v2 facilitator /verify response body. */
|
|
97
|
+
export interface VerifyResponseV2 {
|
|
98
|
+
isValid: boolean;
|
|
99
|
+
invalidReason?: string;
|
|
100
|
+
payer?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** x402 v2 settlement response (base64-encoded in PAYMENT-RESPONSE header). */
|
|
104
|
+
export interface SettlementResponse {
|
|
105
|
+
success: boolean;
|
|
106
|
+
errorReason?: string;
|
|
107
|
+
transaction: string;
|
|
108
|
+
network: string;
|
|
109
|
+
payer?: string;
|
|
110
|
+
extensions: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Result of matching a request against route config. */
|
|
114
|
+
export type RouteMatch =
|
|
115
|
+
| { kind: "paid"; route: RouteConfig; price: string; settlement: "direct" | "tab" }
|
|
116
|
+
| { kind: "free"; route: RouteConfig }
|
|
117
|
+
| { kind: "allowlisted"; agent: string }
|
|
118
|
+
| { kind: "passthrough" }
|
|
119
|
+
| { kind: "blocked" };
|
|
120
|
+
|
|
121
|
+
/** Structured gate error for responses. */
|
|
122
|
+
export interface GateError {
|
|
123
|
+
error: string;
|
|
124
|
+
message: string;
|
|
125
|
+
reason?: string;
|
|
126
|
+
docs?: string;
|
|
127
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PaymentRequirementsV2, VerifyRequestV2, VerifyResponseV2 } from "./types";
|
|
2
|
+
|
|
3
|
+
const FACILITATOR_TIMEOUT_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Call the Pay facilitator's /verify endpoint with v2 wire format.
|
|
7
|
+
* Decodes PAYMENT-SIGNATURE from base64 into a JSON payload.
|
|
8
|
+
* Returns the verify response, or null if the facilitator is unreachable.
|
|
9
|
+
*/
|
|
10
|
+
export async function verifyPayment(
|
|
11
|
+
facilitatorUrl: string,
|
|
12
|
+
paymentHeader: string,
|
|
13
|
+
requirements: PaymentRequirementsV2,
|
|
14
|
+
): Promise<VerifyResponseV2 | null> {
|
|
15
|
+
let paymentPayload: unknown;
|
|
16
|
+
try {
|
|
17
|
+
paymentPayload = JSON.parse(atob(paymentHeader));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const body: VerifyRequestV2 = {
|
|
23
|
+
x402Version: 2,
|
|
24
|
+
paymentPayload,
|
|
25
|
+
paymentRequirements: requirements,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timeout = setTimeout(() => controller.abort(), FACILITATOR_TIMEOUT_MS);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const resp = await fetch(`${facilitatorUrl}/verify`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!resp.ok) {
|
|
40
|
+
console.error(`Facilitator returned ${resp.status}: ${await resp.text()}`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (await resp.json()) as VerifyResponseV2;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
47
|
+
console.error("Facilitator timeout after 5s");
|
|
48
|
+
} else {
|
|
49
|
+
console.error("Facilitator error:", err);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if the facilitator is reachable (for health endpoint).
|
|
59
|
+
*/
|
|
60
|
+
export async function checkFacilitatorHealth(facilitatorUrl: string): Promise<boolean> {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), 3_000);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const resp = await fetch(`${facilitatorUrl}/supported`, {
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
return resp.ok;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"jsxImportSource": "hono/jsx"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|