@agentpayments/edge 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Brzosko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @agentpayments/edge
2
+
3
+ Shared fetch-runtime gate for edge platforms. One core gate, thin platform adapters.
4
+
5
+ ## Adapters
6
+
7
+ | Import | Function | Platform |
8
+ |---|---|---|
9
+ | `@agentpayments/edge/cloudflare` | `createAgentPaymentsWorker()` | Cloudflare Workers |
10
+ | `@agentpayments/edge/netlify` | `createNetlifyGate()` | Netlify Edge Functions |
11
+ | `@agentpayments/edge/vercel` | `createVercelEdgeGate()` | Vercel Edge Middleware |
12
+ | `@agentpayments/edge` | `createEdgeGate()` | Generic fetch runtime |
13
+
14
+ ## Cloudflare Workers
15
+
16
+ ```js
17
+ import { createAgentPaymentsWorker } from '@agentpayments/edge/cloudflare';
18
+
19
+ export default createAgentPaymentsWorker({
20
+ assetsBinding: 'ASSETS', // Workers Assets binding name
21
+ publicPathAllowlist: [], // extra paths to bypass gate
22
+ minPayment: 0.01, // minimum USDC amount
23
+ });
24
+ ```
25
+
26
+ Environment variables are read from the Workers `env` object: `CHALLENGE_SECRET`, `HOME_WALLET_ADDRESS`, `SOLANA_RPC_URL`, `USDC_MINT`, `DEBUG`.
27
+
28
+ ## Netlify Edge Functions
29
+
30
+ ```ts
31
+ import { createNetlifyGate } from '@agentpayments/edge/netlify';
32
+
33
+ export default createNetlifyGate();
34
+ ```
35
+
36
+ Set environment variables in the Netlify dashboard or `netlify.toml`.
37
+
38
+ ## Vercel Edge Middleware
39
+
40
+ ```ts
41
+ import { NextResponse } from 'next/server';
42
+ import { createVercelEdgeGate } from '@agentpayments/edge/vercel';
43
+
44
+ const gate = createVercelEdgeGate({
45
+ env: {
46
+ CHALLENGE_SECRET: process.env.CHALLENGE_SECRET,
47
+ HOME_WALLET_ADDRESS: process.env.HOME_WALLET_ADDRESS,
48
+ SOLANA_RPC_URL: process.env.SOLANA_RPC_URL,
49
+ USDC_MINT: process.env.USDC_MINT,
50
+ DEBUG: process.env.DEBUG,
51
+ },
52
+ upstreamNext: () => NextResponse.next(),
53
+ });
54
+
55
+ export default gate;
56
+ ```
57
+
58
+ > For Next.js projects, prefer [`@agentpayments/next`](../next/README.md) which wraps this adapter.
59
+
60
+ ## Generic / Custom Runtime
61
+
62
+ ```js
63
+ import { createEdgeGate } from '@agentpayments/edge';
64
+
65
+ const gate = createEdgeGate({
66
+ fetchUpstream: (request, env) => fetch(request),
67
+ getClientIp: ({ request }) => request.headers.get('x-forwarded-for') || 'unknown',
68
+ publicPathAllowlist: ['/health'],
69
+ minPayment: 0.01,
70
+ });
71
+
72
+ // Use in any fetch-based handler:
73
+ export default { fetch: (req, env, ctx) => gate(req, env, ctx) };
74
+ ```
75
+
76
+ ## Environment Variables
77
+
78
+ | Variable | Required | Description |
79
+ |---|---|---|
80
+ | `CHALLENGE_SECRET` | Yes (production) | HMAC secret for signing cookies, nonces, and agent keys. |
81
+ | `HOME_WALLET_ADDRESS` | Yes | Solana wallet address to receive USDC payments. |
82
+ | `SOLANA_RPC_URL` | No | Custom Solana RPC endpoint. Defaults by debug flag. |
83
+ | `USDC_MINT` | No | Custom USDC mint address. Defaults by debug flag. |
84
+ | `DEBUG` | No | `"true"` = devnet. `"false"` = mainnet (default varies by adapter). |
85
+
86
+ ## Security Features
87
+
88
+ - **Timing-safe HMAC comparison** — custom HMAC-then-XOR using Web Crypto API (`crypto.subtle`)
89
+ - **Payment verification cache** — 10-minute TTL, 1000-entry max
90
+ - **Rate limiting** — 20 challenge verifications per minute per IP
91
+ - **Input size limits** — key (64 chars), nonce (128), return URL (2048), fingerprint (128)
92
+ - **Wallet address validation** — base58 format, 32-44 chars, validated per-request
93
+ - **Default secret detection** — warns in debug, returns 500 in production
94
+ - **Structured JSON logging** — all gate events logged as JSON
95
+
96
+ ## TypeScript
97
+
98
+ TypeScript types are included via `index.d.ts`. The core export:
99
+
100
+ ```ts
101
+ import type { EdgeGateOptions } from '@agentpayments/edge';
102
+ import { createEdgeGate } from '@agentpayments/edge';
103
+ ```
104
+
105
+ ## Notes
106
+ - ESM module (`import`).
107
+ - Uses Web Crypto API (`crypto.subtle`) — no Node.js `crypto` dependency.
108
+ - Constants are inlined (not imported from `constants.json`) for Deno/Netlify compatibility.
109
+ - The canonical constant values live in `sdk/constants.json`.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * CloudflareKVStore — Store implementation backed by Cloudflare Workers KV.
3
+ *
4
+ * Pass an instance to createEdgeGate (or return one from getStore) to make
5
+ * nonce replay prevention, rate limiting, and payment caching durable across
6
+ * all Cloudflare isolates for a given worker deployment.
7
+ *
8
+ * Usage:
9
+ * import { CloudflareKVStore } from '@agentpayments/edge/cloudflare-kv-store.js';
10
+ * // or via the cloudflare.js createAgentPaymentsWorker({ kvBinding: 'KV_NAME' })
11
+ *
12
+ * KV namespace must be created and bound in wrangler.toml:
13
+ * [[kv_namespaces]]
14
+ * binding = "AGENTPAYMENTS_KV"
15
+ * id = "<id from `wrangler kv:namespace create AGENTPAYMENTS_KV`>"
16
+ *
17
+ * Trade-offs vs. InMemoryStore:
18
+ * consumeNonce — KV put/get is NOT atomic; there is a small race window
19
+ * (~ms) where two concurrent isolates could both accept the
20
+ * same nonce. For a hard guarantee use Durable Objects.
21
+ * KV is still vastly better than per-isolate in-memory
22
+ * (where every isolate has a fresh empty set).
23
+ * checkRateLimit — fixed-window counter; same race applies, counts may be
24
+ * slightly under the true value under high concurrency.
25
+ * getCachedPayment / setCachedPayment — reads/writes are eventually
26
+ * consistent (KV replication lag ~60s in the worst case).
27
+ * Paid keys may briefly re-scan the chain on a cold isolate
28
+ * before the cache propagates.
29
+ */
30
+
31
+ export class CloudflareKVStore {
32
+ /**
33
+ * @param {KVNamespace} kvNamespace — the bound Cloudflare KV namespace
34
+ */
35
+ constructor(kvNamespace) {
36
+ if (!kvNamespace || typeof kvNamespace.get !== 'function') {
37
+ throw new Error('CloudflareKVStore: kvNamespace must be a Cloudflare KV binding');
38
+ }
39
+ this._kv = kvNamespace;
40
+ }
41
+
42
+ /**
43
+ * Mark a nonce signature as consumed.
44
+ * Returns true if this is the first use (fresh), false if it has been seen.
45
+ *
46
+ * Note: put/get is not atomic in KV — see file-level comment for the race caveat.
47
+ */
48
+ async consumeNonce(sig, ttlMs) {
49
+ const key = `nonce:${sig}`;
50
+ const existing = await this._kv.get(key);
51
+ if (existing !== null) return false;
52
+ await this._kv.put(key, '1', { expirationTtl: Math.max(1, Math.ceil(ttlMs / 1000)) });
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * Fixed-window rate limiter. Returns true if the caller is within the limit.
58
+ */
59
+ async checkRateLimit(ipKey, windowMs, max) {
60
+ const key = `rl:${ipKey}`;
61
+ const raw = await this._kv.get(key);
62
+ const now = Date.now();
63
+ let entry = raw ? JSON.parse(raw) : null;
64
+
65
+ if (!entry || now - entry.start > windowMs) {
66
+ entry = { start: now, count: 1 };
67
+ } else {
68
+ entry.count += 1;
69
+ }
70
+
71
+ const ttlSec = Math.max(1, Math.ceil(windowMs / 1000));
72
+ await this._kv.put(key, JSON.stringify(entry), { expirationTtl: ttlSec });
73
+ return entry.count <= max;
74
+ }
75
+
76
+ /**
77
+ * Return the cached payment result for an agent key, or undefined if not cached.
78
+ */
79
+ async getCachedPayment(agentKey) {
80
+ const val = await this._kv.get(`pay:${agentKey}`);
81
+ if (val === null) return undefined;
82
+ return val === 'true';
83
+ }
84
+
85
+ /**
86
+ * Cache a payment verification result for ttlMs milliseconds.
87
+ */
88
+ async setCachedPayment(agentKey, value, ttlMs) {
89
+ await this._kv.put(`pay:${agentKey}`, value ? 'true' : 'false', {
90
+ expirationTtl: Math.max(1, Math.ceil(ttlMs / 1000)),
91
+ });
92
+ }
93
+ }
package/cloudflare.js ADDED
@@ -0,0 +1,43 @@
1
+ import { createEdgeGate, InMemoryStore } from './index.js';
2
+ import { CloudflareKVStore } from './cloudflare-kv-store.js';
3
+
4
+ export { CloudflareKVStore } from './cloudflare-kv-store.js';
5
+
6
+ export function createAgentPaymentsWorker(options = {}) {
7
+ const {
8
+ assetsBinding = 'ASSETS',
9
+ publicPathAllowlist = [],
10
+ minPayment,
11
+ powDifficulty,
12
+ // Name of the KV namespace binding in wrangler.toml (default: AGENTPAYMENTS_KV).
13
+ // If the binding is present in env, a CloudflareKVStore is used — giving
14
+ // cross-isolate nonce replay prevention, rate limiting, and payment caching.
15
+ // If absent (local dev, binding not yet created), falls back to InMemoryStore.
16
+ kvBinding = 'AGENTPAYMENTS_KV',
17
+ } = options;
18
+
19
+ const gate = createEdgeGate({
20
+ publicPathAllowlist,
21
+ minPayment,
22
+ powDifficulty,
23
+ getClientIp: ({ request }) => request.headers.get('cf-connecting-ip') || 'unknown',
24
+ fetchUpstream: (request, env) => {
25
+ const binding = env[assetsBinding];
26
+ if (!binding || typeof binding.fetch !== 'function') {
27
+ return new Response(`${assetsBinding} binding is missing.`, { status: 500 });
28
+ }
29
+ return binding.fetch(request);
30
+ },
31
+ // Per-request store factory: use KV when bound, fall back to in-memory.
32
+ getStore: ({ env }) => {
33
+ const kv = env[kvBinding];
34
+ return kv ? new CloudflareKVStore(kv) : new InMemoryStore();
35
+ },
36
+ });
37
+
38
+ return {
39
+ fetch(request, env, context) {
40
+ return gate(request, env, context);
41
+ },
42
+ };
43
+ }
package/index.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface EdgeGateOptions {
2
+ /**
3
+ * Function to fetch the upstream/origin response.
4
+ * Called when a request passes the gate (browser verified or agent paid).
5
+ */
6
+ fetchUpstream: (request: Request, env: Record<string, string>, context: unknown) => Response | Promise<Response>;
7
+ /**
8
+ * Function to extract the client IP from the request context.
9
+ * Defaults to returning 'unknown'.
10
+ */
11
+ getClientIp?: (ctx: { request: Request; env: Record<string, string>; context: unknown }) => string;
12
+ /** Paths that bypass the gate entirely (e.g., health checks). */
13
+ publicPathAllowlist?: string[];
14
+ /** Minimum USDC payment amount. Defaults to 0.01. */
15
+ minPayment?: number;
16
+ /**
17
+ * Async function to resolve environment variables per-request.
18
+ * Useful for platforms where env is passed per-request (e.g., Cloudflare Workers).
19
+ */
20
+ envResolver?: (ctx: { request: Request; env: Record<string, string>; context: unknown }) => Record<string, string> | Promise<Record<string, string>>;
21
+ }
22
+
23
+ /**
24
+ * Creates an edge gate handler for Cloudflare Workers, Netlify Edge, and similar runtimes.
25
+ *
26
+ * @example
27
+ * ```js
28
+ * import { createEdgeGate } from '@agentpayments/edge';
29
+ *
30
+ * const gate = createEdgeGate({
31
+ * fetchUpstream: (request, env) => env.ASSETS.fetch(request),
32
+ * getClientIp: ({ request }) => request.headers.get('cf-connecting-ip') || 'unknown',
33
+ * });
34
+ *
35
+ * export default { fetch: (req, env, ctx) => gate(req, env, ctx) };
36
+ * ```
37
+ */
38
+ export function createEdgeGate(options: EdgeGateOptions): (
39
+ request: Request,
40
+ env?: Record<string, string>,
41
+ context?: unknown,
42
+ ) => Promise<Response>;
package/index.js ADDED
@@ -0,0 +1,754 @@
1
+ // Values sourced from sdk/constants.json (canonical). Inlined here because JSON
2
+ // import syntax differs across Deno (Netlify), Cloudflare Workers, and Vercel Edge.
3
+ const COOKIE_NAME = '__agp_verified';
4
+ const COOKIE_MAX_AGE = 86400;
5
+ const KEY_PREFIX = 'ag_';
6
+ const USDC_MINT_DEVNET = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
7
+ const USDC_MINT_MAINNET = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
8
+ const RPC_DEVNET = 'https://api.devnet.solana.com';
9
+ const RPC_MAINNET = 'https://api.mainnet-beta.solana.com';
10
+ const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr';
11
+ const MIN_PAYMENT = 0.01;
12
+ const POW_DIFFICULTY = 4;
13
+ const MAX_POW_LENGTH = 20;
14
+ const NONCE_TTL_MS = 300000;
15
+ const MAX_KEY_LENGTH = 64;
16
+ const MAX_NONCE_LENGTH = 128;
17
+ const MAX_RETURN_TO_LENGTH = 2048;
18
+ const MAX_FP_LENGTH = 128;
19
+ const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
20
+ const PAYMENT_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
21
+ const PAYMENT_CACHE_MAX = 1000;
22
+ const NEGATIVE_CACHE_TTL_MS = 30000; // 30 seconds
23
+ const MAX_TRANSACTIONS_PER_VERIFY = 20;
24
+ const AGENT_KEY_RATE_LIMIT_MAX = 10;
25
+ const CHALLENGE_ISSUE_RATE_LIMIT_MAX = 30;
26
+ const USDC_DECIMALS = 6;
27
+ const X402_VERSION = 1;
28
+ const SOLANA_CHAIN_ID_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
29
+ const SOLANA_CHAIN_ID_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1';
30
+ const PLATFORM_API_URL = 'https://api.agentpayments.dev';
31
+ const HOSTED_KEY_PREFIX = 'agp_';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Pluggable async store interface
35
+ //
36
+ // Any object implementing all four methods can be passed as the `store`
37
+ // option to createEdgeGate, or returned by the `getStore` factory.
38
+ //
39
+ // interface Store {
40
+ // consumeNonce(sig: string, ttlMs: number): Promise<boolean> // true = fresh
41
+ // checkRateLimit(key: string, windowMs: number, max: number): Promise<boolean>
42
+ // getCachedPayment(agentKey: string): Promise<boolean | undefined>
43
+ // setCachedPayment(agentKey: string, value: boolean, ttlMs: number): Promise<void>
44
+ // }
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export class InMemoryStore {
48
+ constructor() {
49
+ this._nonces = new Map(); // sig -> expiryMs
50
+ this._rateLimit = new Map(); // key -> {start, count}
51
+ this._payments = new Map(); // agentKey -> {value, ts}
52
+ }
53
+
54
+ async consumeNonce(sig, ttlMs) {
55
+ const now = Date.now();
56
+ const exp = this._nonces.get(sig);
57
+ if (exp !== undefined && exp > now) return false; // already used
58
+ if (this._nonces.size >= 10000) this._nonces.delete(this._nonces.keys().next().value);
59
+ this._nonces.set(sig, now + ttlMs);
60
+ return true;
61
+ }
62
+
63
+ async checkRateLimit(key, windowMs, max) {
64
+ const now = Date.now();
65
+ const entry = this._rateLimit.get(key);
66
+ if (!entry || now - entry.start > windowMs) {
67
+ this._rateLimit.set(key, { start: now, count: 1 });
68
+ return true;
69
+ }
70
+ entry.count++;
71
+ return entry.count <= max;
72
+ }
73
+
74
+ async getCachedPayment(agentKey) {
75
+ const entry = this._payments.get(agentKey);
76
+ if (!entry) return undefined;
77
+ if (Date.now() - entry.ts > entry.ttl) { this._payments.delete(agentKey); return undefined; }
78
+ return entry.value;
79
+ }
80
+
81
+ async setCachedPayment(agentKey, value, ttlMs) {
82
+ if (this._payments.size >= PAYMENT_CACHE_MAX) this._payments.delete(this._payments.keys().next().value);
83
+ this._payments.set(agentKey, { value, ts: Date.now(), ttl: ttlMs });
84
+ }
85
+ }
86
+
87
+ function gateLog(level, message, data = {}) {
88
+ const entry = JSON.stringify({ ts: new Date().toISOString(), level, component: 'agentpayments', message, ...data });
89
+ if (level === 'error') console.error(entry);
90
+ else if (level === 'warn') console.warn(entry);
91
+ else console.log(entry);
92
+ }
93
+
94
+ const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
95
+ const RATE_LIMIT_MAX = 20;
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Verified crawler allowlist (DNS-over-HTTPS for edge environments)
99
+ // ---------------------------------------------------------------------------
100
+ const CRAWLER_PATTERNS = [
101
+ { pattern: /googlebot/i, suffix: '.googlebot.com' },
102
+ { pattern: /google-inspectiontool/i, suffix: '.google.com' },
103
+ { pattern: /bingbot/i, suffix: '.search.msn.com' },
104
+ { pattern: /slurp/i, suffix: '.crawl.yahoo.net' },
105
+ { pattern: /duckduckbot/i, suffix: '.duckduckgo.com' },
106
+ { pattern: /baiduspider/i, suffix: '.crawl.baidu.com' },
107
+ { pattern: /yandexbot/i, suffix: '.yandex.com' },
108
+ { pattern: /applebot/i, suffix: '.applebot.apple.com' },
109
+ ];
110
+ const CRAWLER_CACHE_TTL = 60 * 60 * 1000; // 1 hour, per-isolate
111
+ const _crawlerCache = new Map(); // ip -> { verified: boolean, exp: number }
112
+
113
+ async function isVerifiedCrawler(ip, userAgent) {
114
+ if (!userAgent || !ip || ip === 'unknown') return false;
115
+ const match = CRAWLER_PATTERNS.find((c) => c.pattern.test(userAgent));
116
+ if (!match) return false;
117
+
118
+ const cached = _crawlerCache.get(ip);
119
+ if (cached && cached.exp > Date.now()) return cached.verified;
120
+
121
+ let verified = false;
122
+ try {
123
+ // Reverse lookup: convert IP to PTR name (IPv4 only for now).
124
+ const reversed = ip.split('.').reverse().join('.');
125
+ const ptrResp = await fetch(
126
+ `https://cloudflare-dns.com/dns-query?name=${reversed}.in-addr.arpa&type=PTR`,
127
+ { headers: { Accept: 'application/dns-json' } },
128
+ );
129
+ const ptrData = await ptrResp.json();
130
+ const hostname = (ptrData.Answer?.[0]?.data || '').replace(/\.$/, '');
131
+ if (hostname && hostname.endsWith(match.suffix)) {
132
+ // Forward verify: hostname must resolve back to the original IP.
133
+ const aResp = await fetch(
134
+ `https://cloudflare-dns.com/dns-query?name=${hostname}&type=A`,
135
+ { headers: { Accept: 'application/dns-json' } },
136
+ );
137
+ const aData = await aResp.json();
138
+ verified = (aData.Answer || []).some((r) => r.data === ip);
139
+ }
140
+ } catch { /* DNS failure = not verified */ }
141
+
142
+ _crawlerCache.set(ip, { verified, exp: Date.now() + CRAWLER_CACHE_TTL });
143
+ return verified;
144
+ }
145
+
146
+ // Cache derived CryptoKey objects by secret so importKey isn't called on every
147
+ // hmacSign invocation. Edge isolates reuse module-level state between requests.
148
+ const _hmacKeyCache = new Map();
149
+
150
+ async function _getHmacKey(secret) {
151
+ if (_hmacKeyCache.has(secret)) return _hmacKeyCache.get(secret);
152
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
153
+ _hmacKeyCache.set(secret, key);
154
+ return key;
155
+ }
156
+
157
+ export async function hmacSign(data, secret) {
158
+ const key = await _getHmacKey(secret);
159
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
160
+ return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, '0')).join('');
161
+ }
162
+
163
+ // Separate fixed key for timing-safe string comparison (never changes).
164
+ let _tscKey = null;
165
+ async function _getTimingSafeCmpKey() {
166
+ if (_tscKey) return _tscKey;
167
+ _tscKey = await crypto.subtle.importKey('raw', new TextEncoder().encode('timing-safe-cmp'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
168
+ return _tscKey;
169
+ }
170
+
171
+ async function timingSafeEqual(a, b) {
172
+ if (a.length !== b.length) return false;
173
+ const enc = new TextEncoder();
174
+ const key = await _getTimingSafeCmpKey();
175
+ const [macA, macB] = await Promise.all([
176
+ crypto.subtle.sign('HMAC', key, enc.encode(a)),
177
+ crypto.subtle.sign('HMAC', key, enc.encode(b)),
178
+ ]);
179
+ const viewA = new Uint8Array(macA);
180
+ const viewB = new Uint8Array(macB);
181
+ let result = 0;
182
+ for (let i = 0; i < viewA.length; i++) result |= viewA[i] ^ viewB[i];
183
+ return result === 0;
184
+ }
185
+
186
+ async function sha256Hex(data) {
187
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
188
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
189
+ }
190
+
191
+ // Short HMAC of the client IP. Used to bind nonces and cookies to the client
192
+ // that solved the challenge, so a captured cookie is useless from another IP.
193
+ export async function clientIdForIp(ip, secret) {
194
+ return (await hmacSign(`client:${ip}`, secret)).slice(0, 16);
195
+ }
196
+
197
+ // Canvas fingerprints are a base64 slice of a data URL. Reject anything that
198
+ // isn't base64 or is degenerate (e.g. a single repeated character).
199
+ const FP_RE = /^[A-Za-z0-9+/]{10,}$/;
200
+ function isPlausibleFingerprint(fp) {
201
+ return FP_RE.test(fp) && new Set(fp).size >= 4;
202
+ }
203
+
204
+ // Proof-of-work: sha256(`${nonce}:${pow}`) must start with `difficulty` zero
205
+ // hex chars. Verification is a single hash; solving costs ~16^difficulty tries.
206
+ async function verifyPow(nonce, pow, difficulty) {
207
+ if (!/^\d{1,20}$/.test(pow)) return false;
208
+ return (await sha256Hex(`${nonce}:${pow}`)).startsWith('0'.repeat(difficulty));
209
+ }
210
+
211
+ export async function generateAgentKey(secret) {
212
+ const random = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
213
+ const sig = await hmacSign(random, secret);
214
+ return `${KEY_PREFIX}${random}_${sig.slice(0, 16)}`;
215
+ }
216
+
217
+ export async function isValidAgentKey(key, secret) {
218
+ if (!key || key.length > MAX_KEY_LENGTH || !key.startsWith(KEY_PREFIX)) return false;
219
+ const rest = key.slice(KEY_PREFIX.length);
220
+ const underscoreIndex = rest.indexOf('_');
221
+ if (underscoreIndex === -1) return false;
222
+ const random = rest.slice(0, underscoreIndex);
223
+ const sig = rest.slice(underscoreIndex + 1);
224
+ const expected = await hmacSign(random, secret);
225
+ return timingSafeEqual(sig, expected.slice(0, 16));
226
+ }
227
+
228
+ /**
229
+ * Verify a platform-issued agent key (agp_ prefix) using the vendor's verificationSecret.
230
+ * Key format: agp_${vendorId8}_${nonce16}_${sig16}
231
+ * sig = hmac('agp:vendorId:nonce', verificationSecret).slice(0,16)
232
+ */
233
+ async function isValidHostedKey(key, verificationSecret) {
234
+ if (!key || !key.startsWith(HOSTED_KEY_PREFIX)) return false;
235
+ const parts = key.split('_');
236
+ if (parts.length !== 4) return false;
237
+ const [, vendorId, nonce, sig] = parts;
238
+ if (!vendorId || !nonce || !sig || sig.length !== 16) return false;
239
+ const expected = (await hmacSign(`agp:${vendorId}:${nonce}`, verificationSecret)).slice(0, 16);
240
+ return timingSafeEqual(sig, expected);
241
+ }
242
+
243
+ /**
244
+ * Platform client for the Edge runtime. Cached by apiKey at module level so
245
+ * the verificationSecret is fetched once per isolate/worker restart, not per request.
246
+ */
247
+ const _edgePlatformClients = new Map();
248
+
249
+ class EdgePlatformClient {
250
+ constructor(apiKey, platformUrl = PLATFORM_API_URL) {
251
+ this.apiKey = apiKey;
252
+ this.platformUrl = platformUrl.replace(/\/$/, '');
253
+ this._verificationSecret = null;
254
+ this._secretFetch = null;
255
+ }
256
+
257
+ _authHeaders() {
258
+ return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
259
+ }
260
+
261
+ async getVerificationSecret() {
262
+ if (this._verificationSecret) return this._verificationSecret;
263
+ if (this._secretFetch) return this._secretFetch;
264
+ this._secretFetch = fetch(`${this.platformUrl}/v1/account`, { headers: this._authHeaders() })
265
+ .then((r) => {
266
+ if (!r.ok) throw new Error(`Platform /v1/account returned ${r.status}`);
267
+ return r.json();
268
+ })
269
+ .then((data) => {
270
+ this._verificationSecret = data.verificationSecret;
271
+ return data.verificationSecret;
272
+ })
273
+ .catch((err) => {
274
+ this._secretFetch = null;
275
+ throw err;
276
+ });
277
+ return this._secretFetch;
278
+ }
279
+
280
+ async issueKey() {
281
+ const r = await fetch(`${this.platformUrl}/v1/keys/issue`, {
282
+ method: 'POST',
283
+ headers: this._authHeaders(),
284
+ body: '{}',
285
+ });
286
+ if (!r.ok) throw new Error(`Platform /v1/keys/issue returned ${r.status}`);
287
+ return r.json(); // { key, issuedAt }
288
+ }
289
+ }
290
+
291
+ /** Get or create a cached EdgePlatformClient for the given apiKey. */
292
+ function getEdgePlatformClient(apiKey, platformUrl) {
293
+ const cacheKey = `${apiKey}:${platformUrl || PLATFORM_API_URL}`;
294
+ if (!_edgePlatformClients.has(cacheKey)) {
295
+ _edgePlatformClients.set(cacheKey, new EdgePlatformClient(apiKey, platformUrl));
296
+ }
297
+ return _edgePlatformClients.get(cacheKey);
298
+ }
299
+
300
+ async function rpcCall(rpcUrl, method, params, { retries = 2, backoffMs = 300 } = {}) {
301
+ let lastError;
302
+ for (let attempt = 0; attempt <= retries; attempt++) {
303
+ if (attempt > 0) await new Promise((r) => setTimeout(r, backoffMs * attempt));
304
+ try {
305
+ const resp = await fetch(rpcUrl, {
306
+ method: 'POST',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
309
+ });
310
+ // Only retry on 5xx (server-side transient errors); 4xx are permanent.
311
+ if (resp.status >= 500) { lastError = new Error(`RPC ${method} failed: ${resp.status}`); continue; }
312
+ if (!resp.ok) throw new Error(`RPC ${method} failed: ${resp.status}`);
313
+ return resp.json();
314
+ } catch (err) {
315
+ if (err.message?.includes('failed:')) throw err; // re-throw permanent 4xx
316
+ lastError = err;
317
+ }
318
+ }
319
+ throw lastError;
320
+ }
321
+
322
+ async function rpcCallWithFallback(rpcUrls, method, params, opts) {
323
+ let lastError;
324
+ for (const url of rpcUrls) {
325
+ try {
326
+ return await rpcCall(url, method, params, opts);
327
+ } catch (err) {
328
+ lastError = err;
329
+ if (rpcUrls.length > 1) gateLog('warn', 'RPC endpoint failed, trying fallback', { url, error: err.message });
330
+ }
331
+ }
332
+ throw lastError;
333
+ }
334
+
335
+ async function verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint) {
336
+ try {
337
+ // commitment: 'finalized' — confirmed blocks can be rolled back (rare but possible).
338
+ const ataData = await rpcCallWithFallback(rpcUrls, 'getTokenAccountsByOwner', [walletAddress, { mint: usdcMint }, { encoding: 'jsonParsed', commitment: 'finalized' }]);
339
+ const tokenAccounts = (ataData.result?.value || []).map((entry) => entry.pubkey);
340
+ // Only transfers landing in one of the vendor's USDC token accounts count as
341
+ // payment. Token accounts are mint-bound, so membership also guarantees the
342
+ // token is USDC for plain `transfer` instructions (which carry no mint field).
343
+ const vendorUsdcAccounts = new Set(tokenAccounts);
344
+ if (vendorUsdcAccounts.size === 0) return false; // vendor has no USDC account yet — no payment possible
345
+
346
+ const addressesToScan = [walletAddress, ...tokenAccounts];
347
+ const seen = new Set();
348
+ const allSignatures = [];
349
+
350
+ for (const addr of addressesToScan) {
351
+ const sigsData = await rpcCallWithFallback(rpcUrls, 'getSignaturesForAddress', [addr, { limit: 100, commitment: 'finalized' }]);
352
+ for (const sig of sigsData.result || []) {
353
+ if (!seen.has(sig.signature)) {
354
+ seen.add(sig.signature);
355
+ allSignatures.push(sig);
356
+ }
357
+ }
358
+ }
359
+
360
+ let txCallCount = 0;
361
+ for (const sigInfo of allSignatures) {
362
+ if (txCallCount >= MAX_TRANSACTIONS_PER_VERIFY) {
363
+ gateLog('warn', 'getTransaction cap reached', { key: agentKey.slice(0, 12) + '...', cap: MAX_TRANSACTIONS_PER_VERIFY });
364
+ break;
365
+ }
366
+ if (sigInfo.err) continue;
367
+ txCallCount++;
368
+
369
+ const txData = await rpcCallWithFallback(rpcUrls, 'getTransaction', [sigInfo.signature, { encoding: 'jsonParsed', commitment: 'finalized', maxSupportedTransactionVersion: 0 }]);
370
+ const tx = txData.result;
371
+ if (!tx) continue;
372
+
373
+ const instructions = tx.transaction?.message?.instructions || [];
374
+ const innerInstructions = tx.meta?.innerInstructions || [];
375
+ const allInstructions = [...instructions, ...innerInstructions.flatMap((inner) => inner.instructions || [])];
376
+
377
+ let hasMemo = false;
378
+ let hasPayment = false;
379
+
380
+ for (const ix of allInstructions) {
381
+ if (ix.program === 'spl-memo' || ix.programId === MEMO_PROGRAM) {
382
+ const memo = typeof ix.parsed === 'string' ? ix.parsed : '';
383
+ if (memo.includes(agentKey)) hasMemo = true;
384
+ }
385
+
386
+ if (ix.program === 'spl-token') {
387
+ const parsed = ix.parsed || {};
388
+ if (parsed.type === 'transfer' || parsed.type === 'transferChecked') {
389
+ const info = parsed.info || {};
390
+ // Payment must be delivered to one of the vendor's USDC token accounts.
391
+ if (!vendorUsdcAccounts.has(info.destination)) continue;
392
+ if (parsed.type === 'transferChecked' && info.mint !== usdcMint) continue;
393
+ // Integer base-unit comparison — avoids float precision issues at threshold.
394
+ const amountStr = info.tokenAmount?.amount ?? info.amount ?? '0';
395
+ const amountMicro = parseInt(amountStr, 10);
396
+ const minPaymentMicro = Math.round(MIN_PAYMENT * 1e6);
397
+ if (!Number.isNaN(amountMicro) && amountMicro >= minPaymentMicro) hasPayment = true;
398
+ }
399
+ }
400
+ }
401
+
402
+ if (hasMemo && hasPayment) return true;
403
+ }
404
+ } catch (error) {
405
+ gateLog('error', 'Solana RPC error', { error: error.message });
406
+ }
407
+
408
+ return false;
409
+ }
410
+
411
+ export function getCookie(request, name) {
412
+ const cookies = request.headers.get('cookie') || '';
413
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
414
+ return match ? decodeURIComponent(match[1]) : null;
415
+ }
416
+
417
+ export async function isValidCookie(request, secret, clientIp) {
418
+ const cookie = getCookie(request, COOKIE_NAME);
419
+ if (!cookie) return false;
420
+ const dotIndex = cookie.indexOf('.');
421
+ if (dotIndex === -1) return false;
422
+ const timestamp = cookie.slice(0, dotIndex);
423
+ const signature = cookie.slice(dotIndex + 1);
424
+ const ts = Number.parseInt(timestamp, 10);
425
+ if (Number.isNaN(ts) || Date.now() - ts > COOKIE_MAX_AGE * 1000) return false;
426
+ // Cookie signature is bound to the client IP that solved the challenge.
427
+ const clientId = await clientIdForIp(clientIp, secret);
428
+ const expected = await hmacSign(`cookie:${timestamp}:${clientId}`, secret);
429
+ return timingSafeEqual(signature, expected);
430
+ }
431
+
432
+ export function isPublicPath(pathname, allowlist = []) {
433
+ if (pathname === '/robots.txt') return true;
434
+ if (pathname.startsWith('/.well-known/')) return true;
435
+ if (allowlist.includes(pathname)) return true;
436
+ return false;
437
+ }
438
+
439
+ const BROWSER_UA_RE = /(Chrome|Chromium|Firefox|Safari|Edg|OPR|Opera|SamsungBrowser|UCBrowser|Mobile Safari)/i;
440
+ const BOT_UA_RE = /bot|crawl|spider|slurp|mediapartners|adsbot/i;
441
+
442
+ export function isBrowser(request) {
443
+ if (request.headers.get('sec-fetch-mode') || request.headers.get('sec-fetch-dest')) return true;
444
+ const ua = request.headers.get('user-agent') || '';
445
+ return Boolean(ua && !BOT_UA_RE.test(ua) && BROWSER_UA_RE.test(ua));
446
+ }
447
+
448
+ export function jsonResponse(body, status) {
449
+ return new Response(JSON.stringify(body, null, 2), { status, headers: { 'Content-Type': 'application/json' } });
450
+ }
451
+
452
+ /**
453
+ * Build x402-standard PaymentRequirements for the Solana exact scheme.
454
+ * Spec: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_svm.md
455
+ */
456
+ function buildX402PaymentRequirements({ walletAddress, mint, minPayment, debug, agentKey, resource }) {
457
+ const chainId = debug ? SOLANA_CHAIN_ID_DEVNET : SOLANA_CHAIN_ID_MAINNET;
458
+ const baseUnits = String(Math.round(minPayment * Math.pow(10, USDC_DECIMALS)));
459
+ const req = {
460
+ scheme: 'exact',
461
+ network: chainId,
462
+ amount: baseUnits,
463
+ asset: mint,
464
+ payTo: walletAddress,
465
+ maxTimeoutSeconds: 300,
466
+ extra: {
467
+ name: 'USDC',
468
+ decimals: USDC_DECIMALS,
469
+ ...(agentKey ? { memo: agentKey } : {}),
470
+ },
471
+ };
472
+ if (resource) req.resource = resource;
473
+ return req;
474
+ }
475
+
476
+ /**
477
+ * Like jsonResponse(body, 402) but adds x402Version, accepts[], and
478
+ * the X-PAYMENT-REQUIRED header (base64-encoded PaymentRequirements).
479
+ */
480
+ function paymentRequiredResponse(body, x402Opts) {
481
+ const payReq = buildX402PaymentRequirements(x402Opts);
482
+ const enriched = { x402Version: X402_VERSION, accepts: [payReq], ...body };
483
+ const encoded = btoa(JSON.stringify(payReq));
484
+ return new Response(JSON.stringify(enriched, null, 2), {
485
+ status: 402,
486
+ headers: {
487
+ 'Content-Type': 'application/json',
488
+ 'X-PAYMENT-REQUIRED': encoded,
489
+ },
490
+ });
491
+ }
492
+
493
+ export function challengePage(returnTo, nonce, powDifficulty = POW_DIFFICULTY) {
494
+ const safePath = (returnTo.startsWith('/') && !returnTo.startsWith('//')) ? returnTo : '/';
495
+ const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Verifying your access...</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#fafafa;color:#333}main{text-align:center;padding:2rem}.spinner{width:40px;height:40px;border:4px solid #e0e0e0;border-top-color:#333;border-radius:50%;animation:spin .8s linear infinite;margin:1rem auto}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><main role="status" aria-live="polite"><div class="spinner" aria-hidden="true"></div><p>Verifying your access&hellip;</p><noscript><p><strong>JavaScript is required to verify your access. Please enable JavaScript and reload this page.</strong></p></noscript></main><script>(function(){if(navigator.webdriver)return;if(!window.crypto||!window.crypto.subtle)return;var c=document.createElement("canvas");c.width=200;c.height=50;var ctx=c.getContext("2d");if(!ctx)return;ctx.font="18px Arial";ctx.fillStyle="#1a1a2e";ctx.fillText("verify",10,30);var data=c.toDataURL();if(!data||data.length<100)return;if(typeof window.innerWidth==="undefined"||window.innerWidth===0)return;var nonce=${JSON.stringify(nonce)};var target=${JSON.stringify('0'.repeat(powDifficulty))};var enc=new TextEncoder();var i=0;function submit(pow){var form=document.createElement("form");form.method="POST";form.action="/__challenge/verify";var fields={nonce:nonce,return_to:${JSON.stringify(safePath)},fp:data.slice(22,86),pow:pow};for(var key in fields){var input=document.createElement("input");input.type="hidden";input.name=key;input.value=fields[key];form.appendChild(input);}document.body.appendChild(form);form.submit();}function mine(){window.crypto.subtle.digest("SHA-256",enc.encode(nonce+":"+i)).then(function(buf){var b=new Uint8Array(buf);var h="";for(var j=0;j<4;j++)h+=(b[j]<16?"0":"")+b[j].toString(16);if(h.slice(0,target.length)===target)return submit(String(i));i++;mine();});}mine();})();</script></body></html>`;
496
+ return new Response(html, {
497
+ status: 200,
498
+ headers: {
499
+ 'Content-Type': 'text/html',
500
+ 'Cache-Control': 'no-store',
501
+ 'X-Frame-Options': 'DENY',
502
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action 'self'",
503
+ },
504
+ });
505
+ }
506
+
507
+ export function createEdgeGate(options = {}) {
508
+ const {
509
+ fetchUpstream,
510
+ getClientIp = ({ request }) =>
511
+ request.headers.get('cf-connecting-ip')
512
+ || request.headers.get('x-real-ip')
513
+ || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
514
+ || 'unknown',
515
+ publicPathAllowlist = [],
516
+ minPayment = MIN_PAYMENT,
517
+ powDifficulty = POW_DIFFICULTY,
518
+ envResolver,
519
+ // Pluggable state backend. Provide one of:
520
+ // store — a static Store instance (shared across all requests)
521
+ // getStore — factory ({ request, env, context }) => Store (use for KV,
522
+ // where env holds the binding resolved per-request)
523
+ store: staticStore,
524
+ getStore,
525
+ // When true (default), verified search crawlers bypass the gate entirely.
526
+ verifyCrawlers = true,
527
+ // When true (default in production), requests not over HTTPS get a 400.
528
+ // Edge runtimes are always HTTPS in practice, but the check guards against
529
+ // misconfigured local-dev tunnels forwarding HTTP traffic.
530
+ requireHttps,
531
+ } = options;
532
+
533
+ if (typeof fetchUpstream !== 'function') {
534
+ throw new Error('createEdgeGate requires fetchUpstream(request, env, context)');
535
+ }
536
+
537
+ // Default in-memory store shared across requests within this isolate.
538
+ const _defaultStore = new InMemoryStore();
539
+
540
+ return async function edgeGate(request, env = {}, context = {}) {
541
+ const store = getStore ? getStore({ request, env, context }) : (staticStore || _defaultStore);
542
+ const effectiveEnv = envResolver ? await envResolver({ request, env, context }) : env;
543
+ const url = new URL(request.url);
544
+ const secret = effectiveEnv.CHALLENGE_SECRET || 'default-secret-change-me';
545
+ const walletAddress = effectiveEnv.HOME_WALLET_ADDRESS || '';
546
+ const debug = effectiveEnv.DEBUG !== 'false';
547
+ if (secret === 'default-secret-change-me') {
548
+ if (debug) {
549
+ gateLog('warn', 'Using default CHALLENGE_SECRET. Set a strong secret before deploying to production.');
550
+ } else {
551
+ return jsonResponse({ error: 'server_error', message: 'Server misconfiguration: insecure default secret.' }, 500);
552
+ }
553
+ }
554
+ if (walletAddress && !BASE58_RE.test(walletAddress)) {
555
+ gateLog('error', 'Invalid HOME_WALLET_ADDRESS', { walletAddress });
556
+ return jsonResponse({ error: 'server_error', message: 'Server misconfiguration: invalid wallet address.' }, 500);
557
+ }
558
+ const rawRpc = effectiveEnv.SOLANA_RPC_URL || (debug ? RPC_DEVNET : RPC_MAINNET);
559
+ const rpcUrls = Array.isArray(rawRpc) ? rawRpc : [rawRpc];
560
+ const usdcMint = effectiveEnv.USDC_MINT || (debug ? USDC_MINT_DEVNET : USDC_MINT_MAINNET);
561
+ const httpsRequired = requireHttps ?? !debug;
562
+ // Hosted issuance mode: platform client is cached by apiKey across requests.
563
+ const platformApiKey = effectiveEnv.AGENTPAYMENTS_API_KEY || null;
564
+ const platformApiUrlEnv = effectiveEnv.AGENTPAYMENTS_PLATFORM_URL || PLATFORM_API_URL;
565
+ const platformClient = platformApiKey ? getEdgePlatformClient(platformApiKey, platformApiUrlEnv) : null;
566
+
567
+ if (isPublicPath(url.pathname, publicPathAllowlist)) {
568
+ return fetchUpstream(request, effectiveEnv, context);
569
+ }
570
+
571
+ // Reject plaintext HTTP in production.
572
+ if (httpsRequired && url.protocol !== 'https:') {
573
+ return jsonResponse({ error: 'https_required', message: 'This service requires a secure HTTPS connection.' }, 400);
574
+ }
575
+
576
+ // Verified search crawlers bypass the gate (no challenge, no payment).
577
+ if (verifyCrawlers) {
578
+ const crawlerIp = getClientIp({ request, env: effectiveEnv, context });
579
+ const ua = request.headers.get('user-agent') || '';
580
+ if (await isVerifiedCrawler(crawlerIp, ua)) return fetchUpstream(request, effectiveEnv, context);
581
+ }
582
+
583
+ if (url.pathname === '/__challenge/verify' && request.method === 'POST') {
584
+ const clientIp = getClientIp({ request, env: effectiveEnv, context });
585
+ if (!(await store.checkRateLimit(clientIp, RATE_LIMIT_WINDOW, RATE_LIMIT_MAX))) {
586
+ return jsonResponse({ error: 'rate_limited', message: 'Too many verification attempts. Please wait and try again.' }, 429);
587
+ }
588
+ const formData = await request.formData();
589
+ const nonce = (formData.get('nonce')?.toString() || '').slice(0, MAX_NONCE_LENGTH);
590
+ const returnTo = (formData.get('return_to')?.toString() || '/').slice(0, MAX_RETURN_TO_LENGTH);
591
+ const fp = (formData.get('fp')?.toString() || '').slice(0, MAX_FP_LENGTH);
592
+ const pow = (formData.get('pow')?.toString() || '').slice(0, MAX_POW_LENGTH);
593
+
594
+ // Nonce format: <ts>.<rand>.<sig>
595
+ const [nonceTs, nonceRand, nonceSig] = nonce.split('.');
596
+ if (!nonceTs || !nonceRand || !nonceSig || !isPlausibleFingerprint(fp)) {
597
+ return jsonResponse({ error: 'forbidden', message: 'Challenge verification failed.' }, 403);
598
+ }
599
+
600
+ const ts = Number.parseInt(nonceTs, 10);
601
+ if (Number.isNaN(ts) || Date.now() - ts > NONCE_TTL_MS) {
602
+ return jsonResponse({ error: 'forbidden', message: 'Challenge expired. Reload the page.' }, 403);
603
+ }
604
+
605
+ // Nonce is bound to the IP it was issued to.
606
+ const clientId = await clientIdForIp(clientIp, secret);
607
+ const expectedSig = await hmacSign(`nonce:${nonceTs}:${nonceRand}:${clientId}`, secret);
608
+ if (!(await timingSafeEqual(nonceSig, expectedSig))) {
609
+ return jsonResponse({ error: 'forbidden', message: 'Invalid challenge.' }, 403);
610
+ }
611
+
612
+ if (!(await verifyPow(nonce, pow, powDifficulty))) {
613
+ return jsonResponse({ error: 'forbidden', message: 'Challenge verification failed.' }, 403);
614
+ }
615
+
616
+ // Single use: a solved nonce cannot mint a second cookie.
617
+ if (!(await store.consumeNonce(nonceSig, NONCE_TTL_MS))) {
618
+ return jsonResponse({ error: 'forbidden', message: 'Challenge expired. Reload the page.' }, 403);
619
+ }
620
+
621
+ const now = Date.now().toString();
622
+ const cookieSig = await hmacSign(`cookie:${now}:${clientId}`, secret);
623
+ const safePath = (returnTo.startsWith('/') && !returnTo.startsWith('//')) ? returnTo : '/';
624
+
625
+ return new Response(null, {
626
+ status: 302,
627
+ headers: {
628
+ Location: safePath,
629
+ 'Set-Cookie': `${COOKIE_NAME}=${encodeURIComponent(`${now}.${cookieSig}`)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`,
630
+ },
631
+ });
632
+ }
633
+
634
+ if (!isBrowser(request)) {
635
+ const agentKey = request.headers.get('X-Agent-Key');
636
+
637
+ if (!agentKey) {
638
+ // Hosted mode: issue a metered platform key (agp_...).
639
+ // Local mode: generate a self-signed key (ag_...).
640
+ let newKey;
641
+ if (platformClient) {
642
+ try {
643
+ const issued = await platformClient.issueKey();
644
+ newKey = issued.key;
645
+ } catch (err) {
646
+ gateLog('warn', 'Platform key issuance failed, falling back to local key', { error: err.message });
647
+ newKey = await generateAgentKey(secret);
648
+ }
649
+ } else {
650
+ newKey = await generateAgentKey(secret);
651
+ }
652
+ return paymentRequiredResponse({
653
+ error: 'payment_required',
654
+ message: 'Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.',
655
+ your_key: newKey,
656
+ payment: {
657
+ chain: 'solana',
658
+ network: debug ? 'devnet' : 'mainnet-beta',
659
+ token: 'USDC',
660
+ amount: String(minPayment),
661
+ wallet_address: walletAddress,
662
+ memo: newKey,
663
+ instructions: `Send ${minPayment} USDC on Solana ${debug ? 'devnet' : 'mainnet'} to ${walletAddress} with memo "${newKey}". Then include the header X-Agent-Key: ${newKey} on all subsequent requests.`,
664
+ },
665
+ }, { walletAddress, mint: usdcMint, minPayment, debug, agentKey: newKey, resource: url.pathname });
666
+ }
667
+
668
+ // Validate the key. Platform-issued (agp_) verified with verificationSecret;
669
+ // local keys (ag_) verified with challengeSecret.
670
+ const isHostedKey = agentKey.startsWith(HOSTED_KEY_PREFIX);
671
+ if (isHostedKey) {
672
+ if (!platformClient) {
673
+ return jsonResponse({ error: 'forbidden', message: 'Platform-issued keys (agp_) require AGENTPAYMENTS_API_KEY to be configured.' }, 403);
674
+ }
675
+ let verSec;
676
+ try {
677
+ verSec = await platformClient.getVerificationSecret();
678
+ } catch (err) {
679
+ gateLog('error', 'Failed to fetch verificationSecret from platform', { error: err.message });
680
+ return jsonResponse({ error: 'service_unavailable', message: 'Key verification temporarily unavailable.' }, 503);
681
+ }
682
+ if (!(await isValidHostedKey(agentKey, verSec))) {
683
+ return jsonResponse({ error: 'forbidden', message: 'Invalid API key.' }, 403);
684
+ }
685
+ } else if (!(await isValidAgentKey(agentKey, secret))) {
686
+ return jsonResponse({
687
+ error: 'forbidden',
688
+ message: 'Invalid API key. Keys must be issued by this server.',
689
+ details: 'GET /.well-known/agent-access.json for access instructions.',
690
+ }, 403);
691
+ }
692
+
693
+ // Rate-limit the verification path (stricter than the challenge endpoint).
694
+ const agentKeyIp = getClientIp({ request, env: effectiveEnv, context });
695
+ if (!(await store.checkRateLimit(`ak:${agentKeyIp}`, RATE_LIMIT_WINDOW, AGENT_KEY_RATE_LIMIT_MAX))) {
696
+ return jsonResponse({ error: 'rate_limited', message: 'Too many payment verification requests. Please wait and try again.' }, 429);
697
+ }
698
+
699
+ if (!walletAddress) {
700
+ return jsonResponse({ error: 'server_error', message: 'Payment verification unavailable.' }, 500);
701
+ }
702
+
703
+ const cachedPayment = await store.getCachedPayment(agentKey);
704
+ if (cachedPayment === true) return fetchUpstream(request, effectiveEnv, context);
705
+ if (cachedPayment === false) {
706
+ // Negative result cached — skip the RPC scan until the TTL expires.
707
+ return paymentRequiredResponse({
708
+ error: 'payment_required',
709
+ message: 'Key is valid but payment has not been verified on-chain yet. Please send the USDC payment and allow a few moments for confirmation.',
710
+ your_key: agentKey,
711
+ payment: { chain: 'solana', network: debug ? 'devnet' : 'mainnet-beta', token: 'USDC', amount: String(minPayment), wallet_address: walletAddress, memo: agentKey },
712
+ }, { walletAddress, mint: usdcMint, minPayment, debug, agentKey, resource: url.pathname });
713
+ }
714
+ const paid = await verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint);
715
+ await store.setCachedPayment(agentKey, paid, paid ? PAYMENT_CACHE_TTL : NEGATIVE_CACHE_TTL_MS);
716
+ if (!paid) {
717
+ return paymentRequiredResponse({
718
+ error: 'payment_required',
719
+ message: 'Key is valid but payment has not been verified on-chain yet. Please send the USDC payment and allow a few moments for confirmation.',
720
+ your_key: agentKey,
721
+ payment: {
722
+ chain: 'solana',
723
+ network: debug ? 'devnet' : 'mainnet-beta',
724
+ token: 'USDC',
725
+ amount: String(minPayment),
726
+ wallet_address: walletAddress,
727
+ memo: agentKey,
728
+ },
729
+ }, { walletAddress, mint: usdcMint, minPayment, debug, agentKey, resource: url.pathname });
730
+ }
731
+
732
+ const ua = request.headers.get('user-agent') || 'unknown';
733
+ const ip = getClientIp({ request, env: effectiveEnv, context });
734
+ gateLog('info', 'Payment verified - agent access granted', { network: debug ? 'devnet' : 'mainnet', key: agentKey.slice(0, 12) + '...', ua, ip, path: url.pathname });
735
+ return fetchUpstream(request, effectiveEnv, context);
736
+ }
737
+
738
+ const browserIp = getClientIp({ request, env: effectiveEnv, context });
739
+ if (await isValidCookie(request, secret, browserIp)) {
740
+ return fetchUpstream(request, effectiveEnv, context);
741
+ }
742
+
743
+ // Rate-limit challenge page issuance to prevent unlimited nonce harvesting.
744
+ if (!(await store.checkRateLimit(`ci:${browserIp}`, RATE_LIMIT_WINDOW, CHALLENGE_ISSUE_RATE_LIMIT_MAX))) {
745
+ return jsonResponse({ error: 'rate_limited', message: 'Too many requests. Please try again later.' }, 429);
746
+ }
747
+
748
+ const nonceTs = Date.now().toString();
749
+ const nonceRand = Array.from(crypto.getRandomValues(new Uint8Array(8))).map((b) => b.toString(16).padStart(2, '0')).join('');
750
+ const nonceClientId = await clientIdForIp(browserIp, secret);
751
+ const nonceSig = await hmacSign(`nonce:${nonceTs}:${nonceRand}:${nonceClientId}`, secret);
752
+ return challengePage(url.pathname + url.search, `${nonceTs}.${nonceRand}.${nonceSig}`, powDifficulty);
753
+ };
754
+ }
package/netlify.js ADDED
@@ -0,0 +1,24 @@
1
+ import { createEdgeGate } from './index.js';
2
+
3
+ export function createNetlifyGate(options = {}) {
4
+ const { publicPathAllowlist = [], minPayment, powDifficulty } = options;
5
+
6
+ const gate = createEdgeGate({
7
+ publicPathAllowlist,
8
+ minPayment,
9
+ powDifficulty,
10
+ getClientIp: ({ context }) => context?.ip || 'unknown',
11
+ envResolver: () => ({
12
+ CHALLENGE_SECRET: Deno.env.get('CHALLENGE_SECRET') || 'default-secret-change-me',
13
+ HOME_WALLET_ADDRESS: Deno.env.get('HOME_WALLET_ADDRESS') || '',
14
+ SOLANA_RPC_URL: Deno.env.get('SOLANA_RPC_URL') || '',
15
+ USDC_MINT: Deno.env.get('USDC_MINT') || '',
16
+ DEBUG: Deno.env.get('DEBUG') || '',
17
+ AGENTPAYMENTS_API_KEY: Deno.env.get('AGENTPAYMENTS_API_KEY') || '',
18
+ AGENTPAYMENTS_PLATFORM_URL: Deno.env.get('AGENTPAYMENTS_PLATFORM_URL') || '',
19
+ }),
20
+ fetchUpstream: (request, _env, context) => context.next(request),
21
+ });
22
+
23
+ return (request, context) => gate(request, {}, context);
24
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@agentpayments/edge",
3
+ "version": "0.1.0",
4
+ "description": "AgentPayments gate for Cloudflare Workers, Netlify Edge, and Vercel Edge Functions — charge AI agents USDC on Solana",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "node --test"
9
+ },
10
+ "types": "index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "default": "./index.js"
15
+ },
16
+ "./cloudflare": "./cloudflare.js",
17
+ "./cloudflare-kv-store": "./cloudflare-kv-store.js",
18
+ "./netlify": "./netlify.js",
19
+ "./vercel": "./vercel.js"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "index.d.ts",
24
+ "cloudflare.js",
25
+ "cloudflare-kv-store.js",
26
+ "netlify.js",
27
+ "vercel.js",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "keywords": [
32
+ "agentpayments",
33
+ "ai-agents",
34
+ "solana",
35
+ "usdc",
36
+ "payments",
37
+ "cloudflare-workers",
38
+ "edge",
39
+ "x402"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/adambrzosko/AgentPayments.git"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
package/vercel.js ADDED
@@ -0,0 +1,29 @@
1
+ import { createEdgeGate } from './index.js';
2
+
3
+ // Vercel adapter (Edge Middleware / Route Handler runtime).
4
+ // Caller provides upstreamNext() to return NextResponse.next() (or equivalent).
5
+ export function createVercelEdgeGate(options = {}) {
6
+ const {
7
+ publicPathAllowlist = [],
8
+ minPayment,
9
+ powDifficulty,
10
+ env = {},
11
+ upstreamNext,
12
+ getClientIp,
13
+ } = options;
14
+
15
+ if (typeof upstreamNext !== 'function') {
16
+ throw new Error('createVercelEdgeGate requires upstreamNext(request)');
17
+ }
18
+
19
+ const gate = createEdgeGate({
20
+ publicPathAllowlist,
21
+ minPayment,
22
+ powDifficulty,
23
+ getClientIp: ({ request }) =>
24
+ (getClientIp ? getClientIp(request) : request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()) || 'unknown',
25
+ fetchUpstream: (request) => upstreamNext(request),
26
+ });
27
+
28
+ return (request) => gate(request, env, {});
29
+ }