@dupecom/botcha-cloudflare 0.20.2 → 0.23.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/README.md +74 -9
- package/dist/agent-auth.d.ts +129 -0
- package/dist/agent-auth.d.ts.map +1 -0
- package/dist/agent-auth.js +210 -0
- package/dist/agents.d.ts +10 -0
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +51 -1
- package/dist/app-gate.d.ts +6 -0
- package/dist/app-gate.d.ts.map +1 -0
- package/dist/app-gate.js +69 -0
- package/dist/apps.d.ts +13 -4
- package/dist/apps.d.ts.map +1 -1
- package/dist/apps.js +30 -4
- package/dist/dashboard/account.d.ts +63 -0
- package/dist/dashboard/account.d.ts.map +1 -0
- package/dist/dashboard/account.js +488 -0
- package/dist/dashboard/api.js +15 -68
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +14 -14
- package/dist/dashboard/docs.d.ts.map +1 -1
- package/dist/dashboard/docs.js +146 -3
- package/dist/dashboard/layout.d.ts.map +1 -1
- package/dist/dashboard/layout.js +2 -2
- package/dist/dashboard/mcp-setup.d.ts +15 -0
- package/dist/dashboard/mcp-setup.d.ts.map +1 -0
- package/dist/dashboard/mcp-setup.js +391 -0
- package/dist/dashboard/showcase.d.ts +6 -10
- package/dist/dashboard/showcase.d.ts.map +1 -1
- package/dist/dashboard/showcase.js +67 -991
- package/dist/dashboard/whitepaper.d.ts.map +1 -1
- package/dist/dashboard/whitepaper.js +42 -4
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +660 -83
- package/dist/mcp.d.ts +20 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +1290 -0
- package/dist/oauth-agent.d.ts +130 -0
- package/dist/oauth-agent.d.ts.map +1 -0
- package/dist/oauth-agent.js +194 -0
- package/dist/static.d.ts +781 -5
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +790 -111
- package/dist/tap-a2a-routes.d.ts +355 -0
- package/dist/tap-a2a-routes.d.ts.map +1 -0
- package/dist/tap-a2a-routes.js +475 -0
- package/dist/tap-a2a.d.ts +199 -0
- package/dist/tap-a2a.d.ts.map +1 -0
- package/dist/tap-a2a.js +502 -0
- package/dist/tap-agents.d.ts +15 -0
- package/dist/tap-agents.d.ts.map +1 -1
- package/dist/tap-agents.js +31 -1
- package/dist/tap-ans-routes.d.ts +302 -0
- package/dist/tap-ans-routes.d.ts.map +1 -0
- package/dist/tap-ans-routes.js +535 -0
- package/dist/tap-ans.d.ts +241 -0
- package/dist/tap-ans.d.ts.map +1 -0
- package/dist/tap-ans.js +481 -0
- package/dist/tap-delegation-routes.d.ts.map +1 -1
- package/dist/tap-delegation-routes.js +11 -0
- package/dist/tap-did.d.ts +140 -0
- package/dist/tap-did.d.ts.map +1 -0
- package/dist/tap-did.js +262 -0
- package/dist/tap-oidca-routes.d.ts +383 -0
- package/dist/tap-oidca-routes.d.ts.map +1 -0
- package/dist/tap-oidca-routes.js +597 -0
- package/dist/tap-oidca.d.ts +288 -0
- package/dist/tap-oidca.d.ts.map +1 -0
- package/dist/tap-oidca.js +461 -0
- package/dist/tap-routes.d.ts +24 -8
- package/dist/tap-routes.d.ts.map +1 -1
- package/dist/tap-routes.js +169 -23
- package/dist/tap-vc-routes.d.ts +358 -0
- package/dist/tap-vc-routes.d.ts.map +1 -0
- package/dist/tap-vc-routes.js +367 -0
- package/dist/tap-vc.d.ts +125 -0
- package/dist/tap-vc.d.ts.map +1 -0
- package/dist/tap-vc.js +245 -0
- package/dist/tap-x402-routes.d.ts +89 -0
- package/dist/tap-x402-routes.d.ts.map +1 -0
- package/dist/tap-x402-routes.js +579 -0
- package/dist/tap-x402.d.ts +222 -0
- package/dist/tap-x402.d.ts.map +1 -0
- package/dist/tap-x402.js +546 -0
- package/dist/webhooks.d.ts +99 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +642 -0
- package/package.json +3 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 Payment Gating — Route Handlers
|
|
3
|
+
*
|
|
4
|
+
* Exposes BOTCHA's x402-compliant API endpoints:
|
|
5
|
+
*
|
|
6
|
+
* POST /v1/x402/verify-payment — Facilitator: verify a payment proof
|
|
7
|
+
* GET /v1/x402/challenge — Pay-for-verification (402 → X-Payment → token)
|
|
8
|
+
* GET /agent-only/x402 — Demo: requires BOTCHA token + x402 payment
|
|
9
|
+
* POST /v1/x402/webhook — Settlement notifications from facilitators
|
|
10
|
+
*
|
|
11
|
+
* All endpoints are built for Cloudflare Workers (Hono framework, no Node APIs).
|
|
12
|
+
*/
|
|
13
|
+
import { extractBearerToken, verifyToken, getSigningPublicKeyJWK } from './auth.js';
|
|
14
|
+
function getVerificationPublicKey(env) {
|
|
15
|
+
const rawSigningKey = env?.JWT_SIGNING_KEY;
|
|
16
|
+
if (!rawSigningKey)
|
|
17
|
+
return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const signingKey = JSON.parse(rawSigningKey);
|
|
20
|
+
return getSigningPublicKeyJWK(signingKey);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
import { buildPaymentRequiredDescriptor, parsePaymentHeader, verifyX402Payment, issueTokenForPayment, buildPaymentResponseHeader, storePaymentRecord, processWebhookEvent, VERIFICATION_PRICE_USDC_UNITS, VERIFICATION_PRICE_HUMAN, BASE_CHAIN_ID, BOTCHA_WALLET, USDC_BASE_ADDRESS, PAYMENT_DEADLINE_SECONDS, } from './tap-x402.js';
|
|
27
|
+
// ============ HELPERS ============
|
|
28
|
+
function getSigningKey(env) {
|
|
29
|
+
if (!env.JWT_SIGNING_KEY)
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(env.JWT_SIGNING_KEY);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
console.error('Failed to parse JWT_SIGNING_KEY — falling back to HS256');
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function getPublicKey(env) {
|
|
40
|
+
const sk = getSigningKey(env);
|
|
41
|
+
return sk ? getSigningPublicKeyJWK(sk) : undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve BOTCHA wallet from env (allows override via BOTCHA_PAYMENT_WALLET).
|
|
45
|
+
*/
|
|
46
|
+
function getWallet(env) {
|
|
47
|
+
return env.BOTCHA_PAYMENT_WALLET || BOTCHA_WALLET;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve app_id from query string, header, or JWT claim.
|
|
51
|
+
* Returns undefined if not present.
|
|
52
|
+
*/
|
|
53
|
+
function extractAppId(c) {
|
|
54
|
+
return (c.req.query('app_id') ||
|
|
55
|
+
c.req.header('x-app-id') ||
|
|
56
|
+
undefined);
|
|
57
|
+
}
|
|
58
|
+
// ============ ROUTE HANDLERS ============
|
|
59
|
+
/**
|
|
60
|
+
* POST /v1/x402/verify-payment
|
|
61
|
+
*
|
|
62
|
+
* Acts as a lightweight x402-compatible facilitator.
|
|
63
|
+
* Accepts a payment proof (base64-encoded X-Payment payload) and
|
|
64
|
+
* verifies: structure, recipient, amount, deadline, nonce, signature.
|
|
65
|
+
*
|
|
66
|
+
* Request body:
|
|
67
|
+
* { payment: "<base64-encoded X402PaymentProof>" }
|
|
68
|
+
* OR submit the raw X-Payment header value directly in the body field.
|
|
69
|
+
*
|
|
70
|
+
* Response:
|
|
71
|
+
* 200 { verified: true, txHash, payer, amount, network }
|
|
72
|
+
* 400 { verified: false, error, errorCode }
|
|
73
|
+
*/
|
|
74
|
+
export async function verifyPaymentRoute(c) {
|
|
75
|
+
try {
|
|
76
|
+
// Auth check first — payment verification is a privileged operation
|
|
77
|
+
const authHeader = c.req.header('authorization');
|
|
78
|
+
const token = extractBearerToken(authHeader);
|
|
79
|
+
if (!token) {
|
|
80
|
+
return c.json({
|
|
81
|
+
verified: false,
|
|
82
|
+
error: 'UNAUTHORIZED',
|
|
83
|
+
message: 'Bearer token required. Get a token via the BOTCHA challenge flow.',
|
|
84
|
+
}, 401);
|
|
85
|
+
}
|
|
86
|
+
const publicKey = getVerificationPublicKey(c.env);
|
|
87
|
+
const tokenResult = await verifyToken(token, c.env.JWT_SECRET, c.env, undefined, publicKey);
|
|
88
|
+
if (!tokenResult.valid) {
|
|
89
|
+
return c.json({
|
|
90
|
+
verified: false,
|
|
91
|
+
error: 'INVALID_TOKEN',
|
|
92
|
+
message: 'Bearer token is invalid or expired',
|
|
93
|
+
}, 401);
|
|
94
|
+
}
|
|
95
|
+
const body = await c.req.json().catch(() => ({}));
|
|
96
|
+
const paymentHeader = body.payment || c.req.header('x-payment');
|
|
97
|
+
if (!paymentHeader) {
|
|
98
|
+
return c.json({
|
|
99
|
+
verified: false,
|
|
100
|
+
error: 'Missing payment proof. Provide base64-encoded X-Payment value in body.payment or X-Payment header.',
|
|
101
|
+
errorCode: 'MISSING_PAYMENT',
|
|
102
|
+
hint: 'Base64-encode a JSON object: { scheme: "exact", network: "eip155:8453", payload: { from, to, value, validAfter, validBefore, nonce, signature } }',
|
|
103
|
+
}, 400);
|
|
104
|
+
}
|
|
105
|
+
const proof = parsePaymentHeader(paymentHeader);
|
|
106
|
+
if (!proof) {
|
|
107
|
+
return c.json({
|
|
108
|
+
verified: false,
|
|
109
|
+
error: 'Invalid payment proof format. Must be base64-encoded JSON matching X402PaymentProof schema.',
|
|
110
|
+
errorCode: 'INVALID_FORMAT',
|
|
111
|
+
}, 400);
|
|
112
|
+
}
|
|
113
|
+
const result = await verifyX402Payment(proof, c.env.NONCES, {
|
|
114
|
+
requiredRecipient: body.required_recipient || getWallet(c.env),
|
|
115
|
+
requiredAmount: body.required_amount || VERIFICATION_PRICE_USDC_UNITS,
|
|
116
|
+
});
|
|
117
|
+
if (!result.verified) {
|
|
118
|
+
return c.json({
|
|
119
|
+
verified: false,
|
|
120
|
+
error: result.error,
|
|
121
|
+
errorCode: result.errorCode,
|
|
122
|
+
}, 400);
|
|
123
|
+
}
|
|
124
|
+
// Return x402-compliant success response
|
|
125
|
+
c.header('X-Payment-Response', buildPaymentResponseHeader(result));
|
|
126
|
+
return c.json({
|
|
127
|
+
verified: true,
|
|
128
|
+
txHash: result.txHash,
|
|
129
|
+
payer: result.payer,
|
|
130
|
+
amount: result.amount,
|
|
131
|
+
network: result.network,
|
|
132
|
+
timestamp: new Date().toISOString(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
console.error('verify-payment error:', error);
|
|
137
|
+
return c.json({
|
|
138
|
+
verified: false,
|
|
139
|
+
error: 'Internal verification error',
|
|
140
|
+
errorCode: 'INTERNAL_ERROR',
|
|
141
|
+
}, 500);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* GET /v1/x402/challenge
|
|
146
|
+
*
|
|
147
|
+
* The flagship pay-for-verification endpoint.
|
|
148
|
+
*
|
|
149
|
+
* Without X-Payment header → 402 Payment Required
|
|
150
|
+
* With valid X-Payment header → 200 with BOTCHA access_token
|
|
151
|
+
*
|
|
152
|
+
* This allows agents to get a BOTCHA verification token by paying
|
|
153
|
+
* $0.001 USDC instead of solving a challenge.
|
|
154
|
+
*
|
|
155
|
+
* x402 standard flow:
|
|
156
|
+
* 1. Agent: GET /v1/x402/challenge
|
|
157
|
+
* 2. Server: 402 + X-Payment-Required: { amount, payTo, asset, ... }
|
|
158
|
+
* 3. Agent: signs ERC-3009 transferWithAuthorization
|
|
159
|
+
* 4. Agent: GET /v1/x402/challenge + X-Payment: <base64-proof>
|
|
160
|
+
* 5. Server: 200 + { access_token, ... } + X-Payment-Response: { success, txHash }
|
|
161
|
+
*/
|
|
162
|
+
export async function x402ChallengeRoute(c) {
|
|
163
|
+
try {
|
|
164
|
+
const resource = '/v1/x402/challenge';
|
|
165
|
+
const appId = extractAppId(c);
|
|
166
|
+
const wallet = getWallet(c.env);
|
|
167
|
+
// Check for X-Payment header (payment proof from agent)
|
|
168
|
+
const paymentHeader = c.req.header('x-payment');
|
|
169
|
+
if (!paymentHeader) {
|
|
170
|
+
// No payment → return 402 with payment requirements
|
|
171
|
+
const descriptor = buildPaymentRequiredDescriptor(resource, {
|
|
172
|
+
description: `Pay ${VERIFICATION_PRICE_HUMAN} USDC to receive a BOTCHA verified agent token. Skip the challenge, pay to prove you're a trusted agent.`,
|
|
173
|
+
payTo: wallet,
|
|
174
|
+
appId,
|
|
175
|
+
});
|
|
176
|
+
return c.json({
|
|
177
|
+
error: 'PAYMENT_REQUIRED',
|
|
178
|
+
message: `This endpoint requires a ${VERIFICATION_PRICE_HUMAN} USDC payment on Base to issue a BOTCHA verification token.`,
|
|
179
|
+
x402: descriptor,
|
|
180
|
+
instructions: [
|
|
181
|
+
'1. Encode payment proof as base64 JSON matching X402PaymentProof schema',
|
|
182
|
+
'2. Retry this GET request with header: X-Payment: <base64-encoded-proof>',
|
|
183
|
+
'3. Receive access_token valid for 1 hour',
|
|
184
|
+
],
|
|
185
|
+
alternative: 'Solve a free challenge instead: GET /v1/challenges?app_id=...',
|
|
186
|
+
}, 402, {
|
|
187
|
+
'X-Payment-Required': JSON.stringify(descriptor),
|
|
188
|
+
'X-Payment-Scheme': 'exact',
|
|
189
|
+
'Content-Type': 'application/json',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// Parse payment proof
|
|
193
|
+
const proof = parsePaymentHeader(paymentHeader);
|
|
194
|
+
if (!proof) {
|
|
195
|
+
return c.json({
|
|
196
|
+
verified: false,
|
|
197
|
+
error: 'Invalid X-Payment header. Must be base64-encoded X402PaymentProof JSON.',
|
|
198
|
+
errorCode: 'INVALID_PAYMENT_FORMAT',
|
|
199
|
+
}, 400);
|
|
200
|
+
}
|
|
201
|
+
// Verify payment
|
|
202
|
+
const verification = await verifyX402Payment(proof, c.env.NONCES, {
|
|
203
|
+
requiredRecipient: wallet,
|
|
204
|
+
requiredAmount: VERIFICATION_PRICE_USDC_UNITS,
|
|
205
|
+
});
|
|
206
|
+
if (!verification.verified) {
|
|
207
|
+
return c.json({
|
|
208
|
+
verified: false,
|
|
209
|
+
error: verification.error,
|
|
210
|
+
errorCode: verification.errorCode,
|
|
211
|
+
}, 402, {
|
|
212
|
+
'X-Payment-Required': JSON.stringify(buildPaymentRequiredDescriptor(resource, { payTo: wallet, appId })),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// Payment verified — issue BOTCHA token
|
|
216
|
+
const signingKey = getSigningKey(c.env);
|
|
217
|
+
const tokenStart = Date.now();
|
|
218
|
+
const tokenResult = await issueTokenForPayment(c.env.CHALLENGES, c.env, {
|
|
219
|
+
payer: verification.payer,
|
|
220
|
+
paymentId: verification.txHash,
|
|
221
|
+
appId,
|
|
222
|
+
audience: 'botcha-x402-verified',
|
|
223
|
+
solveTimeMs: 0, // payment-based, no solve time
|
|
224
|
+
}, signingKey);
|
|
225
|
+
const issuedMs = Date.now() - tokenStart;
|
|
226
|
+
// Store payment record
|
|
227
|
+
const paymentRecord = {
|
|
228
|
+
payment_id: verification.txHash,
|
|
229
|
+
payer: verification.payer,
|
|
230
|
+
amount: verification.amount,
|
|
231
|
+
network: verification.network,
|
|
232
|
+
tx_hash: verification.txHash,
|
|
233
|
+
resource,
|
|
234
|
+
nonce: proof.payload.nonce,
|
|
235
|
+
botcha_app_id: appId,
|
|
236
|
+
access_token: tokenResult.access_token,
|
|
237
|
+
verified_at: Math.floor(Date.now() / 1000),
|
|
238
|
+
status: 'verified',
|
|
239
|
+
};
|
|
240
|
+
await storePaymentRecord(c.env.NONCES, paymentRecord);
|
|
241
|
+
// Set x402 response headers
|
|
242
|
+
c.header('X-Payment-Response', buildPaymentResponseHeader(verification));
|
|
243
|
+
return c.json({
|
|
244
|
+
success: true,
|
|
245
|
+
verified: true,
|
|
246
|
+
method: 'x402-payment',
|
|
247
|
+
message: `Payment verified (${verification.amount} USDC units on ${verification.network}). BOTCHA token issued.`,
|
|
248
|
+
// === Token ===
|
|
249
|
+
access_token: tokenResult.access_token,
|
|
250
|
+
refresh_token: tokenResult.refresh_token,
|
|
251
|
+
expires_in: tokenResult.expires_in,
|
|
252
|
+
refresh_expires_in: tokenResult.refresh_expires_in,
|
|
253
|
+
token_type: 'Bearer',
|
|
254
|
+
// === Payment info ===
|
|
255
|
+
payment: {
|
|
256
|
+
payer: verification.payer,
|
|
257
|
+
txHash: verification.txHash,
|
|
258
|
+
amount: verification.amount,
|
|
259
|
+
amountHuman: `${(parseInt(verification.amount || '0') / 1e6).toFixed(6)} USDC`,
|
|
260
|
+
network: verification.network,
|
|
261
|
+
issuedMs,
|
|
262
|
+
},
|
|
263
|
+
// === What to do next ===
|
|
264
|
+
usage: {
|
|
265
|
+
header: 'Authorization: Bearer <access_token>',
|
|
266
|
+
try_it: 'GET /agent-only',
|
|
267
|
+
x402_demo: 'GET /agent-only/x402',
|
|
268
|
+
full_docs: 'GET / with Authorization: Bearer <access_token>',
|
|
269
|
+
refresh: 'POST /v1/token/refresh with {"refresh_token":"<refresh_token>"}',
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
console.error('x402 challenge route error:', error);
|
|
275
|
+
return c.json({
|
|
276
|
+
error: 'INTERNAL_ERROR',
|
|
277
|
+
message: 'Internal server error during x402 payment processing',
|
|
278
|
+
}, 500);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* GET /agent-only/x402
|
|
283
|
+
*
|
|
284
|
+
* Demo endpoint: requires BOTH BOTCHA Bearer token + x402 micropayment.
|
|
285
|
+
* Reference implementation for "verified + paid" double-gated resources.
|
|
286
|
+
*
|
|
287
|
+
* Without token → 401 (get BOTCHA verified first)
|
|
288
|
+
* Without payment → 402 (pay $0.001 USDC)
|
|
289
|
+
* With both → 200 (access granted)
|
|
290
|
+
*/
|
|
291
|
+
export async function agentOnlyX402Route(c) {
|
|
292
|
+
const resource = '/agent-only/x402';
|
|
293
|
+
const wallet = getWallet(c.env);
|
|
294
|
+
// ---- Step 1: BOTCHA Bearer token verification ----
|
|
295
|
+
const authHeader = c.req.header('authorization');
|
|
296
|
+
const token = extractBearerToken(authHeader);
|
|
297
|
+
if (!token) {
|
|
298
|
+
return c.json({
|
|
299
|
+
error: 'UNAUTHORIZED',
|
|
300
|
+
message: 'This resource requires BOTCHA verification AND an x402 micropayment.',
|
|
301
|
+
step1: {
|
|
302
|
+
description: 'First, get a BOTCHA verification token (free challenge or paid x402)',
|
|
303
|
+
challenge: 'GET /v1/token → POST /v1/token/verify → access_token',
|
|
304
|
+
paid: `GET /v1/x402/challenge with X-Payment header → access_token`,
|
|
305
|
+
},
|
|
306
|
+
step2: {
|
|
307
|
+
description: 'Then, pay the x402 resource fee with your X-Payment header',
|
|
308
|
+
amount: VERIFICATION_PRICE_HUMAN,
|
|
309
|
+
network: `Base (eip155:${BASE_CHAIN_ID})`,
|
|
310
|
+
},
|
|
311
|
+
}, 401);
|
|
312
|
+
}
|
|
313
|
+
const publicKey = getPublicKey(c.env);
|
|
314
|
+
const tokenResult = await verifyToken(token, c.env.JWT_SECRET, c.env, undefined, publicKey);
|
|
315
|
+
if (!tokenResult.valid) {
|
|
316
|
+
return c.json({
|
|
317
|
+
error: 'INVALID_TOKEN',
|
|
318
|
+
message: tokenResult.error || 'Bearer token is invalid or expired',
|
|
319
|
+
hint: 'Get a fresh token: GET /v1/token → POST /v1/token/verify',
|
|
320
|
+
}, 401);
|
|
321
|
+
}
|
|
322
|
+
// ---- Step 2: x402 payment verification ----
|
|
323
|
+
const paymentHeader = c.req.header('x-payment');
|
|
324
|
+
if (!paymentHeader) {
|
|
325
|
+
const descriptor = buildPaymentRequiredDescriptor(resource, {
|
|
326
|
+
description: `BOTCHA-verified agent resource. Costs ${VERIFICATION_PRICE_HUMAN} USDC per access. Your BOTCHA identity is already verified — now pay to access.`,
|
|
327
|
+
payTo: wallet,
|
|
328
|
+
mimeType: 'application/json',
|
|
329
|
+
});
|
|
330
|
+
return c.json({
|
|
331
|
+
error: 'PAYMENT_REQUIRED',
|
|
332
|
+
message: `You are BOTCHA-verified! Now pay ${VERIFICATION_PRICE_HUMAN} USDC to access this resource.`,
|
|
333
|
+
verified_as: {
|
|
334
|
+
type: tokenResult.payload?.type,
|
|
335
|
+
app_id: tokenResult.payload?.app_id,
|
|
336
|
+
solve_time_ms: tokenResult.payload?.solveTime,
|
|
337
|
+
},
|
|
338
|
+
x402: descriptor,
|
|
339
|
+
instructions: [
|
|
340
|
+
'1. Sign an ERC-3009 transferWithAuthorization (payTo: ' + wallet + ', amount: ' + VERIFICATION_PRICE_USDC_UNITS + ' USDC units)',
|
|
341
|
+
'2. Base64-encode the X402PaymentProof JSON',
|
|
342
|
+
'3. Retry this GET request with X-Payment: <base64-proof>',
|
|
343
|
+
],
|
|
344
|
+
}, 402, {
|
|
345
|
+
'X-Payment-Required': JSON.stringify(descriptor),
|
|
346
|
+
'X-Payment-Scheme': 'exact',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const proof = parsePaymentHeader(paymentHeader);
|
|
350
|
+
if (!proof) {
|
|
351
|
+
return c.json({
|
|
352
|
+
error: 'INVALID_PAYMENT',
|
|
353
|
+
message: 'X-Payment header is not valid base64-encoded X402PaymentProof JSON',
|
|
354
|
+
}, 400);
|
|
355
|
+
}
|
|
356
|
+
const verification = await verifyX402Payment(proof, c.env.NONCES, {
|
|
357
|
+
requiredRecipient: wallet,
|
|
358
|
+
requiredAmount: VERIFICATION_PRICE_USDC_UNITS,
|
|
359
|
+
});
|
|
360
|
+
if (!verification.verified) {
|
|
361
|
+
return c.json({
|
|
362
|
+
error: 'PAYMENT_FAILED',
|
|
363
|
+
message: verification.error,
|
|
364
|
+
errorCode: verification.errorCode,
|
|
365
|
+
}, 402, {
|
|
366
|
+
'X-Payment-Required': JSON.stringify(buildPaymentRequiredDescriptor(resource, { payTo: wallet })),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// ---- Both verified! ----
|
|
370
|
+
const appId = extractAppId(c) || tokenResult.payload?.app_id;
|
|
371
|
+
// Store payment record
|
|
372
|
+
const paymentRecord = {
|
|
373
|
+
payment_id: verification.txHash,
|
|
374
|
+
payer: verification.payer,
|
|
375
|
+
amount: verification.amount,
|
|
376
|
+
network: verification.network,
|
|
377
|
+
tx_hash: verification.txHash,
|
|
378
|
+
resource,
|
|
379
|
+
nonce: proof.payload.nonce,
|
|
380
|
+
botcha_app_id: appId,
|
|
381
|
+
verified_at: Math.floor(Date.now() / 1000),
|
|
382
|
+
status: 'verified',
|
|
383
|
+
};
|
|
384
|
+
await storePaymentRecord(c.env.NONCES, paymentRecord);
|
|
385
|
+
// Set x402 response header
|
|
386
|
+
c.header('X-Payment-Response', buildPaymentResponseHeader(verification));
|
|
387
|
+
const payload = tokenResult.payload;
|
|
388
|
+
return c.json({
|
|
389
|
+
success: true,
|
|
390
|
+
message: '🤖 Double verified! You are a BOTCHA-verified agent that paid via x402.',
|
|
391
|
+
access: 'GRANTED',
|
|
392
|
+
timestamp: new Date().toISOString(),
|
|
393
|
+
// Identity proof
|
|
394
|
+
botcha_identity: {
|
|
395
|
+
verified: true,
|
|
396
|
+
type: payload?.type,
|
|
397
|
+
app_id: payload?.app_id,
|
|
398
|
+
audience: payload?.aud,
|
|
399
|
+
solve_time_ms: payload?.solveTime,
|
|
400
|
+
issued_at: payload?.iat ? new Date(payload.iat * 1000).toISOString() : null,
|
|
401
|
+
},
|
|
402
|
+
// Payment proof
|
|
403
|
+
payment_proof: {
|
|
404
|
+
verified: true,
|
|
405
|
+
payer: verification.payer,
|
|
406
|
+
txHash: verification.txHash,
|
|
407
|
+
amount: verification.amount,
|
|
408
|
+
amountHuman: `${(parseInt(verification.amount || '0') / 1e6).toFixed(6)} USDC`,
|
|
409
|
+
network: verification.network,
|
|
410
|
+
},
|
|
411
|
+
// The secret resource content
|
|
412
|
+
secret: 'This payload is gated behind BOTCHA identity + x402 payment. Your agent cleared both gates. 🔐',
|
|
413
|
+
demo_data: {
|
|
414
|
+
description: 'Copy this pattern to gate any resource behind verified identity + micropayment',
|
|
415
|
+
middleware: [
|
|
416
|
+
'1. Verify BOTCHA Bearer token (POST /v1/token/validate)',
|
|
417
|
+
'2. Verify X-Payment header (POST /v1/x402/verify-payment)',
|
|
418
|
+
'3. Grant access if both pass',
|
|
419
|
+
],
|
|
420
|
+
sdk_coming_soon: 'npm install @dupecom/botcha-x402',
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* POST /v1/x402/webhook
|
|
426
|
+
*
|
|
427
|
+
* Receive x402 settlement notifications from facilitators (Coinbase CDP, etc.)
|
|
428
|
+
*
|
|
429
|
+
* This endpoint:
|
|
430
|
+
* - Validates the webhook signature (HMAC-SHA256 of payload with BOTCHA_WEBHOOK_SECRET)
|
|
431
|
+
* - Updates payment records
|
|
432
|
+
* - Credits agent reputation on successful payment
|
|
433
|
+
* - Returns 200 to acknowledge receipt
|
|
434
|
+
*
|
|
435
|
+
* Expected payload: X402WebhookEvent
|
|
436
|
+
*/
|
|
437
|
+
export async function x402WebhookRoute(c) {
|
|
438
|
+
try {
|
|
439
|
+
const rawBody = await c.req.text();
|
|
440
|
+
let event;
|
|
441
|
+
try {
|
|
442
|
+
event = JSON.parse(rawBody);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return c.json({ error: 'Invalid JSON payload' }, 400);
|
|
446
|
+
}
|
|
447
|
+
// Validate webhook signature if secret is configured
|
|
448
|
+
const webhookSecret = c.env.BOTCHA_WEBHOOK_SECRET;
|
|
449
|
+
const signatureHeader = c.req.header('x-botcha-signature') || c.req.header('x-webhook-signature');
|
|
450
|
+
if (webhookSecret) {
|
|
451
|
+
if (!signatureHeader) {
|
|
452
|
+
return c.json({ error: 'Missing webhook signature' }, 401);
|
|
453
|
+
}
|
|
454
|
+
const sigValid = await verifyWebhookSignature(rawBody, signatureHeader, webhookSecret);
|
|
455
|
+
if (!sigValid) {
|
|
456
|
+
return c.json({ error: 'Invalid webhook signature' }, 401);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Validate required fields
|
|
460
|
+
if (!event.event_type || !event.payment_id || !event.tx_hash) {
|
|
461
|
+
return c.json({
|
|
462
|
+
error: 'Missing required fields: event_type, payment_id, tx_hash',
|
|
463
|
+
}, 400);
|
|
464
|
+
}
|
|
465
|
+
const validEventTypes = ['payment.settled', 'payment.failed', 'payment.refunded'];
|
|
466
|
+
if (!validEventTypes.includes(event.event_type)) {
|
|
467
|
+
return c.json({
|
|
468
|
+
error: `Unknown event_type: ${event.event_type}`,
|
|
469
|
+
valid_types: validEventTypes,
|
|
470
|
+
}, 400);
|
|
471
|
+
}
|
|
472
|
+
// Process the event
|
|
473
|
+
const result = await processWebhookEvent(event, c.env.NONCES, c.env.AGENTS, c.env.SESSIONS, webhookSecret);
|
|
474
|
+
if (!result.handled) {
|
|
475
|
+
// Still return 200 to prevent retries; just log the failure
|
|
476
|
+
console.warn('Webhook event not fully handled:', result.message);
|
|
477
|
+
}
|
|
478
|
+
return c.json({
|
|
479
|
+
received: true,
|
|
480
|
+
event_type: event.event_type,
|
|
481
|
+
payment_id: event.payment_id,
|
|
482
|
+
handled: result.handled,
|
|
483
|
+
message: result.message,
|
|
484
|
+
timestamp: new Date().toISOString(),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
console.error('x402 webhook error:', error);
|
|
489
|
+
// Return 200 to prevent retries on internal errors
|
|
490
|
+
return c.json({
|
|
491
|
+
received: true,
|
|
492
|
+
error: 'Internal processing error (logged)',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* GET /v1/x402/info
|
|
498
|
+
*
|
|
499
|
+
* Public endpoint: returns x402 payment configuration for this BOTCHA instance.
|
|
500
|
+
* Agents can discover pricing, wallet, and supported networks.
|
|
501
|
+
*/
|
|
502
|
+
export async function x402InfoRoute(c) {
|
|
503
|
+
const wallet = getWallet(c.env);
|
|
504
|
+
const baseUrl = new URL(c.req.url).origin;
|
|
505
|
+
return c.json({
|
|
506
|
+
name: 'BOTCHA x402 Payment Gateway',
|
|
507
|
+
version: '1.0',
|
|
508
|
+
description: 'Pay USDC on Base to receive a BOTCHA verified agent token, or to access x402-gated resources.',
|
|
509
|
+
pricing: {
|
|
510
|
+
verification_token: {
|
|
511
|
+
amount: VERIFICATION_PRICE_USDC_UNITS,
|
|
512
|
+
amountHuman: VERIFICATION_PRICE_HUMAN,
|
|
513
|
+
description: 'One BOTCHA access_token (1 hour validity)',
|
|
514
|
+
},
|
|
515
|
+
resource_access: {
|
|
516
|
+
amount: VERIFICATION_PRICE_USDC_UNITS,
|
|
517
|
+
amountHuman: VERIFICATION_PRICE_HUMAN,
|
|
518
|
+
description: 'One-time access to an x402-gated resource',
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
payment: {
|
|
522
|
+
scheme: 'exact',
|
|
523
|
+
network: `eip155:${BASE_CHAIN_ID}`,
|
|
524
|
+
networkName: 'Base',
|
|
525
|
+
payTo: wallet,
|
|
526
|
+
asset: USDC_BASE_ADDRESS,
|
|
527
|
+
assetSymbol: 'USDC',
|
|
528
|
+
assetDecimals: 6,
|
|
529
|
+
deadlineSeconds: PAYMENT_DEADLINE_SECONDS,
|
|
530
|
+
},
|
|
531
|
+
endpoints: {
|
|
532
|
+
challenge: `${baseUrl}/v1/x402/challenge`,
|
|
533
|
+
verify_payment: `${baseUrl}/v1/x402/verify-payment`,
|
|
534
|
+
demo: `${baseUrl}/agent-only/x402`,
|
|
535
|
+
webhook: `${baseUrl}/v1/x402/webhook`,
|
|
536
|
+
},
|
|
537
|
+
x402_compliance: {
|
|
538
|
+
scheme: 'exact',
|
|
539
|
+
request_header: 'X-Payment',
|
|
540
|
+
response_header: 'X-Payment-Required',
|
|
541
|
+
confirmation_header: 'X-Payment-Response',
|
|
542
|
+
spec: 'https://x402.org',
|
|
543
|
+
},
|
|
544
|
+
verification_mode: 'cryptographic-signature-check + nonce replay protection',
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
// ============ WEBHOOK SIGNATURE VERIFICATION ============
|
|
548
|
+
/**
|
|
549
|
+
* Verify HMAC-SHA256 webhook signature.
|
|
550
|
+
* Facilitators sign payloads as: HMAC-SHA256(secret, body)
|
|
551
|
+
*/
|
|
552
|
+
async function verifyWebhookSignature(rawBody, signatureHeader, secret) {
|
|
553
|
+
try {
|
|
554
|
+
const encoder = new TextEncoder();
|
|
555
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
556
|
+
// Strip "sha256=" prefix if present (GitHub webhook style)
|
|
557
|
+
const sigHex = signatureHeader.replace(/^sha256=/, '');
|
|
558
|
+
const sigBytes = hexToBytes(sigHex);
|
|
559
|
+
return await crypto.subtle.verify('HMAC', key, sigBytes, encoder.encode(rawBody));
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function hexToBytes(hex) {
|
|
566
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
567
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
568
|
+
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
569
|
+
}
|
|
570
|
+
return bytes;
|
|
571
|
+
}
|
|
572
|
+
// ============ EXPORTS ============
|
|
573
|
+
export default {
|
|
574
|
+
verifyPaymentRoute,
|
|
575
|
+
x402ChallengeRoute,
|
|
576
|
+
agentOnlyX402Route,
|
|
577
|
+
x402WebhookRoute,
|
|
578
|
+
x402InfoRoute,
|
|
579
|
+
};
|