@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.
Files changed (88) hide show
  1. package/README.md +74 -9
  2. package/dist/agent-auth.d.ts +129 -0
  3. package/dist/agent-auth.d.ts.map +1 -0
  4. package/dist/agent-auth.js +210 -0
  5. package/dist/agents.d.ts +10 -0
  6. package/dist/agents.d.ts.map +1 -1
  7. package/dist/agents.js +51 -1
  8. package/dist/app-gate.d.ts +6 -0
  9. package/dist/app-gate.d.ts.map +1 -0
  10. package/dist/app-gate.js +69 -0
  11. package/dist/apps.d.ts +13 -4
  12. package/dist/apps.d.ts.map +1 -1
  13. package/dist/apps.js +30 -4
  14. package/dist/dashboard/account.d.ts +63 -0
  15. package/dist/dashboard/account.d.ts.map +1 -0
  16. package/dist/dashboard/account.js +488 -0
  17. package/dist/dashboard/api.js +15 -68
  18. package/dist/dashboard/auth.d.ts.map +1 -1
  19. package/dist/dashboard/auth.js +14 -14
  20. package/dist/dashboard/docs.d.ts.map +1 -1
  21. package/dist/dashboard/docs.js +146 -3
  22. package/dist/dashboard/layout.d.ts.map +1 -1
  23. package/dist/dashboard/layout.js +2 -2
  24. package/dist/dashboard/mcp-setup.d.ts +15 -0
  25. package/dist/dashboard/mcp-setup.d.ts.map +1 -0
  26. package/dist/dashboard/mcp-setup.js +391 -0
  27. package/dist/dashboard/showcase.d.ts +6 -10
  28. package/dist/dashboard/showcase.d.ts.map +1 -1
  29. package/dist/dashboard/showcase.js +67 -991
  30. package/dist/dashboard/whitepaper.d.ts.map +1 -1
  31. package/dist/dashboard/whitepaper.js +42 -4
  32. package/dist/index.d.ts +5 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +660 -83
  35. package/dist/mcp.d.ts +20 -0
  36. package/dist/mcp.d.ts.map +1 -0
  37. package/dist/mcp.js +1290 -0
  38. package/dist/oauth-agent.d.ts +130 -0
  39. package/dist/oauth-agent.d.ts.map +1 -0
  40. package/dist/oauth-agent.js +194 -0
  41. package/dist/static.d.ts +781 -5
  42. package/dist/static.d.ts.map +1 -1
  43. package/dist/static.js +790 -111
  44. package/dist/tap-a2a-routes.d.ts +355 -0
  45. package/dist/tap-a2a-routes.d.ts.map +1 -0
  46. package/dist/tap-a2a-routes.js +475 -0
  47. package/dist/tap-a2a.d.ts +199 -0
  48. package/dist/tap-a2a.d.ts.map +1 -0
  49. package/dist/tap-a2a.js +502 -0
  50. package/dist/tap-agents.d.ts +15 -0
  51. package/dist/tap-agents.d.ts.map +1 -1
  52. package/dist/tap-agents.js +31 -1
  53. package/dist/tap-ans-routes.d.ts +302 -0
  54. package/dist/tap-ans-routes.d.ts.map +1 -0
  55. package/dist/tap-ans-routes.js +535 -0
  56. package/dist/tap-ans.d.ts +241 -0
  57. package/dist/tap-ans.d.ts.map +1 -0
  58. package/dist/tap-ans.js +481 -0
  59. package/dist/tap-delegation-routes.d.ts.map +1 -1
  60. package/dist/tap-delegation-routes.js +11 -0
  61. package/dist/tap-did.d.ts +140 -0
  62. package/dist/tap-did.d.ts.map +1 -0
  63. package/dist/tap-did.js +262 -0
  64. package/dist/tap-oidca-routes.d.ts +383 -0
  65. package/dist/tap-oidca-routes.d.ts.map +1 -0
  66. package/dist/tap-oidca-routes.js +597 -0
  67. package/dist/tap-oidca.d.ts +288 -0
  68. package/dist/tap-oidca.d.ts.map +1 -0
  69. package/dist/tap-oidca.js +461 -0
  70. package/dist/tap-routes.d.ts +24 -8
  71. package/dist/tap-routes.d.ts.map +1 -1
  72. package/dist/tap-routes.js +169 -23
  73. package/dist/tap-vc-routes.d.ts +358 -0
  74. package/dist/tap-vc-routes.d.ts.map +1 -0
  75. package/dist/tap-vc-routes.js +367 -0
  76. package/dist/tap-vc.d.ts +125 -0
  77. package/dist/tap-vc.d.ts.map +1 -0
  78. package/dist/tap-vc.js +245 -0
  79. package/dist/tap-x402-routes.d.ts +89 -0
  80. package/dist/tap-x402-routes.d.ts.map +1 -0
  81. package/dist/tap-x402-routes.js +579 -0
  82. package/dist/tap-x402.d.ts +222 -0
  83. package/dist/tap-x402.d.ts.map +1 -0
  84. package/dist/tap-x402.js +546 -0
  85. package/dist/webhooks.d.ts +99 -0
  86. package/dist/webhooks.d.ts.map +1 -0
  87. package/dist/webhooks.js +642 -0
  88. package/package.json +3 -1
@@ -0,0 +1,642 @@
1
+ /**
2
+ * BOTCHA Webhook Event System
3
+ *
4
+ * Delivers real-time events to API owners when things happen:
5
+ * token.created / token.revoked
6
+ * agent.tap.registered / tap.session.created
7
+ * delegation.created / delegation.revoked
8
+ *
9
+ * KV keys (all stored in AGENTS namespace):
10
+ * webhook:{id} — WebhookConfig (without secret)
11
+ * webhook_secret:{id} — HMAC signing secret
12
+ * app_webhooks:{app_id} — JSON string[] of webhook IDs for this app
13
+ * webhook_deliveries:{id} — JSON DeliveryLog[] (last 100, TTL 7d)
14
+ */
15
+ import { extractBearerToken, verifyToken, getSigningPublicKeyJWK } from './auth.js';
16
+ export const ALL_EVENT_TYPES = [
17
+ 'agent.tap.registered',
18
+ 'token.created',
19
+ 'token.revoked',
20
+ 'tap.session.created',
21
+ 'delegation.created',
22
+ 'delegation.revoked',
23
+ ];
24
+ // ============ ID GENERATION ============
25
+ function generateId(prefix) {
26
+ const bytes = new Uint8Array(16);
27
+ crypto.getRandomValues(bytes);
28
+ const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
29
+ return `${prefix}_${hex}`;
30
+ }
31
+ // ============ HMAC-SHA256 SIGNING ============
32
+ export async function computeHmacSignature(secret, body) {
33
+ const encoder = new TextEncoder();
34
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
35
+ const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
36
+ const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
37
+ return `sha256=${hex}`;
38
+ }
39
+ // ============ KV HELPERS ============
40
+ async function getWebhook(kv, id) {
41
+ const raw = await kv.get(`webhook:${id}`);
42
+ if (!raw)
43
+ return null;
44
+ try {
45
+ return JSON.parse(raw);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ async function saveWebhook(kv, webhook) {
52
+ await kv.put(`webhook:${webhook.id}`, JSON.stringify(webhook));
53
+ }
54
+ async function getAppWebhookIds(kv, appId) {
55
+ const raw = await kv.get(`app_webhooks:${appId}`);
56
+ if (!raw)
57
+ return [];
58
+ try {
59
+ const parsed = JSON.parse(raw);
60
+ return Array.isArray(parsed) ? parsed : [];
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ }
66
+ async function setAppWebhookIds(kv, appId, ids) {
67
+ await kv.put(`app_webhooks:${appId}`, JSON.stringify(ids));
68
+ }
69
+ async function getDeliveries(kv, webhookId) {
70
+ const raw = await kv.get(`webhook_deliveries:${webhookId}`);
71
+ if (!raw)
72
+ return [];
73
+ try {
74
+ const parsed = JSON.parse(raw);
75
+ return Array.isArray(parsed) ? parsed : [];
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
81
+ async function appendDelivery(kv, webhookId, log) {
82
+ const existing = await getDeliveries(kv, webhookId);
83
+ // Keep last 100
84
+ const updated = [log, ...existing].slice(0, 100);
85
+ // TTL 7 days
86
+ await kv.put(`webhook_deliveries:${webhookId}`, JSON.stringify(updated), { expirationTtl: 604800 });
87
+ }
88
+ // ============ RETRY DELAYS ============
89
+ // Keep retries short for Worker waitUntil execution windows.
90
+ const DELIVERY_ATTEMPTS = 3;
91
+ const RETRY_DELAYS_MS = [150, 600];
92
+ const DELIVERY_TIMEOUT_MS = 2500;
93
+ const WAIT_UNTIL_BUDGET_MS = 12000;
94
+ const MAX_WEBHOOKS_PER_APP = 25;
95
+ function isPrivateIPv4(hostname) {
96
+ const parts = hostname.split('.').map(Number);
97
+ if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255))
98
+ return false;
99
+ const [a, b] = parts;
100
+ if (a === 10)
101
+ return true;
102
+ if (a === 127)
103
+ return true;
104
+ if (a === 0)
105
+ return true;
106
+ if (a === 169 && b === 254)
107
+ return true;
108
+ if (a === 172 && b >= 16 && b <= 31)
109
+ return true;
110
+ if (a === 192 && b === 168)
111
+ return true;
112
+ if (a === 100 && b >= 64 && b <= 127)
113
+ return true;
114
+ return false;
115
+ }
116
+ function isPrivateIPv6(hostname) {
117
+ const h = hostname.toLowerCase();
118
+ if (h === '::1')
119
+ return true;
120
+ if (h.startsWith('fe80:'))
121
+ return true; // link-local
122
+ if (h.startsWith('fc') || h.startsWith('fd'))
123
+ return true; // unique local
124
+ return false;
125
+ }
126
+ function validateWebhookUrl(urlValue) {
127
+ let parsed;
128
+ try {
129
+ parsed = new URL(urlValue);
130
+ }
131
+ catch {
132
+ return { valid: false, error: 'INVALID_URL', message: 'url must be a valid HTTPS URL' };
133
+ }
134
+ if (parsed.protocol !== 'https:') {
135
+ return { valid: false, error: 'INVALID_URL', message: 'url must use https:// scheme' };
136
+ }
137
+ const host = parsed.hostname.toLowerCase();
138
+ if (host === 'localhost' || host.endsWith('.localhost') || host === '0.0.0.0') {
139
+ return { valid: false, error: 'UNSAFE_URL', message: 'localhost and loopback webhook URLs are not allowed' };
140
+ }
141
+ if (isPrivateIPv4(host) || isPrivateIPv6(host)) {
142
+ return { valid: false, error: 'UNSAFE_URL', message: 'private-network webhook URLs are not allowed' };
143
+ }
144
+ return { valid: true, normalizedUrl: parsed.toString() };
145
+ }
146
+ function normalizeWebhookEvents(events) {
147
+ if (!Array.isArray(events) || events.length === 0) {
148
+ return { valid: false, message: 'events must be a non-empty array of supported event types' };
149
+ }
150
+ const unsupported = events.filter((event) => typeof event !== 'string' || !ALL_EVENT_TYPES.includes(event));
151
+ if (unsupported.length > 0) {
152
+ return {
153
+ valid: false,
154
+ message: `Unsupported event type(s): ${unsupported.map(String).join(', ')}`,
155
+ };
156
+ }
157
+ const deduped = Array.from(new Set(events));
158
+ return { valid: true, events: deduped };
159
+ }
160
+ // ============ SINGLE DELIVERY ATTEMPT ============
161
+ async function attemptDelivery(url, body, signature, eventType, timeoutMs = DELIVERY_TIMEOUT_MS) {
162
+ const start = Date.now();
163
+ try {
164
+ const response = await fetch(url, {
165
+ method: 'POST',
166
+ headers: {
167
+ 'Content-Type': 'application/json',
168
+ 'X-Botcha-Signature': signature,
169
+ 'X-Botcha-Event': eventType,
170
+ 'User-Agent': 'Botcha-Webhook/1.0',
171
+ },
172
+ body,
173
+ signal: AbortSignal.timeout(timeoutMs),
174
+ });
175
+ const durationMs = Date.now() - start;
176
+ const success = response.status >= 200 && response.status < 300;
177
+ return { statusCode: response.status, success, durationMs };
178
+ }
179
+ catch (err) {
180
+ const durationMs = Date.now() - start;
181
+ const error = err instanceof Error ? err.message : 'Unknown error';
182
+ return { statusCode: null, success: false, error, durationMs };
183
+ }
184
+ }
185
+ // ============ CORE DELIVERY FUNCTION ============
186
+ /**
187
+ * Fire-and-forget webhook delivery with retry logic.
188
+ * Call via: ctx.waitUntil(triggerWebhook(...))
189
+ */
190
+ export async function triggerWebhook(kv, appId, eventType, data) {
191
+ // Build event payload
192
+ const event = {
193
+ id: generateId('evt'),
194
+ type: eventType,
195
+ created_at: new Date().toISOString(),
196
+ app_id: appId,
197
+ data,
198
+ };
199
+ const body = JSON.stringify(event);
200
+ // Load all webhooks for this app
201
+ const ids = await getAppWebhookIds(kv, appId);
202
+ if (ids.length === 0)
203
+ return;
204
+ const deadlineMs = Date.now() + WAIT_UNTIL_BUDGET_MS;
205
+ await Promise.allSettled(ids.map(async (webhookId) => {
206
+ try {
207
+ const webhook = await getWebhook(kv, webhookId);
208
+ if (!webhook)
209
+ return;
210
+ if (!webhook.enabled || webhook.suspended)
211
+ return;
212
+ if (!webhook.events.includes(eventType))
213
+ return;
214
+ const secret = await kv.get(`webhook_secret:${webhookId}`);
215
+ if (!secret)
216
+ return;
217
+ const signature = await computeHmacSignature(secret, body);
218
+ let success = false;
219
+ let attemptsMade = 0;
220
+ for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
221
+ if (attempt > 0) {
222
+ const delayMs = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)] || 0;
223
+ if (Date.now() + delayMs >= deadlineMs)
224
+ break;
225
+ await sleep(delayMs);
226
+ }
227
+ const remainingMs = deadlineMs - Date.now();
228
+ if (remainingMs <= 0)
229
+ break;
230
+ const timeoutMs = Math.max(300, Math.min(DELIVERY_TIMEOUT_MS, remainingMs));
231
+ const result = await attemptDelivery(webhook.url, body, signature, eventType, timeoutMs);
232
+ attemptsMade += 1;
233
+ const log = {
234
+ delivery_id: generateId('dlv'),
235
+ webhook_id: webhookId,
236
+ event_type: eventType,
237
+ event_id: event.id,
238
+ attempted_at: Date.now(),
239
+ attempt_number: attempt + 1,
240
+ status_code: result.statusCode,
241
+ success: result.success,
242
+ error: result.error,
243
+ duration_ms: result.durationMs,
244
+ };
245
+ await appendDelivery(kv, webhookId, log);
246
+ if (result.success) {
247
+ success = true;
248
+ webhook.consecutive_failures = 0;
249
+ webhook.suspended = false;
250
+ await saveWebhook(kv, webhook);
251
+ break;
252
+ }
253
+ }
254
+ if (!success) {
255
+ if (attemptsMade === 0) {
256
+ await appendDelivery(kv, webhookId, {
257
+ delivery_id: generateId('dlv'),
258
+ webhook_id: webhookId,
259
+ event_type: eventType,
260
+ event_id: event.id,
261
+ attempted_at: Date.now(),
262
+ attempt_number: 1,
263
+ status_code: null,
264
+ success: false,
265
+ error: 'Delivery skipped: waitUntil execution budget exceeded',
266
+ duration_ms: 0,
267
+ });
268
+ }
269
+ webhook.consecutive_failures = (webhook.consecutive_failures || 0) + 1;
270
+ // Suspend after 3 consecutive failures
271
+ if (webhook.consecutive_failures >= 3) {
272
+ webhook.suspended = true;
273
+ }
274
+ await saveWebhook(kv, webhook);
275
+ }
276
+ }
277
+ catch (error) {
278
+ console.error(`Webhook delivery failed for ${webhookId}:`, error);
279
+ }
280
+ }));
281
+ }
282
+ function sleep(ms) {
283
+ return new Promise(resolve => setTimeout(resolve, ms));
284
+ }
285
+ // ============ AUTH HELPER FOR ROUTES ============
286
+ function getVerificationPublicKey(env) {
287
+ const rawSigningKey = env?.JWT_SIGNING_KEY;
288
+ if (!rawSigningKey)
289
+ return undefined;
290
+ try {
291
+ const signingKey = JSON.parse(rawSigningKey);
292
+ return getSigningPublicKeyJWK(signingKey);
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ }
298
+ async function validateAppAccess(c) {
299
+ const authHeader = c.req.header('authorization');
300
+ const token = extractBearerToken(authHeader);
301
+ if (!token) {
302
+ return { valid: false, error: 'UNAUTHORIZED', status: 401 };
303
+ }
304
+ const publicKey = getVerificationPublicKey(c.env);
305
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env, undefined, publicKey);
306
+ if (!result.valid || !result.payload) {
307
+ return { valid: false, error: 'INVALID_TOKEN', status: 401 };
308
+ }
309
+ const jwtAppId = result.payload.app_id;
310
+ if (!jwtAppId) {
311
+ return { valid: false, error: 'MISSING_APP_ID', status: 403 };
312
+ }
313
+ return { valid: true, appId: jwtAppId };
314
+ }
315
+ // ============ ROUTE HANDLERS ============
316
+ /**
317
+ * POST /v1/webhooks
318
+ * Register a new webhook endpoint.
319
+ */
320
+ export async function createWebhookRoute(c) {
321
+ const auth = await validateAppAccess(c);
322
+ if (!auth.valid || !auth.appId) {
323
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
324
+ }
325
+ const appId = auth.appId;
326
+ let body;
327
+ try {
328
+ body = await c.req.json();
329
+ }
330
+ catch {
331
+ return c.json({ success: false, error: 'INVALID_JSON', message: 'Request body must be JSON' }, 400);
332
+ }
333
+ const { url, events } = body;
334
+ if (!url || typeof url !== 'string') {
335
+ return c.json({ success: false, error: 'MISSING_URL', message: 'url is required' }, 400);
336
+ }
337
+ const urlValidation = validateWebhookUrl(url);
338
+ if (!urlValidation.valid) {
339
+ return c.json({ success: false, error: urlValidation.error, message: urlValidation.message }, 400);
340
+ }
341
+ // Validate events
342
+ let eventList = [...ALL_EVENT_TYPES];
343
+ if (events !== undefined) {
344
+ const normalized = normalizeWebhookEvents(events);
345
+ if (!normalized.valid) {
346
+ return c.json({ success: false, error: 'INVALID_EVENTS', message: normalized.message }, 400);
347
+ }
348
+ eventList = normalized.events;
349
+ }
350
+ const webhookId = generateId('wh');
351
+ const now = Date.now();
352
+ const kv = c.env.AGENTS;
353
+ const existingIds = await getAppWebhookIds(kv, appId);
354
+ if (existingIds.length >= MAX_WEBHOOKS_PER_APP) {
355
+ return c.json({
356
+ success: false,
357
+ error: 'WEBHOOK_LIMIT_REACHED',
358
+ message: `Maximum of ${MAX_WEBHOOKS_PER_APP} webhooks per app`,
359
+ }, 400);
360
+ }
361
+ const webhook = {
362
+ id: webhookId,
363
+ app_id: appId,
364
+ url: urlValidation.normalizedUrl,
365
+ events: eventList,
366
+ enabled: true,
367
+ created_at: now,
368
+ updated_at: now,
369
+ consecutive_failures: 0,
370
+ suspended: false,
371
+ };
372
+ // Generate HMAC secret (32 random bytes → hex)
373
+ const secretBytes = new Uint8Array(32);
374
+ crypto.getRandomValues(secretBytes);
375
+ const secret = Array.from(secretBytes).map(b => b.toString(16).padStart(2, '0')).join('');
376
+ // Save webhook config and secret
377
+ await saveWebhook(kv, webhook);
378
+ await kv.put(`webhook_secret:${webhookId}`, secret);
379
+ // Add to app index
380
+ await setAppWebhookIds(kv, appId, [...existingIds, webhookId]);
381
+ return c.json({
382
+ success: true,
383
+ webhook: {
384
+ id: webhookId,
385
+ app_id: appId,
386
+ url: webhook.url,
387
+ events: eventList,
388
+ enabled: true,
389
+ created_at: new Date(now).toISOString(),
390
+ },
391
+ // Secret shown ONCE — store it securely!
392
+ secret,
393
+ warning: '⚠️ Save your webhook secret now — it will never be shown again. Use it to verify X-Botcha-Signature headers.',
394
+ signature_info: 'Each delivery includes X-Botcha-Signature: sha256=<hmac-sha256-hex> header. Computed as HMAC-SHA256(secret, request_body).',
395
+ }, 201);
396
+ }
397
+ /**
398
+ * GET /v1/webhooks
399
+ * List all webhooks for the authenticated app.
400
+ */
401
+ export async function listWebhooksRoute(c) {
402
+ const auth = await validateAppAccess(c);
403
+ if (!auth.valid || !auth.appId) {
404
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
405
+ }
406
+ const appId = auth.appId;
407
+ const kv = c.env.AGENTS;
408
+ const ids = await getAppWebhookIds(kv, appId);
409
+ const webhooks = (await Promise.all(ids.map(id => getWebhook(kv, id))))
410
+ .filter((w) => w !== null)
411
+ .map(w => ({
412
+ id: w.id,
413
+ app_id: w.app_id,
414
+ url: w.url,
415
+ events: w.events,
416
+ enabled: w.enabled,
417
+ suspended: w.suspended,
418
+ consecutive_failures: w.consecutive_failures,
419
+ created_at: new Date(w.created_at).toISOString(),
420
+ updated_at: new Date(w.updated_at).toISOString(),
421
+ }));
422
+ return c.json({ success: true, webhooks });
423
+ }
424
+ /**
425
+ * GET /v1/webhooks/:id
426
+ * Get a specific webhook.
427
+ */
428
+ export async function getWebhookRoute(c) {
429
+ const auth = await validateAppAccess(c);
430
+ if (!auth.valid || !auth.appId) {
431
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
432
+ }
433
+ const webhookId = c.req.param('id');
434
+ const kv = c.env.AGENTS;
435
+ const webhook = await getWebhook(kv, webhookId);
436
+ if (!webhook) {
437
+ return c.json({ success: false, error: 'NOT_FOUND', message: 'Webhook not found' }, 404);
438
+ }
439
+ if (webhook.app_id !== auth.appId) {
440
+ return c.json({ success: false, error: 'FORBIDDEN' }, 403);
441
+ }
442
+ return c.json({
443
+ success: true,
444
+ webhook: {
445
+ id: webhook.id,
446
+ app_id: webhook.app_id,
447
+ url: webhook.url,
448
+ events: webhook.events,
449
+ enabled: webhook.enabled,
450
+ suspended: webhook.suspended,
451
+ consecutive_failures: webhook.consecutive_failures,
452
+ created_at: new Date(webhook.created_at).toISOString(),
453
+ updated_at: new Date(webhook.updated_at).toISOString(),
454
+ },
455
+ });
456
+ }
457
+ /**
458
+ * PUT /v1/webhooks/:id
459
+ * Update webhook (url, events, enabled).
460
+ */
461
+ export async function updateWebhookRoute(c) {
462
+ const auth = await validateAppAccess(c);
463
+ if (!auth.valid || !auth.appId) {
464
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
465
+ }
466
+ const webhookId = c.req.param('id');
467
+ const kv = c.env.AGENTS;
468
+ const webhook = await getWebhook(kv, webhookId);
469
+ if (!webhook) {
470
+ return c.json({ success: false, error: 'NOT_FOUND', message: 'Webhook not found' }, 404);
471
+ }
472
+ if (webhook.app_id !== auth.appId) {
473
+ return c.json({ success: false, error: 'FORBIDDEN' }, 403);
474
+ }
475
+ let body;
476
+ try {
477
+ body = await c.req.json();
478
+ }
479
+ catch {
480
+ return c.json({ success: false, error: 'INVALID_JSON' }, 400);
481
+ }
482
+ if (body.url !== undefined) {
483
+ const urlValidation = validateWebhookUrl(body.url);
484
+ if (!urlValidation.valid) {
485
+ return c.json({ success: false, error: urlValidation.error, message: urlValidation.message }, 400);
486
+ }
487
+ webhook.url = urlValidation.normalizedUrl;
488
+ }
489
+ if (body.events !== undefined) {
490
+ const normalized = normalizeWebhookEvents(body.events);
491
+ if (!normalized.valid) {
492
+ return c.json({ success: false, error: 'INVALID_EVENTS', message: normalized.message }, 400);
493
+ }
494
+ webhook.events = normalized.events;
495
+ }
496
+ if (typeof body.enabled === 'boolean') {
497
+ webhook.enabled = body.enabled;
498
+ // Re-enabling a suspended webhook resets failure counter
499
+ if (body.enabled && webhook.suspended) {
500
+ webhook.suspended = false;
501
+ webhook.consecutive_failures = 0;
502
+ }
503
+ }
504
+ webhook.updated_at = Date.now();
505
+ await saveWebhook(kv, webhook);
506
+ return c.json({
507
+ success: true,
508
+ webhook: {
509
+ id: webhook.id,
510
+ app_id: webhook.app_id,
511
+ url: webhook.url,
512
+ events: webhook.events,
513
+ enabled: webhook.enabled,
514
+ suspended: webhook.suspended,
515
+ updated_at: new Date(webhook.updated_at).toISOString(),
516
+ },
517
+ });
518
+ }
519
+ /**
520
+ * DELETE /v1/webhooks/:id
521
+ * Delete a webhook.
522
+ */
523
+ export async function deleteWebhookRoute(c) {
524
+ const auth = await validateAppAccess(c);
525
+ if (!auth.valid || !auth.appId) {
526
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
527
+ }
528
+ const webhookId = c.req.param('id');
529
+ const kv = c.env.AGENTS;
530
+ const webhook = await getWebhook(kv, webhookId);
531
+ if (!webhook) {
532
+ return c.json({ success: false, error: 'NOT_FOUND', message: 'Webhook not found' }, 404);
533
+ }
534
+ if (webhook.app_id !== auth.appId) {
535
+ return c.json({ success: false, error: 'FORBIDDEN' }, 403);
536
+ }
537
+ await kv.delete(`webhook:${webhookId}`);
538
+ await kv.delete(`webhook_secret:${webhookId}`);
539
+ await kv.delete(`webhook_deliveries:${webhookId}`);
540
+ // Remove from app index
541
+ const ids = await getAppWebhookIds(kv, auth.appId);
542
+ await setAppWebhookIds(kv, auth.appId, ids.filter(id => id !== webhookId));
543
+ return c.json({ success: true, deleted: true, id: webhookId });
544
+ }
545
+ /**
546
+ * POST /v1/webhooks/:id/test
547
+ * Send a test event to verify endpoint reachability.
548
+ */
549
+ export async function testWebhookRoute(c) {
550
+ const auth = await validateAppAccess(c);
551
+ if (!auth.valid || !auth.appId) {
552
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
553
+ }
554
+ const webhookId = c.req.param('id');
555
+ const kv = c.env.AGENTS;
556
+ const webhook = await getWebhook(kv, webhookId);
557
+ if (!webhook) {
558
+ return c.json({ success: false, error: 'NOT_FOUND', message: 'Webhook not found' }, 404);
559
+ }
560
+ if (webhook.app_id !== auth.appId) {
561
+ return c.json({ success: false, error: 'FORBIDDEN' }, 403);
562
+ }
563
+ const secret = await kv.get(`webhook_secret:${webhookId}`);
564
+ if (!secret) {
565
+ return c.json({ success: false, error: 'SECRET_NOT_FOUND' }, 500);
566
+ }
567
+ const testEvent = {
568
+ id: generateId('evt'),
569
+ type: 'token.created',
570
+ created_at: new Date().toISOString(),
571
+ app_id: auth.appId,
572
+ data: {
573
+ test: true,
574
+ message: '🐢 BOTCHA webhook test event — if you can read this, delivery is working!',
575
+ webhook_id: webhookId,
576
+ },
577
+ };
578
+ const body = JSON.stringify(testEvent);
579
+ const signature = await computeHmacSignature(secret, body);
580
+ const result = await attemptDelivery(webhook.url, body, signature, 'token.created');
581
+ const log = {
582
+ delivery_id: generateId('dlv'),
583
+ webhook_id: webhookId,
584
+ event_type: 'token.created',
585
+ event_id: testEvent.id,
586
+ attempted_at: Date.now(),
587
+ attempt_number: 1,
588
+ status_code: result.statusCode,
589
+ success: result.success,
590
+ error: result.error,
591
+ duration_ms: result.durationMs,
592
+ };
593
+ await appendDelivery(kv, webhookId, log);
594
+ return c.json({
595
+ success: result.success,
596
+ delivery: {
597
+ status_code: result.statusCode,
598
+ success: result.success,
599
+ duration_ms: result.durationMs,
600
+ error: result.error,
601
+ },
602
+ event: testEvent,
603
+ message: result.success
604
+ ? `✅ Test delivery succeeded (${result.statusCode}) in ${result.durationMs}ms`
605
+ : `❌ Test delivery failed: ${result.error || `HTTP ${result.statusCode}`}`,
606
+ });
607
+ }
608
+ /**
609
+ * GET /v1/webhooks/:id/deliveries
610
+ * Get recent delivery log (last 100 attempts).
611
+ */
612
+ export async function listDeliveriesRoute(c) {
613
+ const auth = await validateAppAccess(c);
614
+ if (!auth.valid || !auth.appId) {
615
+ return c.json({ success: false, error: auth.error }, (auth.status ?? 401));
616
+ }
617
+ const webhookId = c.req.param('id');
618
+ const kv = c.env.AGENTS;
619
+ const webhook = await getWebhook(kv, webhookId);
620
+ if (!webhook) {
621
+ return c.json({ success: false, error: 'NOT_FOUND', message: 'Webhook not found' }, 404);
622
+ }
623
+ if (webhook.app_id !== auth.appId) {
624
+ return c.json({ success: false, error: 'FORBIDDEN' }, 403);
625
+ }
626
+ const deliveries = await getDeliveries(kv, webhookId);
627
+ return c.json({
628
+ success: true,
629
+ webhook_id: webhookId,
630
+ deliveries: deliveries.map(d => ({
631
+ delivery_id: d.delivery_id,
632
+ event_type: d.event_type,
633
+ event_id: d.event_id,
634
+ attempted_at: new Date(d.attempted_at).toISOString(),
635
+ attempt_number: d.attempt_number,
636
+ status_code: d.status_code,
637
+ success: d.success,
638
+ error: d.error,
639
+ duration_ms: d.duration_ms,
640
+ })),
641
+ });
642
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dupecom/botcha-cloudflare",
3
- "version": "0.20.2",
3
+ "version": "0.23.0",
4
4
  "description": "BOTCHA for Cloudflare Workers - Prove you're a bot. Humans need not apply.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,6 +40,8 @@
40
40
  },
41
41
  "homepage": "https://botcha.ai",
42
42
  "dependencies": {
43
+ "@noble/curves": "^1.9.7",
44
+ "@noble/hashes": "^1.8.0",
43
45
  "hono": "^4.7.0",
44
46
  "jose": "^5.9.6"
45
47
  },