@agentsbloom/sdk 0.2.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/index.js ADDED
@@ -0,0 +1,1112 @@
1
+ import zlib from 'zlib';
2
+ import crypto from 'crypto';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
5
+ import { trace, metrics, ValueType, context, propagation } from '@opentelemetry/api';
6
+ import { initExporter } from './telemetry.js';
7
+ import {
8
+ verifyAp2Mandate,
9
+ createAp2Mandate,
10
+ didKeyFromEd25519PublicKey,
11
+ ed25519PublicKeyFromDidKey,
12
+ resetAp2ReplayCache,
13
+ stopAp2ReplayCleanup,
14
+ } from './lib/ap2.js';
15
+
16
+ const tracer = trace.getTracer('agentsbloom-sdk');
17
+ const meter = metrics.getMeter('agentsbloom-sdk');
18
+
19
+ const agentRequestsCounter = meter.createCounter('agent_visits_total', { description: 'Total AI Visits' });
20
+ const agentRevenueCounter = meter.createCounter('agent_revenue_usd', { description: 'Total AI Revenue', valueType: ValueType.DOUBLE });
21
+
22
+ /**
23
+ * Initialize OpenTelemetry with OTLP exporters.
24
+ * Call this BEFORE using the agentsbloom() middleware.
25
+ *
26
+ * @param {Object} options
27
+ * @param {string} options.otlpEndpoint - OTLP collector URL (default: http://localhost:4318)
28
+ * @param {string} options.serviceName - Service name for traces (default: agentsbloom-merchant)
29
+ * @param {number} options.samplingRatio - Trace sampling ratio 0.0-1.0 (default: 1.0)
30
+ * @param {string} options.apiKey - API key for authenticating with the collector
31
+ */
32
+ export async function setupTelemetry(options = {}) {
33
+ const {
34
+ otlpEndpoint = process.env.AGENTSBLOOM_OTEL_ENDPOINT || 'http://localhost:4318',
35
+ serviceName = 'agentsbloom-merchant',
36
+ samplingRatio = parseFloat(process.env.AGENTSBLOOM_SAMPLING_RATIO || '1.0'),
37
+ apiKey = process.env.AGENTSBLOOM_API_KEY || '',
38
+ } = options;
39
+
40
+ // Store config for lazy initialization when OTel SDK packages are available
41
+ globalThis.__agentsbloom_otel_config = {
42
+ otlpEndpoint,
43
+ serviceName,
44
+ samplingRatio,
45
+ apiKey,
46
+ };
47
+
48
+ const handle = await initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey });
49
+ globalThis.__agentsbloom_otel_handle = handle;
50
+
51
+ console.log(`🌸 AgentsBloom: Telemetry configured (sampling: ${samplingRatio * 100}%)`);
52
+ return { otlpEndpoint, serviceName, samplingRatio };
53
+ }
54
+ const DEFAULT_RFC_JWKS_URL = 'https://platform.openai.com/.well-known/jwks.json';
55
+ const LEGACY_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
56
+ const RFC_SIGNATURE_CLOCK_SKEW_MS = 30 * 1000;
57
+ const SIGNATURE_REPLAY_CACHE_MAX_SIZE = 50_000;
58
+ const jwksCache = new Map();
59
+
60
+ function verifySignature(signature, payload, secret) {
61
+ try {
62
+ if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/i.test(signature)) return false;
63
+ const computed = crypto.createHmac('sha256', secret).update(payload).digest();
64
+ const provided = Buffer.from(signature, 'hex');
65
+ return crypto.timingSafeEqual(computed, provided);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function buildLegacySignaturePayload(req, identifier, timestamp, nonce) {
72
+ return JSON.stringify([
73
+ identifier,
74
+ String(req.method || '').toUpperCase(),
75
+ req.originalUrl || req.path,
76
+ timestamp,
77
+ nonce,
78
+ req.body ?? null,
79
+ ]);
80
+ }
81
+
82
+ function buildRequestContentDigest(req) {
83
+ let bodyBytes;
84
+ if (req.rawBody !== undefined) {
85
+ bodyBytes = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(String(req.rawBody));
86
+ } else if (Buffer.isBuffer(req.body)) {
87
+ bodyBytes = req.body;
88
+ } else if (req.body !== undefined) {
89
+ bodyBytes = Buffer.from(JSON.stringify(req.body ?? null));
90
+ } else {
91
+ const contentLength = Number(req.headers['content-length'] || 0);
92
+ if (contentLength > 0) {
93
+ throw new Error('Protected HTTP signatures require parsed request bodies or req.rawBody');
94
+ }
95
+ bodyBytes = Buffer.alloc(0);
96
+ }
97
+
98
+ const digest = crypto.createHash('sha256').update(bodyBytes).digest('base64');
99
+ return `sha-256=:${digest}:`;
100
+ }
101
+
102
+ // In-memory rate limiting map
103
+ const rateLimitMap = new Map();
104
+ const rateLimitInterval = setInterval(() => {
105
+ const now = Date.now();
106
+ for (const [key, val] of rateLimitMap.entries()) {
107
+ if (val.resetTime < now) rateLimitMap.delete(key);
108
+ }
109
+ }, 60 * 1000);
110
+ rateLimitInterval.unref();
111
+
112
+ // In-memory Idempotency cache
113
+ const idempotencyMap = new Map();
114
+ const signatureNonceMap = new Map();
115
+ const idempotencyInterval = setInterval(() => {
116
+ const now = Date.now();
117
+ for (const [key, val] of idempotencyMap.entries()) {
118
+ if (val.expiry < now) idempotencyMap.delete(key);
119
+ }
120
+ for (const [key, expiresAt] of signatureNonceMap.entries()) {
121
+ if (expiresAt < now) signatureNonceMap.delete(key);
122
+ }
123
+ }, 60 * 1000);
124
+ idempotencyInterval.unref();
125
+
126
+ let otelShutdownPromise = null;
127
+
128
+ export async function shutdown() {
129
+ clearInterval(rateLimitInterval);
130
+ clearInterval(idempotencyInterval);
131
+ rateLimitMap.clear();
132
+ idempotencyMap.clear();
133
+ signatureNonceMap.clear();
134
+ jwksCache.clear();
135
+ stopAp2ReplayCleanup();
136
+
137
+ if (otelShutdownPromise) {
138
+ return otelShutdownPromise;
139
+ }
140
+
141
+ const otelHandle = globalThis.__agentsbloom_otel_handle;
142
+ if (!otelHandle) return;
143
+
144
+ // Clear the handle before awaiting so a repeated/concurrent shutdown cannot
145
+ // start a second provider shutdown, even if the first one rejects.
146
+ globalThis.__agentsbloom_otel_handle = null;
147
+ otelShutdownPromise = Promise.resolve().then(() => otelHandle.provider.shutdown());
148
+ try {
149
+ await otelShutdownPromise;
150
+ } finally {
151
+ otelShutdownPromise = null;
152
+ }
153
+ }
154
+
155
+ export function agentsbloom(config = {}) {
156
+ const {
157
+ apiKey = null,
158
+ name = "My Agent-Ready Store",
159
+ description = "An e-commerce store optimized for human and machine AI agents.",
160
+ actions = {},
161
+ llmsDoc = "",
162
+ baseUrl = ""
163
+ } = config;
164
+
165
+ if (!apiKey) {
166
+ console.error("🌸 AgentsBloom SDK Error: Missing `apiKey`. You must provide an API Key to use the SDK. Get one at dashboard.agentsbloom.com");
167
+ }
168
+ if (!baseUrl) {
169
+ console.error("🌸 AgentsBloom SDK Error: Missing `baseUrl` in config. This is required for secure telemetry.");
170
+ }
171
+
172
+ let quotaExceededUntil = 0;
173
+
174
+ const MAX_REQUESTS = config.rateLimit?.max || 30;
175
+ const RATE_LIMIT_WINDOW = config.rateLimit?.windowMs || 60 * 1000;
176
+ const IDEMPOTENCY_TTL = config.idempotency?.ttlMs || 5 * 60 * 1000;
177
+ const signatureMaxAgeMs = Number.isFinite(config.signature?.maxAgeMs) && config.signature.maxAgeMs > 0
178
+ ? config.signature.maxAgeMs
179
+ : LEGACY_SIGNATURE_MAX_AGE_MS;
180
+
181
+ const configuredAgentSecret = config.agentSecret ?? process.env.AGENTSBLOOM_SECRET;
182
+ const agentSecret = typeof configuredAgentSecret === 'string' && configuredAgentSecret.length > 0
183
+ ? configuredAgentSecret
184
+ : null;
185
+ const signatureAuthEnabled = config.disableSignatureAuth !== true && config.demoMode !== true;
186
+ const cacheNamespace = crypto.randomUUID();
187
+
188
+ if (!agentSecret && signatureAuthEnabled) {
189
+ console.warn("🌸 AgentsBloom Warning: AGENTSBLOOM_SECRET is not set. Legacy signed write requests will be rejected until a secret is configured.");
190
+ }
191
+
192
+ return async (req, res, next) => {
193
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
194
+ if (contentLength > 1024 * 1024) {
195
+ return res.status(413).json({ error: "Payload Too Large", message: "Request body exceeds 1MB limit." });
196
+ }
197
+
198
+ if (req.path === '/health' && req.method === 'GET') {
199
+ return res.json({ status: "ok", version: "0.2.0", uptime: process.uptime() });
200
+ }
201
+
202
+ const requestUrl = baseUrl || `${req.protocol}://${req.get('host')}`;
203
+ const ip = req.ip || req.socket.remoteAddress || '127.0.0.1';
204
+ const now = Date.now();
205
+
206
+ // --- CORS HEADERS ---
207
+ const allowedOrigin = config.corsOrigin || baseUrl || '*';
208
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
209
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
210
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Content-Digest, X-Agent-Signature, X-Agent-Identifier, X-Agent-Timestamp, X-Agent-Nonce, Idempotency-Key, Signature, Signature-Input, Signature-Agent');
211
+ if (req.method === 'OPTIONS') {
212
+ return res.status(204).end();
213
+ }
214
+
215
+ // --- SaaS QUOTA ENFORCEMENT (Zero Latency Cache) ---
216
+ if (now < quotaExceededUntil) {
217
+ res.setHeader('Content-Type', 'application/json');
218
+ return res.status(402).json({
219
+ error: "AgentsBloom Quota Exceeded. Please upgrade your API plan to continue serving AI Agents.",
220
+ code: "api_quota_exceeded"
221
+ });
222
+ }
223
+
224
+ // --- 1. DDoS PROTECTION (RATE LIMITING) ---
225
+ let limit = rateLimitMap.get(ip);
226
+ if (!limit || now > limit.resetTime) {
227
+ limit = { count: 1, resetTime: now + RATE_LIMIT_WINDOW };
228
+ rateLimitMap.set(ip, limit);
229
+ } else {
230
+ limit.count++;
231
+ }
232
+
233
+ const remaining = Math.max(0, MAX_REQUESTS - limit.count);
234
+ res.setHeader('X-RateLimit-Limit', String(MAX_REQUESTS));
235
+ res.setHeader('X-RateLimit-Remaining', String(remaining));
236
+ res.setHeader('X-RateLimit-Reset', String(Math.ceil((limit.resetTime - now) / 1000)));
237
+
238
+ if (limit.count > MAX_REQUESTS) {
239
+ res.setHeader('Content-Type', 'application/json');
240
+ res.setHeader('Retry-After', String(Math.ceil((limit.resetTime - now) / 1000)));
241
+ return res.status(429).json({
242
+ error: "Too Many Requests",
243
+ message: "Rate limit exceeded. Please slow down.",
244
+ retryAfter: Math.ceil((limit.resetTime - now) / 1000)
245
+ });
246
+ }
247
+
248
+ // --- 2. SERVE SPEC ENDPOINTS ---
249
+
250
+ // Serve /.well-known/agent-spec & /v1/agent/spec (API Versioning)
251
+ if (req.path === '/.well-known/agent-spec' || req.path === '/v1/agent/spec') {
252
+ res.setHeader('Content-Type', 'application/json');
253
+ res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
254
+ return res.json({
255
+ name,
256
+ description,
257
+ version: "1.0.0",
258
+ agentsbloomVersion: "0.2.0",
259
+ discoveryUrl: `${requestUrl}/v1/agent/spec`,
260
+ catalogUrl: `${requestUrl}/v1/agent/catalog`,
261
+ llmsUrl: `${requestUrl}/llms.txt`,
262
+ security: {
263
+ rateLimiting: { maxRequestsPerMin: MAX_REQUESTS },
264
+ captchaBypassing: { supported: true, authHeader: "X-Agent-Signature", webBotAuth: true },
265
+ idempotency: { supported: true, header: "Idempotency-Key", ttlSeconds: 300 }
266
+ },
267
+ actions: Object.entries(actions).reduce((acc, [key, val]) => {
268
+ acc[key] = {
269
+ endpoint: `/api/agentsbloom/${key}`,
270
+ method: val.method || 'POST',
271
+ description: val.description,
272
+ params: val.params || {}
273
+ };
274
+ return acc;
275
+ }, {}),
276
+ authentication: { type: "http-message-signatures-or-x-agent-signature", requiredForWrites: true }
277
+ });
278
+ }
279
+
280
+ // Serve /.well-known/http-message-signatures-directory
281
+ if (req.path === '/.well-known/http-message-signatures-directory') {
282
+ res.setHeader('Content-Type', 'application/json');
283
+ res.setHeader('Cache-Control', 'public, max-age=86400');
284
+ if (config.merchantJwks) {
285
+ return res.json(config.merchantJwks);
286
+ } else {
287
+ console.warn("🌸 AgentsBloom Warning: Serving placeholder JWKS. Provide `merchantJwks` in config for real keys.");
288
+ return res.json({
289
+ keys: [
290
+ {
291
+ kty: "OKP",
292
+ crv: "Ed25519",
293
+ kid: "placeholder-merchant-key",
294
+ x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPPRwX0"
295
+ }
296
+ ]
297
+ });
298
+ }
299
+ }
300
+
301
+ // Serve /.well-known/ucp (Universal Commerce Protocol Profile)
302
+ if (req.path === '/.well-known/ucp') {
303
+ res.setHeader('Content-Type', 'application/json');
304
+ res.setHeader('Cache-Control', 'public, max-age=86400');
305
+ return res.json({
306
+ protocol: "ucp",
307
+ version: "1.0.0",
308
+ store: { name, description, baseUrl: requestUrl },
309
+ capabilities: [
310
+ "dev.ucp.shopping",
311
+ "dev.ucp.shopping.checkout",
312
+ "dev.ucp.common.identity_linking"
313
+ ],
314
+ endpoints: {
315
+ catalog: `${requestUrl}/ai-catalog.json`,
316
+ cart: `${requestUrl}/api/agentsbloom/cart`,
317
+ checkout: `${requestUrl}/api/agentsbloom/checkout/acp`
318
+ },
319
+ auth: {
320
+ methods: ["http-message-signatures", "x-agent-signature"]
321
+ }
322
+ });
323
+ }
324
+
325
+ // Serve /ai-catalog.json & /v1/agent/catalog (ARD / UCP Compliant)
326
+ if (req.path === '/ai-catalog.json' || req.path === '/.well-known/ai-catalog.json' || req.path === '/v1/agent/catalog') {
327
+ res.setHeader('Content-Type', 'application/json');
328
+ res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
329
+ return res.json({
330
+ $schema: "https://universalcommerce.org/schemas/catalog.json",
331
+ name,
332
+ description,
333
+ version: "1.0.0",
334
+ auth: {
335
+ supported: ["http-message-signatures", "x-agent-signature"]
336
+ },
337
+ items: Object.entries(actions).map(([key, val]) => ({
338
+ id: key,
339
+ type: "action",
340
+ title: key,
341
+ description: val.description,
342
+ actionUrl: `${requestUrl}/api/agentsbloom/${key}`
343
+ }))
344
+ });
345
+ }
346
+
347
+ // Serve MCP SSE Endpoint for Tool Calling
348
+ if (req.path === '/mcp') {
349
+ const transport = new SSEServerTransport('/mcp/messages', res);
350
+ const mcpServer = new Server({ name: name, version: "1.0.0" }, { capabilities: { tools: {} } });
351
+
352
+ // Auto-generate MCP tool declarations from actions
353
+ mcpServer.setRequestHandler("tools/list", async () => ({
354
+ tools: Object.entries(actions).map(([key, val]) => ({
355
+ name: key,
356
+ description: val.description,
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: Object.entries(val.params || {}).reduce((acc, [pkey, pval]) => {
360
+ acc[pkey] = { type: pval };
361
+ return acc;
362
+ }, {})
363
+ }
364
+ }))
365
+ }));
366
+
367
+ mcpServer.setRequestHandler("tools/call", async (request) => {
368
+ const action = actions[request.params.name];
369
+ if (!action) throw new Error(`Tool not found: ${request.params.name}`);
370
+ const result = await action.handler(request.params.arguments, req, res);
371
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
372
+ });
373
+
374
+ return mcpServer.connect(transport);
375
+ }
376
+
377
+ // Serve /llms.txt
378
+ if (req.path === '/llms.txt') {
379
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
380
+ res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
381
+ const defaultLlmDoc = `# ${name}\n\n${description}\n\n## Developer API Reference\n\n- GET /.well-known/agent-spec : Spec sheets\n- GET /ai-catalog.json : Catalog schemas\n`;
382
+ return res.send(llmsDoc || defaultLlmDoc);
383
+ }
384
+
385
+ // --- 3. IDEMPOTENCY METADATA FOR WRITES (POST/PUT/PATCH/DELETE) ---
386
+ const method = String(req.method || '').toUpperCase();
387
+ const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
388
+ const rawIdempotencyKey = req.headers['idempotency-key'];
389
+ const idempotencyHeader = typeof rawIdempotencyKey === 'string' ? rawIdempotencyKey : null;
390
+ let idempotencyKey = null;
391
+ let authenticatedCacheIdentity = null;
392
+
393
+ // --- 4. CRYPTOGRAPHIC CAPTCHA BYPASSING (Web Bot Auth + Legacy HMAC) ---
394
+ const isMcpMessage = req.path === '/mcp/messages' && method === 'POST';
395
+ const isAgentAction = req.path.startsWith('/api/agentsbloom/')
396
+ || req.path.startsWith('/v1/agent/actions/')
397
+ || isMcpMessage;
398
+ if (isAgentAction && isWrite && signatureAuthEnabled) {
399
+ const signature = req.headers['x-agent-signature'];
400
+ const identifier = req.headers['x-agent-identifier'];
401
+ const timestamp = req.headers['x-agent-timestamp'];
402
+ const nonce = req.headers['x-agent-nonce'];
403
+
404
+ const rfcSignature = req.headers['signature'];
405
+ const rfcSignatureInput = req.headers['signature-input'];
406
+ const hasRfcSignature = typeof rfcSignature === 'string' && rfcSignature.length > 0;
407
+ const hasRfcSignatureInput = typeof rfcSignatureInput === 'string' && rfcSignatureInput.length > 0;
408
+
409
+ if (!signature && !hasRfcSignature && !hasRfcSignatureInput) {
410
+ return res.status(401).json({
411
+ error: "Verification Required",
412
+ message: "CAPTCHA check required. Please provide standard RFC 9421 HTTP Message Signatures or the legacy X-Agent-Signature."
413
+ });
414
+ }
415
+
416
+ if (hasRfcSignature !== hasRfcSignatureInput) {
417
+ return res.status(403).json({
418
+ error: "Verification Failed",
419
+ message: "Signature and Signature-Input headers must be provided together."
420
+ });
421
+ }
422
+
423
+ if (hasRfcSignature) {
424
+ try {
425
+ const keyidMatch = rfcSignatureInput.match(/keyid="([^"]+)"/);
426
+ if (!keyidMatch) throw new Error("Missing keyid");
427
+ const keyid = keyidMatch[1];
428
+ const componentsMatch = rfcSignatureInput.match(/\(([^)]+)\)/);
429
+ const components = componentsMatch
430
+ ? componentsMatch[1].split(' ').map(s => s.replace(/"/g, ''))
431
+ : [];
432
+ if (!components.includes('@method') || !components.includes('@path') || !components.includes('content-digest')) {
433
+ throw new Error('HTTP Message Signatures must cover @method, @path, and content-digest');
434
+ }
435
+
436
+ const createdMatch = rfcSignatureInput.match(/(?:^|;)created=(\d+)(?:;|$)/);
437
+ const expiresMatch = rfcSignatureInput.match(/(?:^|;)expires=(\d+)(?:;|$)/);
438
+ const nonceMatch = rfcSignatureInput.match(/(?:^|;)nonce="([^"]+)"(?:;|$)/);
439
+ const algMatch = rfcSignatureInput.match(/(?:^|;)alg="([^"]+)"(?:;|$)/);
440
+ if (!createdMatch || !expiresMatch || !nonceMatch || !algMatch) {
441
+ throw new Error('HTTP Message Signatures require created, expires, nonce, and alg parameters');
442
+ }
443
+
444
+ const createdSeconds = Number(createdMatch[1]);
445
+ const expiresSeconds = Number(expiresMatch[1]);
446
+ const createdMs = createdSeconds * 1000;
447
+ const expiresMs = expiresSeconds * 1000;
448
+ const nowMs = Date.now();
449
+ if (
450
+ !Number.isSafeInteger(createdSeconds) ||
451
+ !Number.isSafeInteger(expiresSeconds) ||
452
+ expiresSeconds <= createdSeconds ||
453
+ createdMs > nowMs + RFC_SIGNATURE_CLOCK_SKEW_MS ||
454
+ createdMs < nowMs - signatureMaxAgeMs ||
455
+ expiresMs < nowMs - RFC_SIGNATURE_CLOCK_SKEW_MS ||
456
+ expiresMs - createdMs > signatureMaxAgeMs
457
+ ) {
458
+ throw new Error('HTTP Message Signature is expired or outside the allowed lifetime');
459
+ }
460
+
461
+ const rfcNonce = nonceMatch[1];
462
+ if (!/^[\x21-\x7e]{16,256}$/.test(rfcNonce)) {
463
+ throw new Error('HTTP Message Signature nonce must be a printable value of 16 to 256 characters');
464
+ }
465
+
466
+ const contentDigest = req.headers['content-digest'];
467
+ if (typeof contentDigest !== 'string' || contentDigest !== buildRequestContentDigest(req)) {
468
+ throw new Error('HTTP Message Signature content-digest does not match the request body');
469
+ }
470
+
471
+ const alg = algMatch[1];
472
+ const rfcReplayKey = `${cacheNamespace}:rfc:${keyid}:${rfcNonce}`;
473
+
474
+ if (/^https?:\/\//i.test(keyid)) {
475
+ throw new Error("Remote keyid URLs are not accepted; configure a trusted JWKS and use its exact key id");
476
+ }
477
+
478
+ const trustedJwks = config.agentJwks;
479
+ const trustedJwksUrl = config.agentJwksUrl || DEFAULT_RFC_JWKS_URL;
480
+ const cacheKey = `${cacheNamespace}:${trustedJwks ? 'inline' : trustedJwksUrl}:${keyid}`;
481
+ let publicKey = null;
482
+ const cached = jwksCache.get(cacheKey);
483
+ if (cached && cached.expires > Date.now()) {
484
+ publicKey = cached.key;
485
+ } else {
486
+ let jwks;
487
+ if (trustedJwks) {
488
+ jwks = trustedJwks;
489
+ } else {
490
+ const parsedJwksUrl = new URL(trustedJwksUrl);
491
+ if (parsedJwksUrl.protocol !== 'https:') {
492
+ throw new Error("Configured agentJwksUrl must use HTTPS");
493
+ }
494
+ const jwksRes = await fetch(parsedJwksUrl.href, {
495
+ redirect: 'error',
496
+ signal: AbortSignal.timeout(5000),
497
+ });
498
+ if (!jwksRes.ok) throw new Error(`JWKS request failed with HTTP ${jwksRes.status}`);
499
+ jwks = await jwksRes.json();
500
+ }
501
+
502
+ if (!Array.isArray(jwks?.keys)) throw new Error("Configured JWKS is invalid");
503
+ const jwk = jwks.keys.find((candidate) => candidate && candidate.kid === keyid);
504
+ if (!jwk) throw new Error("Configured JWKS does not contain the requested key id");
505
+ publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
506
+ jwksCache.set(cacheKey, { key: publicKey, expires: Date.now() + 3600 * 1000 });
507
+ }
508
+
509
+ let signatureBase = '';
510
+ for (const comp of components) {
511
+ if (comp === '@method') signatureBase += `"@method": ${req.method.toLowerCase()}\n`;
512
+ else if (comp === '@path') signatureBase += `"@path": ${req.originalUrl || req.path}\n`;
513
+ else if (comp === '@authority') signatureBase += `"@authority": ${req.headers.host}\n`;
514
+ else signatureBase += `"${comp}": ${req.headers[comp] || ''}\n`;
515
+ }
516
+ const sigParams = rfcSignatureInput.replace(/^[a-zA-Z0-9_]+=\s*/, '');
517
+ signatureBase += `"@signature-params": ${sigParams}`;
518
+
519
+ const sigMatch = rfcSignature.match(/=:([a-zA-Z0-9+/=]+):/);
520
+ const rawSig = sigMatch ? sigMatch[1] : rfcSignature.replace(/^[a-zA-Z0-9_]+=\s*/, '');
521
+ const signatureBuffer = Buffer.from(rawSig, 'base64');
522
+
523
+ const hashAlg = alg.includes('sha512') ? 'SHA512' : 'SHA256';
524
+
525
+ const isValid = crypto.verify(hashAlg, Buffer.from(signatureBase), publicKey, signatureBuffer);
526
+
527
+ if (!isValid) return res.status(403).json({ error: "Forbidden", message: "Invalid HTTP Message Signature." });
528
+ for (const [replayKey, expiresAt] of signatureNonceMap.entries()) {
529
+ if (expiresAt < nowMs) signatureNonceMap.delete(replayKey);
530
+ }
531
+ if (signatureNonceMap.has(rfcReplayKey)) {
532
+ return res.status(403).json({
533
+ error: "Forbidden",
534
+ message: "HTTP Message Signature nonce has already been used."
535
+ });
536
+ }
537
+ if (signatureNonceMap.size >= SIGNATURE_REPLAY_CACHE_MAX_SIZE) {
538
+ return res.status(503).json({
539
+ error: "Verification Unavailable",
540
+ message: "Signature replay protection is temporarily at capacity."
541
+ });
542
+ }
543
+ signatureNonceMap.set(rfcReplayKey, expiresMs);
544
+ authenticatedCacheIdentity = `rfc:${keyid}`;
545
+ } catch (err) {
546
+ return res.status(403).json({ error: "Forbidden", message: "Signature verification failed: " + err.message });
547
+ }
548
+ } else {
549
+ const headerValuePattern = /^[\x21-\x7e]+$/;
550
+ if (
551
+ !agentSecret ||
552
+ typeof signature !== 'string' ||
553
+ typeof identifier !== 'string' ||
554
+ typeof timestamp !== 'string' ||
555
+ typeof nonce !== 'string' ||
556
+ identifier.length > 256 ||
557
+ nonce.length < 16 ||
558
+ nonce.length > 256 ||
559
+ !headerValuePattern.test(identifier) ||
560
+ !headerValuePattern.test(timestamp) ||
561
+ !headerValuePattern.test(nonce)
562
+ ) {
563
+ return res.status(403).json({
564
+ error: "Verification Failed",
565
+ message: "X-Agent-Signature requires a configured secret, identifier, timestamp, and nonce."
566
+ });
567
+ }
568
+
569
+ const timestampSeconds = Number(timestamp);
570
+ const timestampMs = timestampSeconds * 1000;
571
+ const nowMs = Date.now();
572
+ if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowMs - timestampMs) > signatureMaxAgeMs) {
573
+ return res.status(403).json({
574
+ error: "Verification Failed",
575
+ message: "X-Agent-Signature is expired or has an invalid timestamp."
576
+ });
577
+ }
578
+
579
+ const replayKey = `${cacheNamespace}:legacy:${identifier}:${nonce}`;
580
+ if (signatureNonceMap.has(replayKey)) {
581
+ return res.status(403).json({
582
+ error: "Verification Failed",
583
+ message: "X-Agent-Signature nonce has already been used."
584
+ });
585
+ }
586
+
587
+ let signaturePayload;
588
+ try {
589
+ signaturePayload = buildLegacySignaturePayload(req, identifier, timestamp, nonce);
590
+ } catch {
591
+ return res.status(400).json({
592
+ error: "Invalid Request",
593
+ message: "Request body cannot be serialized for signature verification."
594
+ });
595
+ }
596
+ if (!verifySignature(signature, signaturePayload, agentSecret)) {
597
+ return res.status(403).json({
598
+ error: "Verification Failed",
599
+ message: "X-Agent-Signature is invalid. Access denied."
600
+ });
601
+ }
602
+
603
+ for (const [key, expiresAt] of signatureNonceMap.entries()) {
604
+ if (expiresAt < nowMs) signatureNonceMap.delete(key);
605
+ }
606
+ if (signatureNonceMap.size >= SIGNATURE_REPLAY_CACHE_MAX_SIZE) {
607
+ return res.status(503).json({
608
+ error: "Verification Unavailable",
609
+ message: "Signature replay protection is temporarily at capacity."
610
+ });
611
+ }
612
+ signatureNonceMap.set(replayKey, timestampMs + signatureMaxAgeMs);
613
+ authenticatedCacheIdentity = `legacy:${identifier}`;
614
+ }
615
+ }
616
+
617
+ // --- 3b. IDEMPOTENCY CHECKS FOR WRITES (POST/PUT/PATCH/DELETE) ---
618
+ // Perform this lookup only after the protected-route authentication gate.
619
+ // Cache keys are scoped to this middleware instance and the verified agent
620
+ // identity so one caller cannot replay another caller's cached response.
621
+ if (isWrite && idempotencyHeader) {
622
+ const requestedIdentity = typeof req.headers['x-agent-identifier'] === 'string'
623
+ ? req.headers['x-agent-identifier']
624
+ : 'anonymous';
625
+ const cacheIdentity = authenticatedCacheIdentity || `anonymous:${requestedIdentity}`;
626
+ let serializedRequestBody;
627
+ try {
628
+ serializedRequestBody = JSON.stringify(req.body ?? null);
629
+ } catch {
630
+ return res.status(400).json({
631
+ error: "Invalid Request",
632
+ message: "Request body cannot be serialized for idempotency verification."
633
+ });
634
+ }
635
+ const cacheKeyMaterial = [
636
+ cacheNamespace,
637
+ cacheIdentity,
638
+ method,
639
+ req.originalUrl || req.path,
640
+ serializedRequestBody,
641
+ idempotencyHeader,
642
+ ].join('\u0000');
643
+ idempotencyKey = `${cacheNamespace}:${crypto.createHash('sha256').update(cacheKeyMaterial).digest('hex')}`;
644
+
645
+ for (const [key, val] of idempotencyMap.entries()) {
646
+ if (val.expiry < now) {
647
+ idempotencyMap.delete(key);
648
+ }
649
+ }
650
+
651
+ const cachedResponse = idempotencyMap.get(idempotencyKey);
652
+ if (cachedResponse) {
653
+ console.log('🌸 AgentsBloom: Found cached response for an Idempotency Key');
654
+ res.setHeader('X-Cache', 'Idempotent-Hit');
655
+ res.setHeader('Content-Type', cachedResponse.headers['content-type'] || 'application/json');
656
+ return res.status(cachedResponse.status).send(cachedResponse.responseBody);
657
+ }
658
+ }
659
+
660
+ // --- 4b. AP2 MANDATE VERIFICATION (Wired into middleware) ---
661
+ const ap2MandateHeader = req.headers['x-ap2-mandate'] || req.headers['authorization'];
662
+ const detectedProtocol = resolveProtocol(req);
663
+ let ap2MandateResult = null;
664
+
665
+ if (detectedProtocol === 'AP2' || (ap2MandateHeader && ap2MandateHeader.includes('.'))) {
666
+ // Verify the SD-JWT mandate before allowing any write action. Uses
667
+ // either a merchant-configured trusted public key (config.ap2PublicKey)
668
+ // or, when absent, derives a key from the mandate's own did:key issuer
669
+ // (self-certifying - see lib/ap2.js for why an unverifiable mandate is
670
+ // now rejected outright rather than passed through as "valid").
671
+ ap2MandateResult = verifyAP2Mandates(req.headers, req.body || {}, {
672
+ trustedPublicKey: config.ap2PublicKey || null,
673
+ expectedAudience: config.ap2?.expectedAudience || requestUrl,
674
+ maxMandateLifetimeSec: config.ap2?.maxMandateLifetimeSec,
675
+ requireJti: config.ap2?.requireJti,
676
+ requestedCategories: req.body?.requestedCategories || config.ap2?.requestedCategories,
677
+ });
678
+
679
+ if (!ap2MandateResult.valid) {
680
+ return res.status(403).json({
681
+ error: "AP2 Mandate Rejected",
682
+ protocol: "AP2",
683
+ reason: ap2MandateResult.reason,
684
+ message: "The Verifiable Intent mandate failed validation. The agent's payment authorization is invalid."
685
+ });
686
+ }
687
+
688
+ // Attach mandate info to request for downstream handlers
689
+ req.ap2Mandate = ap2MandateResult;
690
+ }
691
+
692
+ // --- AP2 Discovery Endpoint ---
693
+ if (req.path === '/ap2/capabilities' || req.path === '/v1/ap2/capabilities') {
694
+ return res.json({
695
+ protocol: "AP2",
696
+ version: "1.0.0",
697
+ store: { name, description, baseUrl: requestUrl },
698
+ mandateTypes: ["intentMandate", "cartMandate", "paymentMandate"],
699
+ verificationMethods: ["sd-jwt", "jwt"],
700
+ endpoints: {
701
+ capabilities: `${requestUrl}/ap2/capabilities`,
702
+ intent: `${requestUrl}/ap2/intent`,
703
+ checkout: `${requestUrl}/ap2/checkout`,
704
+ actions: Object.keys(actions).map(k => `${requestUrl}/v1/agent/actions/${k}`)
705
+ },
706
+ budgetEnforcement: true,
707
+ signatureAlgorithms: ["ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA"]
708
+ });
709
+ }
710
+
711
+ // --- AP2 Intent Endpoint (agent announces what it wants to do) ---
712
+ if ((req.path === '/ap2/intent' || req.path === '/v1/ap2/intent') && req.method === 'POST') {
713
+ // Must check `.verified`, not mere truthiness: ap2MandateResult is a
714
+ // truthy object even when no mandate header was ever presented
715
+ // (`{ valid: true, verified: false, note: '...' }`), so a bare
716
+ // `if (!ap2MandateResult)` check would let unauthenticated requests
717
+ // reach this mandate-gated endpoint.
718
+ if (!ap2MandateResult?.verified) {
719
+ return res.status(401).json({
720
+ error: "AP2 Mandate Required",
721
+ message: "Send x-ap2-mandate: Bearer <sd-jwt> header with a valid Intent Mandate."
722
+ });
723
+ }
724
+ return res.json({
725
+ protocol: "AP2",
726
+ intentAccepted: true,
727
+ mandateVerified: ap2MandateResult.verified,
728
+ mandates: ap2MandateResult.mandates || {},
729
+ availableActions: Object.entries(actions).map(([key, val]) => ({
730
+ action: key,
731
+ endpoint: `/v1/agent/actions/${key}`,
732
+ method: val.method || 'POST',
733
+ description: val.description
734
+ })),
735
+ budgetRemaining: ap2MandateResult.mandates?.intentMandate?.maxBudget || "unlimited"
736
+ });
737
+ }
738
+
739
+ // --- AP2 Checkout Endpoint (mandate-gated checkout) ---
740
+ if ((req.path === '/ap2/checkout' || req.path === '/v1/ap2/checkout') && req.method === 'POST') {
741
+ // Same fix as /ap2/intent above: this MUST require a genuinely
742
+ // verified mandate, not just a truthy result object. Previously,
743
+ // hitting /ap2/checkout with no x-ap2-mandate header at all still
744
+ // produced a truthy `ap2MandateResult` (valid:true, verified:false),
745
+ // which passed this check, then found no `.mandates.intentMandate`
746
+ // to read a maxBudget from - so checkout proceeded with ZERO budget
747
+ // enforcement. Requiring `.verified` closes that bypass.
748
+ if (!ap2MandateResult?.verified) {
749
+ return res.status(401).json({
750
+ error: "AP2 Payment Mandate Required",
751
+ message: "AP2 checkout requires x-ap2-mandate header with a valid Payment Mandate."
752
+ });
753
+ }
754
+
755
+ const checkoutAction = actions['checkout'];
756
+ if (!checkoutAction || typeof checkoutAction.handler !== 'function') {
757
+ return res.status(404).json({ error: 'Checkout action not configured for this store.' });
758
+ }
759
+
760
+ // Validate budget before executing checkout
761
+ const orderTotal = req.body?.orderTotal || req.body?.total || 0;
762
+ const maxBudget = ap2MandateResult.mandates?.intentMandate?.maxBudget
763
+ || ap2MandateResult.mandates?.paymentMandate?.maxBudget;
764
+
765
+ if (maxBudget && Number(orderTotal) > Number(maxBudget)) {
766
+ return res.status(403).json({
767
+ error: "AP2 Budget Exceeded",
768
+ protocol: "AP2",
769
+ orderTotal: Number(orderTotal),
770
+ maxBudget: Number(maxBudget),
771
+ message: `Order total $${orderTotal} exceeds mandate budget limit of $${maxBudget}.`
772
+ });
773
+ }
774
+
775
+ return Promise.resolve(checkoutAction.handler(req.body, req, res))
776
+ .then(result => {
777
+ if (!res.headersSent) {
778
+ res.json({
779
+ protocol: "AP2",
780
+ verifiableIntent: {
781
+ mandateVerified: ap2MandateResult.verified,
782
+ budgetEnforced: !!maxBudget,
783
+ maxBudget: maxBudget || null,
784
+ orderTotal: result.total || orderTotal
785
+ },
786
+ session_id: result.sessionId || `ap2_sess_${Date.now()}`,
787
+ payment_url: result.paymentUrl,
788
+ status: "authorized",
789
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
790
+ checkout: result
791
+ });
792
+ }
793
+ })
794
+ .catch(err => {
795
+ console.error("AP2 Checkout error:", err);
796
+ if (!res.headersSent) {
797
+ res.status(500).json({ error: 'Internal AP2 checkout error', protocol: 'AP2' });
798
+ }
799
+ });
800
+ }
801
+
802
+ // --- 5. HANDLE ACTION ROUTING ---
803
+ // Agentic Commerce Protocol (ACP) Checkout Wrapper
804
+ if (req.path === '/api/agentsbloom/checkout/acp' && req.method === 'POST') {
805
+ const checkoutAction = actions['checkout'];
806
+ if (checkoutAction && typeof checkoutAction.handler === 'function') {
807
+ return Promise.resolve(checkoutAction.handler(req.body, req, res))
808
+ .then(result => {
809
+ if (!res.headersSent) {
810
+ res.json({
811
+ session_id: result.sessionId || `acp_sess_${Date.now()}`,
812
+ payment_url: result.paymentUrl,
813
+ status: "open",
814
+ expires_at: Math.floor(Date.now() / 1000) + 3600
815
+ });
816
+ }
817
+ })
818
+ .catch(err => {
819
+ console.error(`ACP Checkout execution error:`, err);
820
+ if (!res.headersSent) {
821
+ res.status(500).json({ error: 'Internal ACP checkout error' });
822
+ }
823
+ });
824
+ } else {
825
+ return res.status(404).json({ error: 'ACP Checkout not configured for this store.' });
826
+ }
827
+ }
828
+
829
+ if (req.path.startsWith('/api/agentsbloom/') || req.path.startsWith('/v1/agent/actions/')) {
830
+ const actionName = req.path.replace('/api/agentsbloom/', '').replace('/v1/agent/actions/', '');
831
+ const action = actions[actionName];
832
+
833
+ if (action && typeof action.handler === 'function') {
834
+ const configuredMethod = String(action.method || 'POST').toUpperCase();
835
+ if (method !== configuredMethod) {
836
+ res.setHeader('Allow', configuredMethod);
837
+ return res.status(405).json({
838
+ error: "Method Not Allowed",
839
+ message: `Action ${actionName} only accepts ${configuredMethod} requests.`,
840
+ allowedMethod: configuredMethod,
841
+ });
842
+ }
843
+
844
+ const params = method === 'GET' ? req.query : req.body;
845
+
846
+ if (action.params) {
847
+ for (const [key, type] of Object.entries(action.params)) {
848
+ if (params[key] !== undefined && typeof params[key] !== type && type !== 'any') {
849
+ return res.status(400).json({ error: "Invalid Parameter", message: `Expected ${type} for parameter ${key}` });
850
+ }
851
+ }
852
+ }
853
+
854
+ // Cache original res.send to support idempotency key caching
855
+ const originalSend = res.send;
856
+ res.send = function (body) {
857
+ if (isWrite && idempotencyKey && res.statusCode >= 200 && res.statusCode < 300) {
858
+ idempotencyMap.set(idempotencyKey, {
859
+ responseBody: body,
860
+ status: res.statusCode,
861
+ headers: { 'content-type': res.getHeader('content-type') },
862
+ timestamp: Date.now(),
863
+ expiry: Date.now() + IDEMPOTENCY_TTL
864
+ });
865
+ }
866
+ return originalSend.apply(this, arguments);
867
+ };
868
+
869
+ const startTime = Date.now();
870
+ const agentName = req.headers['x-agent-identifier'] || req.headers['signature-agent'] || 'Unknown Agent';
871
+
872
+ // Extract W3C Trace Context from incoming request (HIGH-16)
873
+ const parentContext = propagation.extract(context.active(), req.headers);
874
+ const span = tracer.startSpan(`agent_request:${actionName}`, {}, parentContext);
875
+
876
+ return Promise.resolve(action.handler(params, req, res))
877
+ .then(result => {
878
+ if (!res.headersSent) {
879
+ res.json(result);
880
+ }
881
+
882
+ const latencyMs = Date.now() - startTime;
883
+ const revenue = result && result.totalPrice ? result.totalPrice : 0;
884
+
885
+ // OpenTelemetry Native Instrumentation
886
+ span.setAttribute('agent.name', agentName);
887
+ span.setAttribute('agent.route', `/api/agentsbloom/${actionName}`);
888
+ span.setAttribute('http.status_code', res.statusCode);
889
+ span.setAttribute('http.latency_ms', latencyMs);
890
+
891
+ agentRequestsCounter.add(1, { agent: agentName });
892
+ if (revenue) agentRevenueCounter.add(revenue, { agent: agentName });
893
+ span.end();
894
+
895
+ // Telemetry is handled by OTLP exporters configured via setupTelemetry().
896
+ // The span.end() call above will auto-export to the OTLP collector.
897
+ })
898
+ .catch(err => {
899
+ span.setAttribute('error', true);
900
+ span.end();
901
+ console.error(`AgentsBloom action execution error (${actionName}):`, err);
902
+ if (!res.headersSent) {
903
+ res.status(500).json({ error: 'Internal agent endpoint error' });
904
+ }
905
+ });
906
+ } else {
907
+ return res.status(404).json({ error: `Unknown AgentsBloom action.` });
908
+ }
909
+ }
910
+
911
+ // --- 6. HTML INJECTION WITH COMPRESSION SUPPORT ---
912
+ // Opt-out via config.disableHtmlInjection (MED-12)
913
+ if (config.disableHtmlInjection) {
914
+ return next();
915
+ }
916
+
917
+ const originalWrite = res.write;
918
+ const originalEnd = res.end;
919
+ let chunks = [];
920
+ let isHtml = false;
921
+
922
+ const originalWriteHead = res.writeHead;
923
+ res.writeHead = function (statusCode, headers) {
924
+ const contentType = res.getHeader('Content-Type') || (headers && headers['content-type']) || '';
925
+ if (typeof contentType === 'string' && contentType.includes('text/html')) {
926
+ isHtml = true;
927
+ res.removeHeader('Content-Length');
928
+ if (headers) delete headers['content-length'];
929
+ }
930
+ return originalWriteHead.apply(this, arguments);
931
+ };
932
+
933
+ res.write = function (chunk) {
934
+ const contentType = res.getHeader('Content-Type') || '';
935
+ if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
936
+ isHtml = true;
937
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
938
+ return true;
939
+ }
940
+ return originalWrite.apply(res, arguments);
941
+ };
942
+
943
+ res.end = function (chunk) {
944
+ const contentType = res.getHeader('Content-Type') || '';
945
+ if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
946
+ isHtml = true;
947
+ if (chunk) {
948
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
949
+ }
950
+
951
+ let bodyBuffer = Buffer.concat(chunks);
952
+ const encoding = res.getHeader('Content-Encoding');
953
+ const isGzipped = typeof encoding === 'string' && encoding.includes('gzip');
954
+
955
+ // Decompress if gzipped
956
+ if (isGzipped) {
957
+ try {
958
+ bodyBuffer = zlib.gunzipSync(bodyBuffer);
959
+ } catch (err) {
960
+ console.error("AgentsBloom decompression error:", err);
961
+ }
962
+ }
963
+
964
+ let body = bodyBuffer.toString('utf8');
965
+
966
+ if (body.toLowerCase().includes('</body>')) {
967
+ const jsonLdData = {
968
+ "@context": "https://schema.org",
969
+ "@type": "WebPage",
970
+ "name": name,
971
+ "description": description,
972
+ "potentialAction": Object.entries(actions).map(([key, val]) => ({
973
+ "@type": "SearchAction",
974
+ "name": key,
975
+ "target": `${requestUrl}/api/agentsbloom/${key}`
976
+ }))
977
+ };
978
+
979
+ const jsonLdScript = `\n<script type="application/ld+json">\n${JSON.stringify(jsonLdData, null, 2)}\n</script>`;
980
+
981
+ const webMcpScript = `
982
+ <meta name="webmcp" content="active">
983
+ <script>
984
+ // Auto-generated WebMCP Declarative Actions by AgentsBloom
985
+ (function() {
986
+ if (typeof navigator !== 'undefined' && navigator.ai && typeof navigator.ai.registerTool === 'function') {
987
+ console.log("🌸 AgentsBloom: Registering WebMCP tools natively in browser.");
988
+
989
+ ${Object.entries(actions).map(([key, val]) => `
990
+ navigator.ai.registerTool({
991
+ name: "${key}",
992
+ description: "${val.description.replace(/"/g, '\\"')}",
993
+ inputSchema: {
994
+ type: "object",
995
+ properties: {
996
+ ${Object.entries(val.params || {}).map(([pkey, pval]) => `
997
+ "${pkey}": { type: "${pval}" }
998
+ `).join(',')}
999
+ }
1000
+ },
1001
+ handler: async (args) => {
1002
+ try {
1003
+ const method = "${val.method || 'POST'}";
1004
+ const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1005
+ let fetchOptions = { method, headers };
1006
+
1007
+ let url = '/api/agentsbloom/${key}';
1008
+ if (method === 'GET') {
1009
+ const queryParams = new URLSearchParams(args).toString();
1010
+ if (queryParams) url += '?' + queryParams;
1011
+ } else {
1012
+ fetchOptions.body = JSON.stringify(args);
1013
+ }
1014
+
1015
+ const response = await fetch(url, fetchOptions);
1016
+ return await response.json();
1017
+ } catch (e) {
1018
+ return { error: e.message };
1019
+ }
1020
+ }
1021
+ });
1022
+ `).join('\n')}
1023
+ }
1024
+ })();
1025
+ </script>
1026
+ `;
1027
+
1028
+ if (body.toLowerCase().includes('</head>')) {
1029
+ body = body.replace(/<\/head>/i, `${jsonLdScript}\n</head>`);
1030
+ } else {
1031
+ body = body + jsonLdScript;
1032
+ }
1033
+
1034
+ body = body.replace(/<\/body>/i, `${webMcpScript}\n</body>`);
1035
+ }
1036
+
1037
+ let outputBuffer = Buffer.from(body);
1038
+
1039
+ // Re-compress if gzipped
1040
+ if (isGzipped) {
1041
+ try {
1042
+ outputBuffer = zlib.gzipSync(outputBuffer);
1043
+ } catch (err) {
1044
+ console.error("AgentsBloom compression error:", err);
1045
+ }
1046
+ }
1047
+
1048
+ res.setHeader('Content-Length', outputBuffer.length);
1049
+ originalWrite.call(res, outputBuffer);
1050
+ return originalEnd.call(res);
1051
+ }
1052
+ return originalEnd.apply(res, arguments);
1053
+ };
1054
+
1055
+ next();
1056
+ };
1057
+ }
1058
+
1059
+ // --- UNIFIED PROTOCOL ROUTER ---
1060
+ export function resolveProtocol(req) {
1061
+ const accept = (req.headers && req.headers['accept']) || '';
1062
+ const xProtocol = (req.headers && req.headers['x-protocol']) || '';
1063
+ const path = req.path || req.url || '';
1064
+
1065
+ if (accept.includes('application/mcp+json') || path.startsWith('/mcp')) {
1066
+ return 'WEBMCP';
1067
+ }
1068
+ if (xProtocol.toLowerCase() === 'ucp' || path.startsWith('/.well-known/ucp') || path.startsWith('/ucp')) {
1069
+ return 'UCP';
1070
+ }
1071
+ if (xProtocol.toLowerCase() === 'acp' || path.startsWith('/acp')) {
1072
+ return 'ACP';
1073
+ }
1074
+ if (req.headers && (req.headers['x-ap2-mandate'] || path.startsWith('/ap2'))) {
1075
+ return 'AP2';
1076
+ }
1077
+ return 'AGENTSBLOOM_REST';
1078
+ }
1079
+
1080
+ // --- AP2 (AGENT PAYMENTS PROTOCOL) SD-JWT MANDATE VERIFIER ---
1081
+ //
1082
+ // Thin, backward-compatible wrapper over the hardened implementation in
1083
+ // lib/ap2.js. The original signature `verifyAP2Mandates(headers, body,
1084
+ // publicKey)` treated an unsigned/unverifiable mandate as "valid but
1085
+ // unverified" and let downstream code (and, worse, the /ap2/checkout gate
1086
+ // itself - see the truthiness-check fix above) treat that as authorization
1087
+ // to check out. lib/ap2.js's verifyAp2Mandate instead REJECTS any mandate
1088
+ // it cannot cryptographically verify.
1089
+ //
1090
+ // Both call shapes are supported for backward compatibility:
1091
+ // verifyAP2Mandates(headers, body, publicKey) // legacy
1092
+ // verifyAP2Mandates(headers, body, { trustedPublicKey, expectedAudience, ... }) // current
1093
+ export function verifyAP2Mandates(headers = {}, body = {}, publicKeyOrOptions = null) {
1094
+ let options;
1095
+ if (publicKeyOrOptions === null || publicKeyOrOptions === undefined) {
1096
+ options = {};
1097
+ } else if (
1098
+ publicKeyOrOptions instanceof crypto.KeyObject ||
1099
+ Buffer.isBuffer(publicKeyOrOptions) ||
1100
+ typeof publicKeyOrOptions === 'string'
1101
+ ) {
1102
+ // Legacy call shape: third argument is a raw public key.
1103
+ options = { trustedPublicKey: publicKeyOrOptions };
1104
+ } else {
1105
+ // Current call shape: third argument is an options object.
1106
+ options = publicKeyOrOptions;
1107
+ }
1108
+ return verifyAp2Mandate(headers, body, options);
1109
+ }
1110
+
1111
+ export { createAp2Mandate, didKeyFromEd25519PublicKey, ed25519PublicKeyFromDidKey, resetAp2ReplayCache };
1112
+