@agentsbloom/sdk 0.4.0 → 0.5.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 CHANGED
@@ -1,1995 +1,2352 @@
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';
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
+ // `setRequestHandler` takes a Zod request SCHEMA, not a method-name string.
6
+ // Passing the strings 'tools/list' / 'tools/call' made the MCP SDK throw
7
+ // `Error: Schema is missing a method literal` on EVERY `GET /mcp`, so the MCP
8
+ // surface advertised in every discovery document was completely non-functional.
9
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
+ import { trace, metrics, ValueType, context, propagation } from '@opentelemetry/api';
11
+ import { initExporter } from './telemetry.js';
12
+ import {
13
+ verifyAp2Mandate,
14
+ createAp2Mandate,
15
+ didKeyFromEd25519PublicKey,
16
+ ed25519PublicKeyFromDidKey,
17
+ resetAp2ReplayCache,
18
+ stopAp2ReplayCleanup,
19
+ canonicalCartHash,
20
+ stableStringify,
21
+ normalizeAudience,
22
+ SUPPORTED_MANDATE_ALGORITHMS,
23
+ } from './lib/ap2.js';
15
24
  import { createReplayCache } from './lib/shared-store.js';
16
- import { createOutcomeReporter, stripeEventToOutcome } from './lib/outcomes.js';
17
-
18
- const tracer = trace.getTracer('agentsbloom-sdk');
19
- const meter = metrics.getMeter('agentsbloom-sdk');
20
-
21
- const agentRequestsCounter = meter.createCounter('agent_visits_total', { description: 'Total AI Visits' });
22
- const agentRevenueCounter = meter.createCounter('agent_revenue_usd', { description: 'Total AI Revenue', valueType: ValueType.DOUBLE });
23
-
24
- /**
25
- * Initialize OpenTelemetry with OTLP exporters.
26
- * Call this BEFORE using the agentsbloom() middleware.
27
- *
28
- * @param {Object} options
29
- * @param {string} options.otlpEndpoint - OTLP collector URL (default: http://localhost:4318)
30
- * @param {string} options.serviceName - Service name for traces (default: agentsbloom-merchant)
31
- * @param {number} options.samplingRatio - Trace sampling ratio 0.0-1.0 (default: 1.0)
32
- * @param {string} options.apiKey - API key for authenticating with the collector
33
- */
34
- export async function setupTelemetry(options = {}) {
35
- const {
36
- otlpEndpoint = process.env.AGENTSBLOOM_OTEL_ENDPOINT || 'http://localhost:4318',
37
- serviceName = 'agentsbloom-merchant',
38
- samplingRatio = parseFloat(process.env.AGENTSBLOOM_SAMPLING_RATIO || '1.0'),
39
- apiKey = process.env.AGENTSBLOOM_API_KEY || '',
40
- } = options;
41
-
42
- // Store config for lazy initialization when OTel SDK packages are available
43
- globalThis.__agentsbloom_otel_config = {
44
- otlpEndpoint,
45
- serviceName,
46
- samplingRatio,
47
- apiKey,
48
- };
49
-
50
- const handle = await initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey });
51
- globalThis.__agentsbloom_otel_handle = handle;
52
-
53
- console.log(`🌸 AgentsBloom: Telemetry configured (sampling: ${samplingRatio * 100}%)`);
54
- return { otlpEndpoint, serviceName, samplingRatio };
55
- }
56
- // v4 security hardening: there is deliberately NO default RFC 9421 JWKS
57
- // source anymore. The previous default pointed at a third-party provider's
58
- // live key set, which silently accepted that provider's signing keys for
59
- // MERCHANT write actions whenever a merchant forgot to configure their own
60
- // trust root. Verification now fails closed until the merchant configures
61
- // `agentJwks` (inline JWKS object) or `agentJwksUrl` (HTTPS JWKS endpoint).
62
- const LEGACY_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
63
- const RFC_SIGNATURE_CLOCK_SKEW_MS = 30 * 1000;
64
- const SIGNATURE_REPLAY_CACHE_MAX_SIZE = 50_000;
65
- const MAX_RATE_TRACKED_CLIENTS = 50_000;
66
- // Second-pass review: the JWKS cache was an unswept Map keyed by
67
- // (config, keyid) - attacker-controlled keyids grew it without bound, and
68
- // every UNKNOWN keyid triggered a live JWKS fetch (5s timeout), turning
69
- // cheap requests into outbound-request amplification. The cache is now
70
- // bounded, swept, and unknown keyids are negatively cached briefly so
71
- // repeated junk kids cost one lookup instead of one network fetch each.
72
- const JWKS_CACHE_MAX_ENTRIES = 500;
73
- const JWKS_NEGATIVE_TTL_MS = 60 * 1000;
74
- const jwksCache = new Map();
75
-
76
- /** Returns the cached JWKS entry (positive or negative) or null. Sweeps lazily. */
77
- function jwksCacheGet(cacheKey) {
78
- const entry = jwksCache.get(cacheKey);
79
- if (!entry) return null;
80
- if (entry.expires <= Date.now()) {
81
- jwksCache.delete(cacheKey);
82
- return null;
83
- }
84
- return entry;
85
- }
86
-
87
- /** Bounded insert: sweep expired first, then evict the oldest-tracked entry. */
88
- function jwksCachePut(cacheKey, entry) {
89
- if (jwksCache.size >= JWKS_CACHE_MAX_ENTRIES) {
90
- const now = Date.now();
91
- for (const [key, value] of jwksCache.entries()) {
92
- if (value.expires <= now) jwksCache.delete(key);
93
- }
94
- if (jwksCache.size >= JWKS_CACHE_MAX_ENTRIES) {
95
- const oldestKey = jwksCache.keys().next().value;
96
- if (oldestKey !== undefined) jwksCache.delete(oldestKey);
97
- }
98
- }
99
- jwksCache.set(cacheKey, entry);
100
- }
101
-
102
- /** Escapes JSON for embedding inside an HTML <script> element. */
103
- function toSafeJson(value) {
104
- return JSON.stringify(value)
105
- .replace(/</g, '\\u003c')
106
- .replace(/>/g, '\\u003e')
107
- .replace(/&/g, '\\u0026')
108
- .replace(/\u2028/g, '\\u2028')
109
- .replace(/\u2029/g, '\\u2029');
110
- }
111
-
112
- /**
113
- * Normalizes a client key for rate limiting: strips the IPv6-mapped IPv4
114
- * prefix (::ffff:127.0.0.1 -> 127.0.0.1) so the same client cannot get a
115
- * fresh bucket by alternating address representations.
116
- */
117
- function normalizeRateLimitKey(ip) {
118
- const raw = String(ip || '');
119
- return raw.startsWith('::ffff:') ? raw.slice(7) : raw;
120
- }
121
-
122
- function verifySignature(signature, payload, secret) {
123
- try {
124
- if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/i.test(signature)) return false;
125
- const computed = crypto.createHmac('sha256', secret).update(payload).digest();
126
- const provided = Buffer.from(signature, 'hex');
127
- return crypto.timingSafeEqual(computed, provided);
128
- } catch {
129
- return false;
130
- }
131
- }
132
-
133
- function buildLegacySignaturePayload(req, identifier, timestamp, nonce) {
134
- return JSON.stringify([
135
- identifier,
136
- String(req.method || '').toUpperCase(),
137
- req.originalUrl || req.path,
138
- timestamp,
139
- nonce,
140
- req.body ?? null,
141
- ]);
142
- }
143
-
144
- function buildRequestContentDigest(req) {
145
- let bodyBytes;
146
- if (req.rawBody !== undefined) {
147
- bodyBytes = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(String(req.rawBody));
148
- } else if (Buffer.isBuffer(req.body)) {
149
- bodyBytes = req.body;
150
- } else if (req.body !== undefined) {
151
- bodyBytes = Buffer.from(JSON.stringify(req.body ?? null));
152
- } else {
153
- const contentLength = Number(req.headers['content-length'] || 0);
154
- if (contentLength > 0) {
155
- throw new Error('Protected HTTP signatures require parsed request bodies or req.rawBody');
156
- }
157
- bodyBytes = Buffer.alloc(0);
158
- }
159
-
160
- const digest = crypto.createHash('sha256').update(bodyBytes).digest('base64');
161
- return `sha-256=:${digest}:`;
162
- }
163
-
164
- /**
165
- * v4: validates/coerces action parameters against the action's declared
166
- * `params` type map. Shared by the REST action route AND the MCP tools/call
167
- * handler - previously the MCP path passed arguments straight through with
168
- * no validation at all, so the type guarantees advertised in discovery
169
- * documents were not actually enforced there.
170
- *
171
- * @returns {{ ok: boolean, params?: object, message?: string }}
172
- */
173
- function validateActionParams(rawParams, action) {
174
- const params = { ...rawParams };
175
- if (!action.params) return { ok: true, params };
176
- for (const [key, type] of Object.entries(action.params)) {
177
- const val = params[key];
178
- if (val === undefined) continue;
179
- if (type === 'any') continue;
180
- if (type === 'number') {
181
- if (typeof val === 'number' && !isNaN(val)) {
182
- // valid
183
- } else if (typeof val === 'string' && val.trim() !== '' && !isNaN(Number(val))) {
184
- params[key] = Number(val);
185
- } else {
186
- return { ok: false, message: `Expected ${type} for parameter ${key}` };
187
- }
188
- } else if (type === 'boolean') {
189
- if (typeof val === 'boolean') {
190
- // valid
191
- } else if (typeof val === 'string' && (val === 'true' || val === 'false' || val === '1' || val === '0')) {
192
- params[key] = val === 'true' || val === '1';
193
- } else {
194
- return { ok: false, message: `Expected ${type} for parameter ${key}` };
195
- }
196
- } else if (type === 'string') {
197
- if (typeof val !== 'string') {
198
- return { ok: false, message: `Expected ${type} for parameter ${key}` };
199
- }
200
- } else if (typeof val !== type && !(typeof type === 'string' && type.includes(typeof val))) {
201
- return { ok: false, message: `Expected ${type} for parameter ${key}` };
202
- }
203
- }
204
- return { ok: true, params };
205
- }
206
-
207
- // In-memory rate limiting map
208
- const rateLimitMap = new Map();
209
- // v4: bounded count of live MCP SSE sessions (see /mcp handler).
210
- let activeMcpSessions = 0;
211
-
212
- // --- v4 medium pass (V15): the SaaS quota gate is now WIRED ---
213
- // Previously `quotaExceededUntil` was a per-instance variable initialized
214
- // to 0 that nothing ever set - dead code implying enforcement that did not
215
- // exist. The hosted gateway (or any operator tooling) can now trip every
216
- // middleware instance in the process via setQuotaExceededUntil(), or set
217
- // AGENTSBLOOM_QUOTA_EXCEEDED_UNTIL (epoch ms) in the environment.
218
- let quotaExceededUntilMs = Number(process.env.AGENTSBLOOM_QUOTA_EXCEEDED_UNTIL || 0) || 0;
219
-
220
- /** Marks quota exceeded process-wide until the given epoch-ms timestamp. */
221
- export function setQuotaExceededUntil(untilEpochMs) {
222
- const value = Number(untilEpochMs);
223
- quotaExceededUntilMs = Number.isFinite(value) ? value : 0;
224
- }
225
-
226
- /** Clears the process-wide quota-exceeded state immediately. */
227
- export function clearQuotaExceeded() {
228
- quotaExceededUntilMs = 0;
229
- }
230
- const rateLimitInterval = setInterval(() => {
231
- const now = Date.now();
232
- for (const [key, val] of rateLimitMap.entries()) {
233
- if (val.resetTime < now) rateLimitMap.delete(key);
234
- }
235
- }, 60 * 1000);
236
- rateLimitInterval.unref();
237
-
238
- // In-memory Idempotency cache (per-instance optimization; order-level
239
- // idempotency is enforced downstream by deterministic order refs).
240
- // Signature nonces live in a replay cache that becomes cluster-wide when
241
- // an Upstash REST endpoint is configured (lib/shared-store.js).
242
- const idempotencyMap = new Map();
243
- const signatureNonceMap = createReplayCache('agentsbloom:sig:nonce', SIGNATURE_REPLAY_CACHE_MAX_SIZE);
244
- const idempotencyInterval = setInterval(() => {
245
- const now = Date.now();
246
- for (const [key, val] of idempotencyMap.entries()) {
247
- if (val.expiry < now) idempotencyMap.delete(key);
248
- }
249
- }, 60 * 1000);
250
- idempotencyInterval.unref();
251
-
252
- let otelShutdownPromise = null;
253
-
254
- // --- OpenAPI 3.1 document generation ---
255
- //
256
- // Generated from the merchant's declared actions so every action the store
257
- // actually mounts appears in /openapi.json. Well-known commerce actions
258
- // keep their curated, example-rich schemas; anything else a merchant
259
- // declares is derived from its `params` type map. AP2 and protocol
260
- // discovery endpoints are documented alongside the REST surface, and the
261
- // security schemes describe the exact headers RFC 9421 / legacy /
262
- // AP2 callers must send.
263
-
264
- const OPENAPI_PARAM_TYPE_MAP = {
265
- string: 'string',
266
- number: 'number',
267
- integer: 'integer',
268
- boolean: 'boolean',
269
- any: null, // schema-less when the action accepts anything
270
- };
271
-
272
- function openApiSchemaForParamType(type) {
273
- const mapped = OPENAPI_PARAM_TYPE_MAP[type];
274
- return mapped ? { type: mapped } : {};
275
- }
276
-
277
- /** Common error responses referenced via $ref from every generated operation. */
278
- function openApiErrorRefs() {
279
- return {
280
- "401": { $ref: '#/components/responses/VerificationRequired' },
281
- "403": { $ref: '#/components/responses/Forbidden' },
282
- "429": { $ref: '#/components/responses/RateLimited' },
283
- };
284
- }
285
-
286
- /** Curated, example-rich entries for the canonical commerce actions. */
287
- function curatedOpenApiPaths() {
288
- return {
289
- "/api/agentsbloom/products": {
290
- get: {
291
- operationId: "listProducts",
292
- summary: "Retrieve product catalog or filter by category/query",
293
- parameters: [
294
- { name: "category", in: "query", schema: { type: "string" }, description: "Category filter (e.g. shoes, apparel, accessories, electronics, home, books)" },
295
- { name: "query", in: "query", schema: { type: "string" }, description: "Search term" },
296
- { name: "limit", in: "query", schema: { type: "integer" }, description: "Maximum products to return" }
297
- ],
298
- responses: { "200": { description: "List of matching products with price, sizes, stock, rating" }, ...openApiErrorRefs() }
299
- }
300
- },
301
- "/api/agentsbloom/search": {
302
- get: {
303
- operationId: "searchProducts",
304
- summary: "Search products by natural language query",
305
- parameters: [
306
- { name: "query", in: "query", schema: { type: "string" }, description: "Product name or search keywords" },
307
- { name: "category", in: "query", schema: { type: "string" }, description: "Category filter" }
308
- ],
309
- responses: { "200": { description: "Search results with count and product details" }, ...openApiErrorRefs() }
310
- }
311
- },
312
- "/api/agentsbloom/cart": {
313
- get: {
314
- operationId: "getCart",
315
- summary: "Retrieve current cart contents and total price",
316
- responses: { "200": { description: "Cart items, item count, and price breakdown" }, ...openApiErrorRefs() }
317
- }
318
- },
319
- "/api/agentsbloom/addToCart": {
320
- post: {
321
- operationId: "addToCart",
322
- summary: "Add a product variant to cart",
323
- security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }],
324
- requestBody: {
325
- required: true,
326
- content: {
327
- "application/json": {
328
- schema: {
329
- type: "object",
330
- required: ["productId"],
331
- properties: {
332
- productId: { type: "string", description: "Product ID (e.g. puma-velocity-3, trail-master-x2, agent-pro-backpack)" },
333
- size: { type: "string", description: "Product size or variant (e.g. 10, M, One Size)" },
334
- quantity: { type: "integer", default: 1, description: "Quantity to add" }
335
- }
336
- }
337
- }
338
- }
339
- },
340
- responses: { "200": { description: "Success confirmation and updated cart size" }, ...openApiErrorRefs() }
341
- }
342
- },
343
- "/api/agentsbloom/checkout": {
344
- post: {
345
- operationId: "checkout",
346
- summary: "Create a secure checkout link for payment",
347
- security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }],
348
- requestBody: {
349
- required: false,
350
- content: {
351
- "application/json": {
352
- schema: {
353
- type: "object",
354
- properties: {
355
- address: { type: "string", description: "Customer shipping destination address" },
356
- gateway: { type: "string", default: "stripe", description: "Payment gateway: stripe, razorpay, or paddle" }
357
- }
358
- }
359
- }
360
- }
361
- },
362
- responses: { "200": { description: "Secure payment URL and session ID" }, ...openApiErrorRefs() }
363
- }
364
- }
365
- };
366
- }
367
-
368
- /** Derives an operation entry for any declared action from its params map. */
369
- function openApiOperationForAction(key, action, signatureAuthEnabled) {
370
- const httpMethod = String(action.method || 'POST').toLowerCase();
371
- const isWrite = ['post', 'put', 'patch', 'delete'].includes(httpMethod);
372
- const paramSchema = {
373
- type: "object",
374
- properties: Object.fromEntries(
375
- Object.entries(action.params || {}).map(([pkey, pval]) => [pkey, openApiSchemaForParamType(pval)])
376
- ),
377
- };
378
-
379
- const operation = {
380
- operationId: key,
381
- summary: action.description || key,
382
- ...(isWrite && signatureAuthEnabled
383
- ? { security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }] }
384
- : {}),
385
- ...(httpMethod === 'get'
386
- ? {
387
- parameters: Object.entries(action.params || {}).map(([pkey, pval]) => ({
388
- name: pkey,
389
- in: "query",
390
- schema: openApiSchemaForParamType(pval),
391
- })),
392
- }
393
- : {
394
- requestBody: {
395
- required: true,
396
- content: { "application/json": { schema: paramSchema } },
397
- },
398
- }),
399
- responses: {
400
- "200": { description: action.description ? `${action.description} result` : `${key} result` },
401
- ...openApiErrorRefs(),
402
- },
403
- };
404
-
405
- return { [httpMethod]: operation };
406
- }
407
-
408
- /**
409
- * Builds the full OpenAPI 3.1 document: generated action paths, curated
410
- * canonical entries, AP2 endpoints, discovery endpoints, reusable security
411
- * schemes, and error responses.
412
- */
413
- function buildOpenApiDocument({ name, description, requestUrl, actions = {}, signatureAuthEnabled = true }) {
414
- const paths = {};
415
-
416
- // Every declared action gets a path (curated entries override generated
417
- // ones for the canonical five).
418
- for (const [key, action] of Object.entries(actions)) {
419
- paths[`/api/agentsbloom/${key}`] = openApiOperationForAction(key, action, signatureAuthEnabled);
420
- }
421
- Object.assign(paths, curatedOpenApiPaths());
422
-
423
- // AP2 protocol surface.
424
- paths["/ap2/capabilities"] = {
425
- get: {
426
- operationId: "ap2Capabilities",
427
- summary: "AP2 capabilities, mandate types, and verification methods",
428
- responses: { "200": { description: "AP2 capability document" }, ...openApiErrorRefs() },
429
- },
430
- };
431
- paths["/ap2/intent"] = {
432
- post: {
433
- operationId: "ap2Intent",
434
- summary: "Announce purchase intent with a verified AP2 Intent Mandate",
435
- security: [{ Ap2MandateAuth: [] }],
436
- requestBody: {
437
- required: false,
438
- content: {
439
- "application/json": {
440
- schema: {
441
- type: "object",
442
- properties: {
443
- requestedCategories: { type: "array", items: { type: "string" }, description: "Cart categories the intent covers" },
444
- },
445
- },
446
- },
447
- },
448
- },
449
- responses: {
450
- "200": { description: "Intent accepted with mandate verification evidence" },
451
- "401": { $ref: '#/components/responses/VerificationRequired' },
452
- "403": { $ref: '#/components/responses/Forbidden' },
453
- },
454
- },
455
- };
456
- paths["/ap2/checkout"] = {
457
- post: {
458
- operationId: "ap2Checkout",
459
- summary: "Mandate-gated checkout: creates a payment URL only when a verified AP2 mandate with a positive maxBudget covers the order total",
460
- security: [{ Ap2MandateAuth: [] }],
461
- requestBody: {
462
- required: true,
463
- content: {
464
- "application/json": {
465
- schema: {
466
- type: "object",
467
- required: ["orderTotal"],
468
- properties: {
469
- orderTotal: { type: "number", description: "Order total that must be covered by the mandate's maxBudget" },
470
- address: { type: "string", description: "Customer shipping destination address" },
471
- gateway: { type: "string", default: "stripe", description: "Payment gateway: stripe, razorpay, or paddle" },
472
- },
473
- },
474
- },
475
- },
476
- },
477
- responses: {
478
- "200": { description: "Authorized checkout with payment URL, session id, and budget-enforcement evidence" },
479
- "401": { $ref: '#/components/responses/VerificationRequired' },
480
- "403": { $ref: '#/components/responses/Forbidden' },
481
- },
482
- },
483
- };
484
-
485
- // Protocol discovery surface.
486
- paths["/.well-known/agent-spec"] = {
487
- get: {
488
- operationId: "agentSpec",
489
- summary: "Legacy/compatibility discovery document",
490
- responses: { "200": { description: "Agent spec manifest" } },
491
- },
492
- };
493
- paths["/.well-known/ucp"] = {
494
- get: {
495
- operationId: "ucpProfile",
496
- summary: "UCP (Universal Commerce Protocol) profile",
497
- responses: { "200": { description: "UCP profile with capabilities and endpoint declarations" } },
498
- },
499
- };
500
- paths["/ai-catalog.json"] = {
501
- get: {
502
- operationId: "aiCatalog",
503
- summary: "WebMCP/ARD-style action catalog",
504
- responses: { "200": { description: "Action catalog document" } },
505
- },
506
- };
507
- paths["/llms.txt"] = {
508
- get: {
509
- operationId: "llmsDoc",
510
- summary: "LLM-oriented developer guide",
511
- responses: { "200": { description: "Plain-text guide for LLM agents" } },
512
- },
513
- };
514
-
515
- return {
516
- openapi: "3.1.0",
517
- info: { title: name, description, version: "1.0.0" },
518
- servers: [{ url: requestUrl }],
519
- // Reads are open; writes carry their own operation-level security.
520
- security: [],
521
- tags: [
522
- { name: "catalog", description: "Product browsing and search" },
523
- { name: "cart", description: "Cart operations (write actions require agent signatures)" },
524
- { name: "checkout", description: "Checkout link creation" },
525
- { name: "ap2", description: "AP2 mandate-gated payment authorization" },
526
- { name: "discovery", description: "Protocol discovery documents" },
527
- ],
528
- paths,
529
- components: {
530
- securitySchemes: {
531
- HttpSignatureAuth: {
532
- type: "apiKey",
533
- description: "RFC 9421 HTTP Message Signatures: send `Signature` and `Signature-Input` headers; the signature must cover @method, @path, and content-digest for bodied requests, with created/expires/nonce parameters and a keyid resolvable through the configured JWKS.",
534
- in: "header",
535
- name: "Signature",
536
- },
537
- LegacyAgentSignature: {
538
- type: "apiKey",
539
- description: "Legacy HMAC scheme: X-Agent-Signature plus X-Agent-Identifier, X-Agent-Timestamp, X-Agent-Nonce headers, signed with the shared agent secret.",
540
- in: "header",
541
- name: "X-Agent-Signature",
542
- },
543
- Ap2MandateAuth: {
544
- type: "apiKey",
545
- description: "AP2 budget mandate: an SD-JWT with intentMandate.maxBudget, audience-bound to this store, sent as `X-AP2-Mandate: Bearer <sd-jwt>`.",
546
- in: "header",
547
- name: "X-AP2-Mandate",
548
- },
549
- },
550
- responses: {
551
- VerificationRequired: {
552
- description: "Credential missing - write actions need an agent signature; AP2 endpoints need a mandate.",
553
- content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, message: { type: "string" } } } } },
554
- },
555
- Forbidden: {
556
- description: "Credential presented but invalid (bad signature, expired mandate, budget exceeded, wrong audience, replay detected).",
557
- content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, reason: { type: "string" }, protocol: { type: "string" } } } } },
558
- },
559
- RateLimited: {
560
- description: "Too many requests - respect X-RateLimit-Reset and Retry-After.",
561
- content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, retryAfter: { type: "number" } } } } },
562
- },
563
- },
564
- },
565
- };
566
- }
567
-
568
-
569
- export async function shutdown() {
570
- clearInterval(rateLimitInterval);
571
- clearInterval(idempotencyInterval);
572
- rateLimitMap.clear();
573
- idempotencyMap.clear();
574
- signatureNonceMap.clear();
575
- jwksCache.clear();
576
- stopAp2ReplayCleanup();
577
-
578
- if (otelShutdownPromise) {
579
- return otelShutdownPromise;
580
- }
581
-
582
- const otelHandle = globalThis.__agentsbloom_otel_handle;
583
- if (!otelHandle) return;
584
-
585
- // Clear the handle before awaiting so a repeated/concurrent shutdown cannot
586
- // start a second provider shutdown, even if the first one rejects.
587
- globalThis.__agentsbloom_otel_handle = null;
588
- otelShutdownPromise = Promise.resolve().then(() => otelHandle.provider.shutdown());
589
- try {
590
- await otelShutdownPromise;
591
- } finally {
592
- otelShutdownPromise = null;
593
- }
594
- }
595
-
596
- export function agentsbloom(config = {}) {
597
- const {
598
- apiKey = null,
599
- name = "My Agent-Ready Store",
600
- description = "An e-commerce store optimized for human and machine AI agents.",
601
- actions = {},
602
- llmsDoc = "",
603
- baseUrl = ""
604
- } = config;
605
-
606
- if (!apiKey) {
607
- console.error("🌸 AgentsBloom SDK Error: Missing `apiKey`. You must provide an API Key to use the SDK. Get one at dashboard.agentsbloom.com");
608
- }
609
- if (!baseUrl) {
610
- console.error("🌸 AgentsBloom SDK Error: Missing `baseUrl` in config. This is required for secure telemetry.");
611
- }
612
-
613
- const MAX_REQUESTS = config.rateLimit?.max || 30;
614
- const RATE_LIMIT_WINDOW = config.rateLimit?.windowMs || 60 * 1000;
615
- const IDEMPOTENCY_TTL = config.idempotency?.ttlMs || 5 * 60 * 1000;
616
- // v4 medium pass (V18): bound the per-process idempotency cache so
617
- // unique-key spam cannot grow memory without limit between TTL sweeps.
618
- const IDEMPOTENCY_MAX_ENTRIES = Number.isFinite(config.idempotency?.maxEntries) && config.idempotency.maxEntries > 0
619
- ? config.idempotency.maxEntries
620
- : 10_000;
621
- const signatureMaxAgeMs = Number.isFinite(config.signature?.maxAgeMs) && config.signature.maxAgeMs > 0
622
- ? config.signature.maxAgeMs
623
- : LEGACY_SIGNATURE_MAX_AGE_MS;
624
- // v4 medium pass (V14): require the @authority covered component so the
625
- // host is bound into every RFC 9421 signature (off by default for
626
- // backward compatibility with agents that don't sign it yet).
627
- const signatureRequireAuthority = config.signature?.requireAuthority === true;
628
-
629
- const configuredAgentSecret = config.agentSecret ?? process.env.AGENTSBLOOM_SECRET;
630
- const agentSecret = typeof configuredAgentSecret === 'string' && configuredAgentSecret.length > 0
631
- ? configuredAgentSecret
632
- : null;
633
- // Previous secret kept verify-live during rotation (see verifySignature
634
- // call sites) so rolling AGENTSBLOOM_SECRET doesn't hard-drop every
635
- // in-flight agent.
636
- const configuredPreviousSecret = config.agentSecretPrevious ?? process.env.AGENTSBLOOM_SECRET_PREVIOUS;
637
- const agentSecretPrevious = typeof configuredPreviousSecret === 'string' && configuredPreviousSecret.length > 0
638
- ? configuredPreviousSecret
639
- : null;
640
- const signatureAuthEnabled = config.disableSignatureAuth !== true && config.demoMode !== true;
641
- const cacheNamespace = crypto.randomUUID();
642
-
643
- // --- v4 medium pass (V11): per-agent keys and revocation ---
644
- // The single shared merchant secret meant one compromised agent
645
- // compromised every agent: any holder could forge requests as ANY
646
- // identifier, and there was no way to revoke one agent without rotating
647
- // the global secret for all of them. `config.agentKeys` maps identifier
648
- // -> per-agent secret (checked first); `config.revokedIdentifiers`
649
- // rejects an identifier outright. The shared secret remains the
650
- // fallback so existing deployments keep working.
651
- const agentKeys = config.agentKeys && typeof config.agentKeys === 'object'
652
- ? Object.fromEntries(
653
- Object.entries(config.agentKeys).filter(([, v]) => typeof v === 'string' && v.length > 0)
654
- )
655
- : null;
656
- const revokedIdentifiers = Array.isArray(config.revokedIdentifiers)
657
- ? new Set(config.revokedIdentifiers)
658
- : null;
659
-
660
- // --- v4 medium pass (V12): per-action authorization ---
661
- // Any verified identity could previously invoke ANY write action,
662
- // checkout included - the permission matrix existed only in the hosted
663
- // gateway. Merchants can now require a verified identity per action and
664
- // restrict which identities may call it.
665
- const actionAccess = config.actionAccess && typeof config.actionAccess === 'object' ? config.actionAccess : null;
666
- const actionIdentities = config.actionIdentities && typeof config.actionIdentities === 'object' ? config.actionIdentities : null;
667
-
668
- // --- v4 medium pass (V20): CORS origin policy ---
669
- // Array = exact-match allow-list with per-request reflection; string =
670
- // legacy single-value behavior ('*' by default). The wildcard default is
671
- // loud about itself so merchants notice and restrict it.
672
- const allowedOriginList = Array.isArray(config.corsOrigin)
673
- ? config.corsOrigin.filter((o) => typeof o === 'string' && o.length > 0)
674
- : null;
675
- const allowedOriginString = allowedOriginList ? null : (config.corsOrigin || '*');
676
- if (!config.corsOrigin) {
677
- console.warn("🌸 AgentsBloom Warning: corsOrigin defaults to '*' - any web origin can call this store's API, including write endpoints reachable from a victim browser. Set corsOrigin to an explicit origin or array of origins for production.");
678
- }
679
-
680
- /**
681
- * Returns a rejection descriptor when `identity` (verified cache identity
682
- * string like `rfc:<keyid>` / `legacy:<identifier>`, or null) may not
683
- * invoke `actionName`; null when allowed.
684
- */
685
- function authorizeAction(actionName, identity) {
686
- if (actionAccess?.[actionName] === 'authenticated' && !identity) {
687
- return {
688
- status: 401,
689
- body: {
690
- error: "Authentication Required",
691
- message: `Action ${actionName} requires a verified agent signature (RFC 9421 or X-Agent-Signature).`
692
- },
693
- };
694
- }
695
- const allowed = actionIdentities?.[actionName];
696
- if (Array.isArray(allowed) && allowed.length > 0) {
697
- const matched = Boolean(identity) && allowed.some((pattern) => (
698
- pattern === identity || (typeof pattern === 'string' && pattern.endsWith(':') && identity.startsWith(pattern))
699
- ));
700
- if (!matched) {
701
- return {
702
- status: 403,
703
- body: {
704
- error: "Forbidden",
705
- message: `Verified identity ${identity || '(anonymous)'} is not authorized to invoke ${actionName}.`
706
- },
707
- };
708
- }
709
- }
710
- return null;
711
- }
712
-
713
- if (!agentSecret && !agentKeys && signatureAuthEnabled) {
714
- console.warn("🌸 AgentsBloom Warning: Neither AGENTSBLOOM_SECRET nor config.agentKeys is set. Legacy signed write requests will be rejected until a secret is configured.");
715
- }
716
-
717
- // v4 (Host-header poisoning defense): warn when the AP2 audience would be
718
- // derived from the request's own Host header - that fallback is spoofable.
719
- if (!config.ap2?.expectedAudience && !baseUrl) {
720
- console.warn("🌸 AgentsBloom Warning: Neither `baseUrl` nor `ap2.expectedAudience` is configured. The AP2 mandate audience will fall back to each request's Host header, which an attacker can spoof. Set one of them for production.");
721
- }
722
-
723
- return async (req, res, next) => {
724
- // v4 medium pass (V10): the 1MB gate used to check the Content-Length
725
- // header only - a chunked transfer with no Content-Length sailed past
726
- // it. The cap is now also enforced against the ACTUAL received bytes
727
- // (req.rawBody) once the body parser has run, and is configurable.
728
- const maxBodyBytes = Number.isFinite(config.maxBodyBytes) && config.maxBodyBytes > 0
729
- ? config.maxBodyBytes
730
- : 1024 * 1024;
731
- const contentLength = parseInt(req.headers['content-length'] || '0', 10);
732
- if (contentLength > maxBodyBytes) {
733
- return res.status(413).json({ error: "Payload Too Large", message: `Request body exceeds ${maxBodyBytes} byte limit.` });
734
- }
735
- let receivedBodyBytes;
736
- if (Buffer.isBuffer(req.rawBody)) {
737
- receivedBodyBytes = req.rawBody.length;
738
- } else if (typeof req.rawBody === 'string') {
739
- receivedBodyBytes = Buffer.byteLength(req.rawBody);
740
- }
741
- if (receivedBodyBytes !== undefined && receivedBodyBytes > maxBodyBytes) {
742
- return res.status(413).json({
743
- error: "Payload Too Large",
744
- message: `Request body exceeds ${maxBodyBytes} byte limit.`,
745
- code: "body_size_exceeded"
746
- });
747
- }
748
-
749
- if (req.path === '/health' && req.method === 'GET') {
750
- return res.json({ status: "ok", version: "0.4.0", uptime: process.uptime() });
751
- }
752
-
753
- // v4 (Host-header poisoning defense): when `config.allowedHosts` is
754
- // configured, a request whose Host header is not on the list gets its
755
- // discovery URLs built from the merchant's trusted `baseUrl` instead of
756
- // the attacker-controlled Host. Without an allow-list the previous
757
- // behavior is preserved (tunnel hosts etc. keep working), so this is
758
- // opt-in hardening for production deployments behind a fixed domain.
759
- const allowedHosts = Array.isArray(config.allowedHosts)
760
- ? config.allowedHosts.filter((h) => typeof h === 'string' && h.length > 0).map((h) => h.toLowerCase())
761
- : null;
762
- const rawHost = req.get('host') || '';
763
- const hostIsAllowed = !allowedHosts || !rawHost || allowedHosts.includes(rawHost.toLowerCase());
764
- const host = hostIsAllowed ? rawHost : '';
765
- // v4: only well-known protocol values are honored from x-forwarded-proto.
766
- const forwardedProtoCandidate = String(req.get('x-forwarded-proto') || '').split(',')[0].trim().toLowerCase();
767
- const forwardedProto = forwardedProtoCandidate === 'http' || forwardedProtoCandidate === 'https'
768
- ? forwardedProtoCandidate
769
- : '';
770
- const isTlsTunnel = host.includes('.life') || host.includes('.loca.lt') || host.includes('.trycloudflare.com') || host.includes('.ngrok');
771
- const protocol = forwardedProto || (isTlsTunnel ? 'https' : (req.protocol || 'http'));
772
- const requestUrl = host ? `${protocol}://${host}` : (baseUrl || `${protocol}://localhost:3000`);
773
- const ip = req.ip || req.socket.remoteAddress || '127.0.0.1';
774
- const now = Date.now();
775
-
776
- // --- CORS HEADERS ---
777
- // v4 medium pass (V20): `corsOrigin` now accepts an array of exact
778
- // origins; requests whose Origin matches get it reflected (with
779
- // Vary: Origin), everyone else gets NO Access-Control-Allow-Origin at
780
- // all - a real allow-list instead of `*` for everything including
781
- // writes. The string form behaves exactly as before. The wildcard
782
- // default is retained for backward compatibility but warns once.
783
- if (Array.isArray(allowedOriginList)) {
784
- const requestOrigin = req.headers.origin;
785
- if (typeof requestOrigin === 'string' && allowedOriginList.includes(requestOrigin)) {
786
- res.setHeader('Access-Control-Allow-Origin', requestOrigin);
787
- res.setHeader('Vary', 'Origin');
788
- }
789
- } else {
790
- res.setHeader('Access-Control-Allow-Origin', allowedOriginString);
791
- }
792
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
793
- 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');
794
-
795
- // --- SaaS QUOTA ENFORCEMENT (Zero Latency Cache) ---
796
- if (now < quotaExceededUntilMs) {
797
- res.setHeader('Content-Type', 'application/json');
798
- return res.status(402).json({
799
- error: "AgentsBloom Quota Exceeded. Please upgrade your API plan to continue serving AI Agents.",
800
- code: "api_quota_exceeded"
801
- });
802
- }
803
-
804
- // --- 1. DDoS PROTECTION (RATE LIMITING) ---
805
- // v4: OPTIONS preflights are now counted toward the same bucket (they
806
- // used to bypass the limiter entirely, making free preflight floods
807
- // possible), and the client key is normalized so IPv6-mapped IPv4
808
- // representations cannot mint fresh buckets.
809
- const rateKey = normalizeRateLimitKey(ip);
810
- let limit = rateLimitMap.get(rateKey);
811
- const rateKeyNormalized = rateKey;
812
- if (!limit || now > limit.resetTime) {
813
- // v4: bound the tracking map. When at capacity, sweep expired
814
- // entries first; if still full, drop the oldest-tracked client so
815
- // attacker-controlled IP rotation cannot grow memory unboundedly.
816
- if (!rateLimitMap.has(rateKeyNormalized) && rateLimitMap.size >= MAX_RATE_TRACKED_CLIENTS) {
817
- for (const [key, val] of rateLimitMap.entries()) {
818
- if (val.resetTime < now) rateLimitMap.delete(key);
819
- }
820
- if (rateLimitMap.size >= MAX_RATE_TRACKED_CLIENTS) {
821
- const oldestKey = rateLimitMap.keys().next().value;
822
- if (oldestKey !== undefined) rateLimitMap.delete(oldestKey);
823
- }
824
- }
825
- limit = { count: 1, resetTime: now + RATE_LIMIT_WINDOW };
826
- rateLimitMap.set(rateKeyNormalized, limit);
827
- } else {
828
- limit.count++;
829
- }
830
-
831
- const remaining = Math.max(0, MAX_REQUESTS - limit.count);
832
- res.setHeader('X-RateLimit-Limit', String(MAX_REQUESTS));
833
- res.setHeader('X-RateLimit-Remaining', String(remaining));
834
- res.setHeader('X-RateLimit-Reset', String(Math.ceil((limit.resetTime - now) / 1000)));
835
-
836
- if (limit.count > MAX_REQUESTS) {
837
- res.setHeader('Content-Type', 'application/json');
838
- res.setHeader('Retry-After', String(Math.ceil((limit.resetTime - now) / 1000)));
839
- return res.status(429).json({
840
- error: "Too Many Requests",
841
- message: "Rate limit exceeded. Please slow down.",
842
- retryAfter: Math.ceil((limit.resetTime - now) / 1000)
843
- });
844
- }
845
-
846
- // OPTIONS preflights are answered AFTER being counted against the
847
- // caller's rate bucket (v4 fix for unthrottled preflight floods).
848
- if (req.method === 'OPTIONS') {
849
- return res.status(204).end();
850
- }
851
-
852
- // --- 2. SERVE SPEC ENDPOINTS ---
853
-
854
- // Serve /.well-known/agent-spec & /v1/agent/spec (API Versioning)
855
- if (req.path === '/.well-known/agent-spec' || req.path === '/v1/agent/spec') {
856
- res.setHeader('Content-Type', 'application/json');
857
- res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
858
- return res.json({
859
- name,
860
- description,
861
- version: "1.0.0",
862
- agentsbloomVersion: "0.4.0",
863
- discoveryUrl: `${requestUrl}/v1/agent/spec`,
864
- catalogUrl: `${requestUrl}/v1/agent/catalog`,
865
- llmsUrl: `${requestUrl}/llms.txt`,
866
- security: {
867
- rateLimiting: { maxRequestsPerMin: MAX_REQUESTS },
868
- captchaBypassing: { supported: true, authHeader: "X-Agent-Signature", webBotAuth: true },
869
- idempotency: { supported: true, header: "Idempotency-Key", ttlSeconds: 300 }
870
- },
871
- actions: Object.entries(actions).reduce((acc, [key, val]) => {
872
- acc[key] = {
873
- endpoint: `/api/agentsbloom/${key}`,
874
- method: val.method || 'POST',
875
- description: val.description,
876
- params: val.params || {}
877
- };
878
- return acc;
879
- }, {}),
880
- authentication: { type: "http-message-signatures-or-x-agent-signature", requiredForWrites: true }
881
- });
882
- }
883
-
884
- // Serve /.well-known/http-message-signatures-directory
885
- // v4 medium pass (V16): the old placeholder key ("placeholder-merchant-key")
886
- // was a fake trust anchor - agents could "verify" nothing real against
887
- // it while believing they had a genuine merchant key set. Fail closed
888
- // instead until the merchant configures their JWKS.
889
- if (req.path === '/.well-known/http-message-signatures-directory') {
890
- if (!config.merchantJwks) {
891
- return res.status(503).json({
892
- error: 'JWKS Not Configured',
893
- message: 'This store has not published a merchant JWKS. Provide `merchantJwks` in the AgentsBloom SDK config to serve /.well-known/http-message-signatures-directory.'
894
- });
895
- }
896
- res.setHeader('Content-Type', 'application/json');
897
- res.setHeader('Cache-Control', 'public, max-age=86400');
898
- return res.json(config.merchantJwks);
899
- }
900
-
901
- // Serve /.well-known/ucp (Universal Commerce Protocol Profile)
902
- if (req.path === '/.well-known/ucp') {
903
- res.setHeader('Content-Type', 'application/json');
904
- res.setHeader('Cache-Control', 'public, max-age=86400');
905
- return res.json({
906
- protocol: "ucp",
907
- version: "1.0.0",
908
- store: { name, description, baseUrl: requestUrl },
909
- capabilities: [
910
- "dev.ucp.shopping",
911
- "dev.ucp.shopping.checkout",
912
- "dev.ucp.common.identity_linking"
913
- ],
914
- endpoints: {
915
- catalog: `${requestUrl}/ai-catalog.json`,
916
- search: `${requestUrl}/api/agentsbloom/search`,
917
- products: `${requestUrl}/api/agentsbloom/products`,
918
- cart: `${requestUrl}/api/agentsbloom/cart`,
919
- checkout: `${requestUrl}/api/agentsbloom/checkout/acp`
920
- },
921
- actions: Object.entries(actions).reduce((acc, [key, val]) => {
922
- acc[key] = {
923
- endpoint: `/api/agentsbloom/${key}`,
924
- method: val.method || 'POST',
925
- description: val.description,
926
- params: val.params || {}
927
- };
928
- return acc;
929
- }, {}),
930
- auth: {
931
- methods: ["http-message-signatures", "x-agent-signature"]
932
- }
933
- });
934
- }
935
-
936
- // Serve /ai-catalog.json & /v1/agent/catalog (ARD / UCP Compliant)
937
- if (req.path === '/ai-catalog.json' || req.path === '/.well-known/ai-catalog.json' || req.path === '/v1/agent/catalog') {
938
- res.setHeader('Content-Type', 'application/json');
939
- res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
940
- return res.json({
941
- $schema: "https://universalcommerce.org/schemas/catalog.json",
942
- name,
943
- description,
944
- version: "1.0.0",
945
- auth: {
946
- supported: ["http-message-signatures", "x-agent-signature"]
947
- },
948
- items: Object.entries(actions).map(([key, val]) => ({
949
- id: key,
950
- type: "action",
951
- title: key,
952
- description: val.description,
953
- actionUrl: `${requestUrl}/api/agentsbloom/${key}`
954
- }))
955
- });
956
- }
957
-
958
- // Serve /openapi.json & /schema.json (OpenAPI 3.1 for custom GPT
959
- // actions and any OpenAPI-consuming agent). The document is generated
960
- // from the merchant's declared actions - previously it hardcoded five
961
- // paths, so any custom action (removeFromCart, clearCart, or anything
962
- // a merchant added) silently vanished from the OpenAPI surface, and
963
- // AP2 endpoints were entirely undocumented.
964
- if (req.path === '/openapi.json' || req.path === '/schema.json') {
965
- res.setHeader('Content-Type', 'application/json');
966
- res.setHeader('Cache-Control', 'public, max-age=86400');
967
- return res.json(buildOpenApiDocument({ name, description, requestUrl, actions, signatureAuthEnabled }));
968
- }
969
-
970
- // Serve MCP SSE Endpoint for Tool Calling
971
- // v4: concurrent SSE sessions are bounded (config.mcp.maxSessions,
972
- // default 100). Previously every GET /mcp created an unbounded new
973
- // transport with no accounting - a trivial socket/memory exhaustion
974
- // vector.
975
- if (req.path === '/mcp') {
976
- const maxMcpSessions = Number.isFinite(config.mcp?.maxSessions) && config.mcp.maxSessions > 0
977
- ? config.mcp.maxSessions
978
- : 100;
979
- if (activeMcpSessions >= maxMcpSessions) {
980
- return res.status(503).json({
981
- error: "Too Many MCP Sessions",
982
- message: `Concurrent MCP session limit (${maxMcpSessions}) reached. Retry later.`
983
- });
984
- }
985
- const transport = new SSEServerTransport('/mcp/messages', res);
986
- activeMcpSessions += 1;
987
- res.on('close', () => { activeMcpSessions -= 1; });
988
- const mcpServer = new Server({ name: name, version: "1.0.0" }, { capabilities: { tools: {} } });
989
- // Auto-generate MCP tool declarations from actions
990
- mcpServer.setRequestHandler("tools/list", async () => ({
991
- tools: Object.entries(actions).map(([key, val]) => ({
992
- name: key,
993
- description: val.description,
994
- inputSchema: {
995
- type: "object",
996
- properties: Object.entries(val.params || {}).reduce((acc, [pkey, pval]) => {
997
- acc[pkey] = { type: pval };
998
- return acc;
999
- }, {})
1000
- }
1001
- }))
1002
- }));
1003
-
1004
- mcpServer.setRequestHandler("tools/call", async (request) => {
1005
- const action = actions[request.params.name];
1006
- if (!action) throw new Error(`Tool not found: ${request.params.name}`);
1007
- // v4 medium pass (V12): the same per-action authorization model
1008
- // applies to MCP tool invocations.
1009
- const accessRejection = authorizeAction(request.params.name, authenticatedCacheIdentity);
1010
- if (accessRejection) throw new Error(accessRejection.body.message);
1011
- // v4: enforce the declared parameter contract. The REST route has
1012
- // always validated/coerced params; the MCP path now does too, so
1013
- // merchants get identical type guarantees on both surfaces.
1014
- const validation = validateActionParams(request.params.arguments || {}, action);
1015
- if (!validation.ok) throw new Error(validation.message);
1016
- const result = await action.handler(validation.params, req, res);
1017
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
1018
- });
1019
-
1020
- // Second-pass review (N4): if connect() rejects, release the session
1021
- // slot immediately instead of waiting for a close event that may not
1022
- // fire for a transport that never started.
1023
- try {
1024
- return await mcpServer.connect(transport);
1025
- } catch (err) {
1026
- activeMcpSessions -= 1;
1027
- throw err;
1028
- }
1029
- }
1030
-
1031
- // Serve /llms.txt
1032
- if (req.path === '/llms.txt') {
1033
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1034
- res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
1035
- 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- GET /openapi.json : OpenAPI 3.1 schema\n`;
1036
- const docContent = typeof llmsDoc === 'function' ? llmsDoc() : (llmsDoc || defaultLlmDoc);
1037
- return res.send(docContent);
1038
- }
1039
-
1040
- // --- 3. IDEMPOTENCY METADATA FOR WRITES (POST/PUT/PATCH/DELETE) ---
1041
- const method = String(req.method || '').toUpperCase();
1042
- const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
1043
- const rawIdempotencyKey = req.headers['idempotency-key'];
1044
- const idempotencyHeader = typeof rawIdempotencyKey === 'string' ? rawIdempotencyKey : null;
1045
- let idempotencyKey = null;
1046
- let authenticatedCacheIdentity = null;
1047
-
1048
- // --- 4. CRYPTOGRAPHIC CAPTCHA BYPASSING (Web Bot Auth + Legacy HMAC) ---
1049
- const isMcpMessage = req.path === '/mcp/messages' && method === 'POST';
1050
- // Second-pass review (N3): AP2 intent/checkout POSTs are now part of the
1051
- // signature gate. The agent-side SDK has always signed these posts, but
1052
- // the merchant gate ignored them - so a mandate alone could check out
1053
- // anonymously, and per-action authorization policies could never see a
1054
- // verified identity on the most valuable endpoint. A verified mandate
1055
- // proves budget INTENT; a signature proves who is spending it.
1056
- const isAp2WritePath = method === 'POST' && (
1057
- req.path === '/ap2/intent'
1058
- || req.path === '/ap2/checkout'
1059
- || req.path === '/v1/ap2/intent'
1060
- || req.path === '/v1/ap2/checkout'
1061
- );
1062
- const isAgentAction = req.path.startsWith('/api/agentsbloom/')
1063
- || req.path.startsWith('/v1/agent/actions/')
1064
- || isMcpMessage
1065
- || isAp2WritePath;
1066
- // v4 medium pass (V12): resolve which action (if any) this path targets
1067
- // so per-action authorization can require verification even on reads.
1068
- let requestedActionName = null;
1069
- if (req.path.startsWith('/api/agentsbloom/')) {
1070
- requestedActionName = req.path.slice('/api/agentsbloom/'.length);
1071
- } else if (req.path.startsWith('/v1/agent/actions/')) {
1072
- requestedActionName = req.path.slice('/v1/agent/actions/'.length);
1073
- }
1074
- const needsAuthenticatedRead = Boolean(
1075
- requestedActionName && actionAccess?.[requestedActionName] === 'authenticated'
1076
- );
1077
- if (isAgentAction && (isWrite || needsAuthenticatedRead) && signatureAuthEnabled) {
1078
- const signature = req.headers['x-agent-signature'];
1079
- const identifier = req.headers['x-agent-identifier'];
1080
- const timestamp = req.headers['x-agent-timestamp'];
1081
- const nonce = req.headers['x-agent-nonce'];
1082
-
1083
- const rfcSignature = req.headers['signature'];
1084
- const rfcSignatureInput = req.headers['signature-input'];
1085
- const hasRfcSignature = typeof rfcSignature === 'string' && rfcSignature.length > 0;
1086
- const hasRfcSignatureInput = typeof rfcSignatureInput === 'string' && rfcSignatureInput.length > 0;
1087
-
1088
- if (!signature && !hasRfcSignature && !hasRfcSignatureInput) {
1089
- return res.status(401).json({
1090
- error: "Verification Required",
1091
- message: "CAPTCHA check required. Please provide standard RFC 9421 HTTP Message Signatures or the legacy X-Agent-Signature."
1092
- });
1093
- }
1094
-
1095
- if (hasRfcSignature !== hasRfcSignatureInput) {
1096
- return res.status(403).json({
1097
- error: "Verification Failed",
1098
- message: "Signature and Signature-Input headers must be provided together."
1099
- });
1100
- }
1101
-
1102
- if (hasRfcSignature) {
1103
- try {
1104
- const keyidMatch = rfcSignatureInput.match(/keyid="([^"]+)"/);
1105
- if (!keyidMatch) throw new Error("Missing keyid");
1106
- const keyid = keyidMatch[1];
1107
-
1108
- // v4 medium pass (V19/V14): only single-label signatures are
1109
- // supported - the regex-based parameter extraction below would
1110
- // silently mis-parse a multi-label input, so reject it explicitly
1111
- // instead of verifying something other than what was sent.
1112
- if (/,\s*[a-zA-Z0-9_]+\s*=\s*\(/.test(rfcSignatureInput)) {
1113
- return res.status(403).json({
1114
- error: "Verification Failed",
1115
- message: "Multi-label HTTP Message Signatures are not supported; send exactly one signature label."
1116
- });
1117
- }
1118
- const inputLabelMatch = rfcSignatureInput.match(/^([a-zA-Z0-9_]+)\s*=/);
1119
- const sigLabelMatch = rfcSignature.match(/^([a-zA-Z0-9_]+)\s*=/);
1120
- if (!inputLabelMatch || !sigLabelMatch || inputLabelMatch[1] !== sigLabelMatch[1]) {
1121
- return res.status(403).json({
1122
- error: "Verification Failed",
1123
- message: "Signature and Signature-Input labels do not match."
1124
- });
1125
- }
1126
-
1127
- const componentsMatch = rfcSignatureInput.match(/\(([^)]+)\)/);
1128
- const components = componentsMatch
1129
- ? componentsMatch[1].split(' ').map(s => s.replace(/"/g, ''))
1130
- : [];
1131
- if (!components.includes('@method') || !components.includes('@path') || !components.includes('content-digest')) {
1132
- throw new Error('HTTP Message Signatures must cover @method, @path, and content-digest');
1133
- }
1134
-
1135
- // v4 medium pass (V14): deployments that need the host bound into
1136
- // every signature can require the @authority component.
1137
- if (signatureRequireAuthority && !components.includes('@authority')) {
1138
- throw new Error('HTTP Message Signatures must cover @authority for this deployment');
1139
- }
1140
-
1141
- const createdMatch = rfcSignatureInput.match(/(?:^|;)created=(\d+)(?:;|$)/);
1142
- const expiresMatch = rfcSignatureInput.match(/(?:^|;)expires=(\d+)(?:;|$)/);
1143
- const nonceMatch = rfcSignatureInput.match(/(?:^|;)nonce="([^"]+)"(?:;|$)/);
1144
- const algMatch = rfcSignatureInput.match(/(?:^|;)alg="([^"]+)"(?:;|$)/);
1145
- if (!createdMatch || !expiresMatch || !nonceMatch || !algMatch) {
1146
- throw new Error('HTTP Message Signatures require created, expires, nonce, and alg parameters');
1147
- }
1148
-
1149
- const createdSeconds = Number(createdMatch[1]);
1150
- const expiresSeconds = Number(expiresMatch[1]);
1151
- const createdMs = createdSeconds * 1000;
1152
- const expiresMs = expiresSeconds * 1000;
1153
- const nowMs = Date.now();
1154
- if (
1155
- !Number.isSafeInteger(createdSeconds) ||
1156
- !Number.isSafeInteger(expiresSeconds) ||
1157
- expiresSeconds <= createdSeconds ||
1158
- createdMs > nowMs + RFC_SIGNATURE_CLOCK_SKEW_MS ||
1159
- createdMs < nowMs - signatureMaxAgeMs ||
1160
- expiresMs < nowMs - RFC_SIGNATURE_CLOCK_SKEW_MS ||
1161
- expiresMs - createdMs > signatureMaxAgeMs
1162
- ) {
1163
- throw new Error('HTTP Message Signature is expired or outside the allowed lifetime');
1164
- }
1165
-
1166
- const rfcNonce = nonceMatch[1];
1167
- if (!/^[\x21-\x7e]{16,256}$/.test(rfcNonce)) {
1168
- throw new Error('HTTP Message Signature nonce must be a printable value of 16 to 256 characters');
1169
- }
1170
-
1171
- const contentDigest = req.headers['content-digest'];
1172
- if (typeof contentDigest !== 'string' || contentDigest !== buildRequestContentDigest(req)) {
1173
- throw new Error('HTTP Message Signature content-digest does not match the request body');
1174
- }
1175
-
1176
- const alg = algMatch[1];
1177
- const rfcReplayKey = `${cacheNamespace}:rfc:${keyid}:${rfcNonce}`;
1178
-
1179
- if (/^https?:\/\//i.test(keyid)) {
1180
- throw new Error("Remote keyid URLs are not accepted; configure a trusted JWKS and use its exact key id");
1181
- }
1182
-
1183
- // v4 (fail closed): RFC 9421 verification requires a merchant-
1184
- // configured trust root. The old third-party default JWKS meant
1185
- // unconfigured merchants accepted an external provider's signing
1186
- // keys for their own write actions.
1187
- const trustedJwks = config.agentJwks;
1188
- const trustedJwksUrl = config.agentJwksUrl;
1189
- if (!trustedJwks && !trustedJwksUrl) {
1190
- return res.status(503).json({
1191
- error: "Forbidden",
1192
- message: "RFC 9421 verification is not configured: set config.agentJwks or config.agentJwksUrl to your agent population's JWKS."
1193
- });
1194
- }
1195
- const cacheKey = `${cacheNamespace}:${trustedJwks ? 'inline' : trustedJwksUrl}:${keyid}`;
1196
- let publicKey = null;
1197
- const cachedEntry = jwksCacheGet(cacheKey);
1198
- if (cachedEntry && cachedEntry.key) {
1199
- publicKey = cachedEntry.key;
1200
- } else if (cachedEntry && cachedEntry.negative) {
1201
- // Second-pass review: negative caching - an unknown keyid must
1202
- // not re-trigger a JWKS fetch on every request.
1203
- throw new Error("Configured JWKS does not contain the requested key id");
1204
- } else {
1205
- let jwks;
1206
- if (trustedJwks) {
1207
- jwks = trustedJwks;
1208
- } else {
1209
- const parsedJwksUrl = new URL(trustedJwksUrl);
1210
- if (parsedJwksUrl.protocol !== 'https:') {
1211
- throw new Error("Configured agentJwksUrl must use HTTPS");
1212
- }
1213
- const jwksRes = await fetch(parsedJwksUrl.href, {
1214
- redirect: 'error',
1215
- signal: AbortSignal.timeout(5000),
1216
- });
1217
- if (!jwksRes.ok) throw new Error(`JWKS request failed with HTTP ${jwksRes.status}`);
1218
- jwks = await jwksRes.json();
1219
- }
1220
-
1221
- if (!Array.isArray(jwks?.keys)) throw new Error("Configured JWKS is invalid");
1222
- const jwk = jwks.keys.find((candidate) => candidate && candidate.kid === keyid);
1223
- if (!jwk) {
1224
- jwksCachePut(cacheKey, { negative: true, expires: Date.now() + JWKS_NEGATIVE_TTL_MS });
1225
- throw new Error("Configured JWKS does not contain the requested key id");
1226
- }
1227
- publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
1228
- jwksCachePut(cacheKey, { key: publicKey, expires: Date.now() + 3600 * 1000 });
1229
- }
1230
-
1231
- // v4 medium pass (V14): pin the declared algorithm family to the
1232
- // resolved key type so an attacker cannot shop algorithms against
1233
- // an unrelated key, and derive the digest correctly - the old code
1234
- // mapped EVERY non-512 alg to SHA-256, silently breaking 384 and
1235
- // rejecting Ed25519 keys outright.
1236
- const keyTypeName = String(publicKey.asymmetricKeyType || '').toLowerCase();
1237
- const algLower = alg.toLowerCase();
1238
- const isEdKey = keyTypeName === 'ed25519' || keyTypeName === 'ed448';
1239
- const algFamilyCompatible =
1240
- (keyTypeName === 'rsa' && algLower.startsWith('rs')) ||
1241
- (keyTypeName === 'ec' && algLower.startsWith('es')) ||
1242
- (isEdKey && algLower.startsWith('ed'));
1243
- if (!algFamilyCompatible) {
1244
- throw new Error(`Signature algorithm ${alg} is not compatible with the configured key type`);
1245
- }
1246
-
1247
- let signatureBase = '';
1248
- for (const comp of components) {
1249
- if (comp === '@method') signatureBase += `"@method": ${req.method.toLowerCase()}\n`;
1250
- else if (comp === '@path') signatureBase += `"@path": ${req.originalUrl || req.path}\n`;
1251
- else if (comp === '@authority') signatureBase += `"@authority": ${req.headers.host}\n`;
1252
- else signatureBase += `"${comp}": ${req.headers[comp] || ''}\n`;
1253
- }
1254
- const sigParams = rfcSignatureInput.replace(/^[a-zA-Z0-9_]+=\s*/, '');
1255
- signatureBase += `"@signature-params": ${sigParams}`;
1256
-
1257
- const sigMatch = rfcSignature.match(/=:([a-zA-Z0-9+/=]+):/);
1258
- const rawSig = sigMatch ? sigMatch[1] : rfcSignature.replace(/^[a-zA-Z0-9_]+=\s*/, '');
1259
- const signatureBuffer = Buffer.from(rawSig, 'base64');
1260
-
1261
- const hashAlg = isEdKey
1262
- ? null // Ed25519/Ed448 sign the message itself, no digest
1263
- : algLower.includes('512') ? 'SHA512' : algLower.includes('384') ? 'SHA384' : 'SHA256';
1264
-
1265
- const isValid = crypto.verify(hashAlg, Buffer.from(signatureBase), publicKey, signatureBuffer);
1266
-
1267
- if (!isValid) return res.status(403).json({ error: "Forbidden", message: "Invalid HTTP Message Signature." });
1268
- // v4: atomic claim - the previous has()-then-set() pair had a
1269
- // race window where two concurrent identical requests could both
1270
- // pass before either recorded the nonce. Exactly one of N
1271
- // concurrent claims now wins.
1272
- const claimed = await signatureNonceMap.claim(rfcReplayKey, expiresMs);
1273
- if (!claimed) {
1274
- return res.status(403).json({
1275
- error: "Forbidden",
1276
- message: "HTTP Message Signature nonce has already been used."
1277
- });
1278
- }
1279
- authenticatedCacheIdentity = `rfc:${keyid}`;
1280
- } catch (err) {
1281
- // v4 medium pass (V13): verification internals (err.message) were
1282
- // previously reflected to the caller. Log server-side, respond
1283
- // with a generic message.
1284
- console.error("🌸 AgentsBloom: RFC 9421 signature verification failed:", err.message);
1285
- return res.status(403).json({ error: "Forbidden", message: "Invalid HTTP Message Signature." });
1286
- }
1287
- } else {
1288
- // v4 medium pass (V11): resolve a PER-AGENT secret when one is
1289
- // configured, and reject revoked identifiers outright.
1290
- if (revokedIdentifiers && typeof identifier === 'string' && revokedIdentifiers.has(identifier)) {
1291
- return res.status(403).json({
1292
- error: "Verification Failed",
1293
- message: "X-Agent-Identifier has been revoked."
1294
- });
1295
- }
1296
- const perAgentSecret = agentKeys && typeof identifier === 'string' ? agentKeys[identifier] : undefined;
1297
- const effectiveSecret = (typeof perAgentSecret === 'string' && perAgentSecret.length > 0)
1298
- ? perAgentSecret
1299
- : agentSecret;
1300
-
1301
- const headerValuePattern = /^[\x21-\x7e]+$/;
1302
- if (
1303
- !effectiveSecret ||
1304
- typeof signature !== 'string' ||
1305
- typeof identifier !== 'string' ||
1306
- typeof timestamp !== 'string' ||
1307
- typeof nonce !== 'string' ||
1308
- identifier.length > 256 ||
1309
- nonce.length < 16 ||
1310
- nonce.length > 256 ||
1311
- !headerValuePattern.test(identifier) ||
1312
- !headerValuePattern.test(timestamp) ||
1313
- !headerValuePattern.test(nonce)
1314
- ) {
1315
- return res.status(403).json({
1316
- error: "Verification Failed",
1317
- message: "X-Agent-Signature requires a configured secret, identifier, timestamp, and nonce."
1318
- });
1319
- }
1320
-
1321
- const timestampSeconds = Number(timestamp);
1322
- const timestampMs = timestampSeconds * 1000;
1323
- const nowMs = Date.now();
1324
- if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowMs - timestampMs) > signatureMaxAgeMs) {
1325
- return res.status(403).json({
1326
- error: "Verification Failed",
1327
- message: "X-Agent-Signature is expired or has an invalid timestamp."
1328
- });
1329
- }
1330
-
1331
- const replayKey = `${cacheNamespace}:legacy:${identifier}:${nonce}`;
1332
-
1333
- let signaturePayload;
1334
- try {
1335
- signaturePayload = buildLegacySignaturePayload(req, identifier, timestamp, nonce);
1336
- } catch {
1337
- return res.status(400).json({
1338
- error: "Invalid Request",
1339
- message: "Request body cannot be serialized for signature verification."
1340
- });
1341
- }
1342
- // Rotation window: a signature minted under the PREVIOUS secret
1343
- // still verifies while merchants roll AGENTSBLOOM_SECRET. Per-agent
1344
- // keys (V11) are checked first; the shared secret(s) remain the
1345
- // fallback so existing agents keep working during migration.
1346
- if (!verifySignature(signature, signaturePayload, effectiveSecret)
1347
- && !(agentSecret && agentSecret !== effectiveSecret && verifySignature(signature, signaturePayload, agentSecret))
1348
- && !(agentSecretPrevious && verifySignature(signature, signaturePayload, agentSecretPrevious))) {
1349
- return res.status(403).json({
1350
- error: "Verification Failed",
1351
- message: "X-Agent-Signature is invalid. Access denied."
1352
- });
1353
- }
1354
-
1355
- // v4: atomic claim AFTER successful verification - failed attempts
1356
- // never burn the nonce, and concurrent replays cannot slip through
1357
- // the old has()/set() race window.
1358
- const claimed = await signatureNonceMap.claim(replayKey, timestampMs + signatureMaxAgeMs);
1359
- if (!claimed) {
1360
- return res.status(403).json({
1361
- error: "Verification Failed",
1362
- message: "X-Agent-Signature nonce has already been used."
1363
- });
1364
- }
1365
- authenticatedCacheIdentity = `legacy:${identifier}`;
1366
- }
1367
- }
1368
-
1369
- // Expose the verified signature identity (rfc:<keyid> or legacy:<id>)
1370
- // to merchant handlers: stores persist it on orders to correlate
1371
- // purchases with the agent-reputation system. Null for unsigned
1372
- // traffic - never trust the raw self-asserted header alone.
1373
- req.agentIdentity = authenticatedCacheIdentity;
1374
-
1375
- // --- 3b. IDEMPOTENCY CHECKS FOR WRITES (POST/PUT/PATCH/DELETE) ---
1376
- // Perform this lookup only after the protected-route authentication gate.
1377
- // Cache keys are scoped to this middleware instance and the verified agent
1378
- // identity so one caller cannot replay another caller's cached response.
1379
- if (isWrite && idempotencyHeader) {
1380
- const requestedIdentity = typeof req.headers['x-agent-identifier'] === 'string'
1381
- ? req.headers['x-agent-identifier']
1382
- : 'anonymous';
1383
- const cacheIdentity = authenticatedCacheIdentity || `anonymous:${requestedIdentity}`;
1384
- let serializedRequestBody;
1385
- try {
1386
- serializedRequestBody = JSON.stringify(req.body ?? null);
1387
- } catch {
1388
- return res.status(400).json({
1389
- error: "Invalid Request",
1390
- message: "Request body cannot be serialized for idempotency verification."
1391
- });
1392
- }
1393
- const cacheKeyMaterial = [
1394
- cacheNamespace,
1395
- cacheIdentity,
1396
- method,
1397
- req.originalUrl || req.path,
1398
- serializedRequestBody,
1399
- idempotencyHeader,
1400
- ].join('\u0000');
1401
- idempotencyKey = `${cacheNamespace}:${crypto.createHash('sha256').update(cacheKeyMaterial).digest('hex')}`;
1402
-
1403
- for (const [key, val] of idempotencyMap.entries()) {
1404
- if (val.expiry < now) {
1405
- idempotencyMap.delete(key);
1406
- }
1407
- }
1408
-
1409
- const cachedResponse = idempotencyMap.get(idempotencyKey);
1410
- if (cachedResponse) {
1411
- console.log('🌸 AgentsBloom: Found cached response for an Idempotency Key');
1412
- res.setHeader('X-Cache', 'Idempotent-Hit');
1413
- res.setHeader('Content-Type', cachedResponse.headers['content-type'] || 'application/json');
1414
- return res.status(cachedResponse.status).send(cachedResponse.responseBody);
1415
- }
1416
- }
1417
-
1418
- // --- 4b. AP2 MANDATE VERIFICATION (Wired into middleware) ---
1419
- // v4: the legacy `authorization` header fallback is now only honored on
1420
- // requests that are ALREADY AP2-shaped (an /ap2 path or x-protocol AP2).
1421
- // Previously ANY request carrying a dotted Authorization value - which
1422
- // includes every ordinary OAuth/JWT bearer token - was parsed as an AP2
1423
- // SD-JWT, failed verification, and had the whole request rejected 403,
1424
- // breaking unrelated authenticated routes. The explicit
1425
- // `x-ap2-mandate` header works everywhere as before.
1426
- const detectedProtocol = resolveProtocol(req);
1427
- const rawAuthorizationHeader = req.headers['authorization'];
1428
- const ap2MandateHeader = req.headers['x-ap2-mandate']
1429
- || (detectedProtocol === 'AP2' && typeof rawAuthorizationHeader === 'string'
1430
- ? rawAuthorizationHeader
1431
- : null);
1432
- let ap2MandateResult = null;
1433
-
1434
- if (detectedProtocol === 'AP2' || (ap2MandateHeader && ap2MandateHeader.includes('.'))) {
1435
- // Second-pass review (N2): the mandate's single-use jti must only be
1436
- // consumed where the mandate is actually USED. Verification still
1437
- // runs wherever a mandate is presented (so merchant handlers keep
1438
- // receiving req.ap2Mandate), but a mandate riding along on an
1439
- // unrelated cart-add no longer burns itself.
1440
- const isAp2Endpoint = req.path.startsWith('/ap2/') || req.path.startsWith('/v1/ap2/');
1441
- // Verify the SD-JWT mandate before allowing any write action. Uses
1442
- // either a merchant-configured trusted public key (config.ap2PublicKey)
1443
- // or, when absent, derives a key from the mandate's own did:key issuer
1444
- // (self-certifying - see lib/ap2.js for why an unverifiable mandate is
1445
- // now rejected outright rather than passed through as "valid").
1446
- ap2MandateResult = await verifyAP2Mandates(req.headers, req.body || {}, {
1447
- trustedPublicKey: config.ap2PublicKey || null,
1448
- // v4: production-grade trust policy. When ap2.trustedIssuersOnly is
1449
- // set, self-certifying did:key mandates (which anyone can mint for
1450
- // any budget) are rejected in favor of the merchant-trusted key.
1451
- allowSelfCertifying: config.ap2?.trustedIssuersOnly !== true,
1452
- // Audience binding prefers explicit configuration, then the
1453
- // merchant-configured baseUrl (a trusted deployment constant), and
1454
- // only falls back to the Host-derived requestUrl when neither is
1455
- // set - deriving the expected audience purely from the request's
1456
- // own Host header would let an attacker align a stolen mandate's
1457
- // audience with a spoofed Host.
1458
- expectedAudience: config.ap2?.expectedAudience || (baseUrl ? baseUrl : requestUrl),
1459
- maxMandateLifetimeSec: config.ap2?.maxMandateLifetimeSec,
1460
- requireJti: config.ap2?.requireJti,
1461
- requestedCategories: req.body?.requestedCategories || config.ap2?.requestedCategories,
1462
- expectedCurrency: config.ap2?.expectedCurrency,
1463
- consumeJti: isAp2Endpoint,
1464
- });
1465
-
1466
- if (!ap2MandateResult.valid) {
1467
- return res.status(403).json({
1468
- error: "AP2 Mandate Rejected",
1469
- protocol: "AP2",
1470
- reason: ap2MandateResult.reason,
1471
- message: "The Verifiable Intent mandate failed validation. The agent's payment authorization is invalid."
1472
- });
1473
- }
1474
-
1475
- // Attach mandate info to request for downstream handlers
1476
- req.ap2Mandate = ap2MandateResult;
1477
- }
1478
-
1479
- // --- AP2 Discovery Endpoint ---
1480
- if (req.path === '/ap2/capabilities' || req.path === '/v1/ap2/capabilities') {
1481
- return res.json({
1482
- protocol: "AP2",
1483
- version: "1.0.0",
1484
- store: { name, description, baseUrl: requestUrl },
1485
- mandateTypes: ["intentMandate", "cartMandate", "paymentMandate"],
1486
- verificationMethods: ["sd-jwt", "jwt"],
1487
- endpoints: {
1488
- capabilities: `${requestUrl}/ap2/capabilities`,
1489
- intent: `${requestUrl}/ap2/intent`,
1490
- checkout: `${requestUrl}/ap2/checkout`,
1491
- actions: Object.keys(actions).map(k => `${requestUrl}/v1/agent/actions/${k}`)
1492
- },
1493
- budgetEnforcement: true,
1494
- signatureAlgorithms: ["ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA"]
1495
- });
1496
- }
1497
-
1498
- // --- AP2 Intent Endpoint (agent announces what it wants to do) ---
1499
- if ((req.path === '/ap2/intent' || req.path === '/v1/ap2/intent') && req.method === 'POST') {
1500
- // Must check `.verified`, not mere truthiness: ap2MandateResult is a
1501
- // truthy object even when no mandate header was ever presented
1502
- // (`{ valid: true, verified: false, note: '...' }`), so a bare
1503
- // `if (!ap2MandateResult)` check would let unauthenticated requests
1504
- // reach this mandate-gated endpoint.
1505
- if (!ap2MandateResult?.verified) {
1506
- return res.status(401).json({
1507
- error: "AP2 Mandate Required",
1508
- message: "Send x-ap2-mandate: Bearer <sd-jwt> header with a valid Intent Mandate."
1509
- });
1510
- }
1511
- return res.json({
1512
- protocol: "AP2",
1513
- intentAccepted: true,
1514
- mandateVerified: ap2MandateResult.verified,
1515
- mandates: ap2MandateResult.mandates || {},
1516
- availableActions: Object.entries(actions).map(([key, val]) => ({
1517
- action: key,
1518
- endpoint: `/v1/agent/actions/${key}`,
1519
- method: val.method || 'POST',
1520
- description: val.description
1521
- })),
1522
- budgetRemaining: ap2MandateResult.mandates?.intentMandate?.maxBudget || "unlimited"
1523
- });
1524
- }
1525
-
1526
- // --- AP2 Checkout Endpoint (mandate-gated checkout) ---
1527
- if ((req.path === '/ap2/checkout' || req.path === '/v1/ap2/checkout') && req.method === 'POST') {
1528
- // Same fix as /ap2/intent above: this MUST require a genuinely
1529
- // verified mandate, not just a truthy result object. Previously,
1530
- // hitting /ap2/checkout with no x-ap2-mandate header at all still
1531
- // produced a truthy `ap2MandateResult` (valid:true, verified:false),
1532
- // which passed this check, then found no `.mandates.intentMandate`
1533
- // to read a maxBudget from - so checkout proceeded with ZERO budget
1534
- // enforcement. Requiring `.verified` closes that bypass.
1535
- if (!ap2MandateResult?.verified) {
1536
- return res.status(401).json({
1537
- error: "AP2 Payment Mandate Required",
1538
- message: "AP2 checkout requires x-ap2-mandate header with a valid Payment Mandate."
1539
- });
1540
- }
1541
-
1542
- const checkoutAction = actions['checkout'];
1543
- if (!checkoutAction || typeof checkoutAction.handler !== 'function') {
1544
- return res.status(404).json({ error: 'Checkout action not configured for this store.' });
1545
- }
1546
-
1547
- // v4 medium pass (V12): mandate verification proves budget intent,
1548
- // not identity authorization - the checkout action still respects
1549
- // the merchant's per-action policy.
1550
- const checkoutAccessRejection = authorizeAction('checkout', authenticatedCacheIdentity);
1551
- if (checkoutAccessRejection) {
1552
- return res.status(checkoutAccessRejection.status).json(checkoutAccessRejection.body);
1553
- }
1554
-
1555
- // Validate budget before executing checkout. A checkout-time mandate
1556
- // MUST declare a positive maxBudget: a capless mandate would
1557
- // authorize unlimited spend (previously it produced
1558
- // budgetEnforced:false and the checkout proceeded at any total).
1559
- const orderTotal = req.body?.orderTotal || req.body?.total || 0;
1560
- const maxBudget = ap2MandateResult.mandates?.intentMandate?.maxBudget
1561
- || ap2MandateResult.mandates?.paymentMandate?.maxBudget;
1562
-
1563
- if (!Number.isFinite(Number(maxBudget)) || Number(maxBudget) <= 0) {
1564
- return res.status(403).json({
1565
- error: "AP2 Mandate Rejected",
1566
- protocol: "AP2",
1567
- reason: "Mandate must declare a positive intentMandate.maxBudget for checkout",
1568
- message: "A checkout mandate without a spending cap authorizes unlimited spend and is rejected. Reissue the mandate with maxBudget."
1569
- });
1570
- }
1571
-
1572
- // v4 medium pass (V17): a budget number without a matching currency
1573
- // is unitless - reject unit-mismatch attempts before any handler run.
1574
- const mandateCurrency = ap2MandateResult.mandates?.intentMandate?.currency
1575
- || ap2MandateResult.mandates?.paymentMandate?.currency;
1576
- const normalizeCurrency = (value) => String(value || '').trim().toUpperCase();
1577
- const expectedCheckoutCurrency = config.ap2?.expectedCurrency;
1578
- if (
1579
- mandateCurrency && expectedCheckoutCurrency &&
1580
- normalizeCurrency(mandateCurrency) !== normalizeCurrency(expectedCheckoutCurrency)
1581
- ) {
1582
- return res.status(403).json({
1583
- error: "AP2 Mandate Rejected",
1584
- protocol: "AP2",
1585
- reason: `Mandate currency "${mandateCurrency}" does not match this store's expected currency "${expectedCheckoutCurrency}"`,
1586
- message: "The mandate was issued for a different currency than this store accepts."
1587
- });
1588
- }
1589
- if (
1590
- mandateCurrency && req.body?.currency &&
1591
- normalizeCurrency(mandateCurrency) !== normalizeCurrency(req.body.currency)
1592
- ) {
1593
- return res.status(403).json({
1594
- error: "AP2 Budget Exceeded",
1595
- protocol: "AP2",
1596
- reason: `Order currency "${req.body.currency}" does not match the mandate's currency "${mandateCurrency}"`,
1597
- message: "Order currency does not match the mandate's declared currency."
1598
- });
1599
- }
1600
-
1601
- if (Number(orderTotal) > Number(maxBudget)) {
1602
- return res.status(403).json({
1603
- error: "AP2 Budget Exceeded",
1604
- protocol: "AP2",
1605
- orderTotal: Number(orderTotal),
1606
- maxBudget: Number(maxBudget),
1607
- message: `Order total $${orderTotal} exceeds mandate budget limit of $${maxBudget}.`
1608
- });
1609
- }
1610
-
1611
- // v4 (budget bypass fix): the client-declared orderTotal above is a
1612
- // fast-fail convenience only - it is attacker-controlled and can
1613
- // never be the basis of authorization. The AUTHORITATIVE check runs
1614
- // after the merchant handler returns, against the handler-computed
1615
- // total (result.total / result.totalPrice). If the real cart total
1616
- // exceeds the mandate budget, no payment URL is returned.
1617
- const issuerTrust = ap2MandateResult.selfCertifying ? 'self-certifying' : 'merchant-trusted';
1618
-
1619
- return Promise.resolve(checkoutAction.handler(req.body, req, res))
1620
- .then(result => {
1621
- const serverTotal = result && (result.total ?? result.totalPrice);
1622
- const effectiveTotal = serverTotal !== undefined ? Number(serverTotal) : Number(orderTotal);
1623
- if (Number.isFinite(effectiveTotal) && effectiveTotal > Number(maxBudget)) {
1624
- return res.status(403).json({
1625
- error: "AP2 Budget Exceeded",
1626
- protocol: "AP2",
1627
- reason: "Server-computed order total exceeds the mandate's maxBudget; the client-declared orderTotal is never authoritative.",
1628
- orderTotal: effectiveTotal,
1629
- totalSource: serverTotal !== undefined ? 'handler' : 'client-declared',
1630
- maxBudget: Number(maxBudget),
1631
- message: `Order total $${effectiveTotal} computed by the store exceeds mandate budget limit of $${maxBudget}. No checkout link was issued.`
1632
- });
1633
- }
1634
- if (!res.headersSent) {
1635
- res.json({
1636
- protocol: "AP2",
1637
- verifiableIntent: {
1638
- mandateVerified: ap2MandateResult.verified,
1639
- budgetEnforced: !!maxBudget,
1640
- issuerTrust,
1641
- maxBudget: maxBudget || null,
1642
- orderTotal: result.total || orderTotal,
1643
- ...(serverTotal !== undefined ? { totalSource: 'handler' } : {})
1644
- },
1645
- session_id: result.sessionId || `ap2_sess_${Date.now()}`,
1646
- payment_url: result.paymentUrl,
1647
- status: "authorized",
1648
- expires_at: Math.floor(Date.now() / 1000) + 3600,
1649
- checkout: result
1650
- });
1651
- }
1652
- })
1653
- .catch(err => {
1654
- console.error("AP2 Checkout error:", err);
1655
- if (!res.headersSent) {
1656
- res.status(500).json({ error: 'Internal AP2 checkout error', protocol: 'AP2' });
1657
- }
1658
- });
1659
- }
1660
-
1661
- // --- 5. HANDLE ACTION ROUTING ---
1662
- // Agentic Commerce Protocol (ACP) Checkout Wrapper
1663
- if (req.path === '/api/agentsbloom/checkout/acp' && req.method === 'POST') {
1664
- const checkoutAction = actions['checkout'];
1665
- if (checkoutAction && typeof checkoutAction.handler === 'function') {
1666
- return Promise.resolve(checkoutAction.handler(req.body, req, res))
1667
- .then(result => {
1668
- if (!res.headersSent) {
1669
- res.json({
1670
- session_id: result.sessionId || `acp_sess_${Date.now()}`,
1671
- payment_url: result.paymentUrl,
1672
- status: "open",
1673
- expires_at: Math.floor(Date.now() / 1000) + 3600
1674
- });
1675
- }
1676
- })
1677
- .catch(err => {
1678
- console.error(`ACP Checkout execution error:`, err);
1679
- if (!res.headersSent) {
1680
- res.status(500).json({ error: 'Internal ACP checkout error' });
1681
- }
1682
- });
1683
- } else {
1684
- return res.status(404).json({ error: 'ACP Checkout not configured for this store.' });
1685
- }
1686
- }
1687
-
1688
- if (req.path.startsWith('/api/agentsbloom/') || req.path.startsWith('/v1/agent/actions/')) {
1689
- const actionName = requestedActionName;
1690
- const action = actions[actionName];
1691
-
1692
- if (action && typeof action.handler === 'function') {
1693
- const configuredMethod = String(action.method || 'POST').toUpperCase();
1694
- if (method !== configuredMethod) {
1695
- res.setHeader('Allow', configuredMethod);
1696
- return res.status(405).json({
1697
- error: "Method Not Allowed",
1698
- message: `Action ${actionName} only accepts ${configuredMethod} requests.`,
1699
- allowedMethod: configuredMethod,
1700
- });
1701
- }
1702
-
1703
- // v4 medium pass (V12): per-action authorization - the SDK had no
1704
- // authorization model at all; any verified identity could invoke
1705
- // any write action including checkout.
1706
- const accessRejection = authorizeAction(actionName, authenticatedCacheIdentity);
1707
- if (accessRejection) {
1708
- return res.status(accessRejection.status).json(accessRejection.body);
1709
- }
1710
-
1711
- const params = method === 'GET' ? { ...req.query } : { ...req.body };
1712
-
1713
- // v4: shared with the MCP tools/call path so both surfaces enforce
1714
- // the same declared parameter contract.
1715
- const validation = validateActionParams(params, action);
1716
- if (!validation.ok) {
1717
- return res.status(400).json({ error: "Invalid Parameter", message: validation.message });
1718
- }
1719
- const validatedParams = validation.params;
1720
-
1721
- // Cache original res.send to support idempotency key caching
1722
- const originalSend = res.send;
1723
- res.send = function (body) {
1724
- if (isWrite && idempotencyKey && res.statusCode >= 200 && res.statusCode < 300) {
1725
- // v4 (V18): evict the oldest entry at capacity instead of
1726
- // growing without bound between TTL sweeps.
1727
- if (!idempotencyMap.has(idempotencyKey) && idempotencyMap.size >= IDEMPOTENCY_MAX_ENTRIES) {
1728
- const oldestKey = idempotencyMap.keys().next().value;
1729
- if (oldestKey !== undefined) idempotencyMap.delete(oldestKey);
1730
- }
1731
- idempotencyMap.set(idempotencyKey, {
1732
- responseBody: body,
1733
- status: res.statusCode,
1734
- headers: { 'content-type': res.getHeader('content-type') },
1735
- timestamp: Date.now(),
1736
- expiry: Date.now() + IDEMPOTENCY_TTL
1737
- });
1738
- }
1739
- return originalSend.apply(this, arguments);
1740
- };
1741
-
1742
- const startTime = Date.now();
1743
- const agentName = req.headers['x-agent-identifier'] || req.headers['signature-agent'] || 'Unknown Agent';
1744
-
1745
- // Extract W3C Trace Context from incoming request (HIGH-16)
1746
- const parentContext = propagation.extract(context.active(), req.headers);
1747
- const span = tracer.startSpan(`agent_request:${actionName}`, {}, parentContext);
1748
-
1749
- return Promise.resolve(action.handler(validatedParams, req, res))
1750
- .then(result => {
1751
- if (!res.headersSent) {
1752
- res.json(result);
1753
- }
1754
-
1755
- const latencyMs = Date.now() - startTime;
1756
- const revenue = result && result.totalPrice ? result.totalPrice : 0;
1757
-
1758
- // OpenTelemetry Native Instrumentation
1759
- span.setAttribute('agent.name', agentName);
1760
- span.setAttribute('agent.route', `/api/agentsbloom/${actionName}`);
1761
- span.setAttribute('http.status_code', res.statusCode);
1762
- span.setAttribute('http.latency_ms', latencyMs);
1763
-
1764
- agentRequestsCounter.add(1, { agent: agentName });
1765
- if (revenue) agentRevenueCounter.add(revenue, { agent: agentName });
1766
- span.end();
1767
-
1768
- // Telemetry is handled by OTLP exporters configured via setupTelemetry().
1769
- // The span.end() call above will auto-export to the OTLP collector.
1770
- })
1771
- .catch(err => {
1772
- span.setAttribute('error', true);
1773
- span.end();
1774
- console.error(`AgentsBloom action execution error (${actionName}):`, err);
1775
- if (!res.headersSent) {
1776
- res.status(500).json({ error: 'Internal agent endpoint error' });
1777
- }
1778
- });
1779
- } else {
1780
- return res.status(404).json({ error: `Unknown AgentsBloom action.` });
1781
- }
1782
- }
1783
-
1784
- // --- 6. HTML INJECTION WITH COMPRESSION SUPPORT ---
1785
- // Opt-out via config.disableHtmlInjection (MED-12)
1786
- if (config.disableHtmlInjection) {
1787
- return next();
1788
- }
1789
-
1790
- const originalWrite = res.write;
1791
- const originalEnd = res.end;
1792
- let chunks = [];
1793
- let isHtml = false;
1794
-
1795
- const originalWriteHead = res.writeHead;
1796
- res.writeHead = function (statusCode, headers) {
1797
- const contentType = res.getHeader('Content-Type') || (headers && headers['content-type']) || '';
1798
- if (typeof contentType === 'string' && contentType.includes('text/html')) {
1799
- isHtml = true;
1800
- res.removeHeader('Content-Length');
1801
- if (headers) delete headers['content-length'];
1802
- }
1803
- return originalWriteHead.apply(this, arguments);
1804
- };
1805
-
1806
- res.write = function (chunk) {
1807
- const contentType = res.getHeader('Content-Type') || '';
1808
- if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
1809
- isHtml = true;
1810
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
1811
- return true;
1812
- }
1813
- return originalWrite.apply(res, arguments);
1814
- };
1815
-
1816
- res.end = function (chunk) {
1817
- const contentType = res.getHeader('Content-Type') || '';
1818
- if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
1819
- isHtml = true;
1820
- if (chunk) {
1821
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
1822
- }
1823
-
1824
- let bodyBuffer = Buffer.concat(chunks);
1825
- const encoding = res.getHeader('Content-Encoding');
1826
- const isGzipped = typeof encoding === 'string' && encoding.includes('gzip');
1827
-
1828
- // Decompress if gzipped
1829
- if (isGzipped) {
1830
- try {
1831
- bodyBuffer = zlib.gunzipSync(bodyBuffer);
1832
- } catch (err) {
1833
- console.error("AgentsBloom decompression error:", err);
1834
- }
1835
- }
1836
-
1837
- let body = bodyBuffer.toString('utf8');
1838
-
1839
- if (body.toLowerCase().includes('</body>')) {
1840
- // v4 (stored-XSS fix): merchant-controlled strings (store name,
1841
- // description, action names/descriptions) are no longer spliced
1842
- // into inline JavaScript or raw JSON.stringify output. JSON.stringify
1843
- // does NOT escape `</script>`, so a crafted description used to be
1844
- // able to break out of the script tag and execute on every page.
1845
- // All dynamic data is now serialized through toSafeJson() (hex-
1846
- // escaped `<`, `>`, `&`, U+2028/2029) and consumed as DATA by a
1847
- // static loader - the same approach as the hardened next-sdk.
1848
- const jsonLdData = {
1849
- "@context": "https://schema.org",
1850
- "@type": "WebPage",
1851
- "name": name,
1852
- "description": description,
1853
- "potentialAction": Object.entries(actions).map(([key]) => ({
1854
- "@type": "SearchAction",
1855
- "name": key,
1856
- "target": `${requestUrl}/api/agentsbloom/${key}`
1857
- }))
1858
- };
1859
-
1860
- const jsonLdScript = `\n<script type="application/ld+json">\n${toSafeJson(jsonLdData)}\n</script>`;
1861
-
1862
- const webMcpTools = Object.entries(actions).map(([key, val]) => ({
1863
- name: key,
1864
- description: val.description || '',
1865
- method: String(val.method || 'POST').toUpperCase(),
1866
- inputSchema: {
1867
- type: 'object',
1868
- properties: Object.fromEntries(
1869
- Object.entries(val.params || {}).map(([pkey, pval]) => [pkey, { type: pval }])
1870
- ),
1871
- },
1872
- }));
1873
- const webMcpScript = `
1874
- <meta name="webmcp" content="active">
1875
- <script>
1876
- // Auto-generated WebMCP Declarative Actions by AgentsBloom
1877
- (function() {
1878
- if (typeof navigator === 'undefined' || !navigator.ai || typeof navigator.ai.registerTool !== 'function') return;
1879
- var tools = ${toSafeJson(webMcpTools)};
1880
- for (var i = 0; i < tools.length; i++) {
1881
- (function(tool) {
1882
- navigator.ai.registerTool({
1883
- name: tool.name,
1884
- description: tool.description,
1885
- inputSchema: tool.inputSchema,
1886
- handler: async function(args) {
1887
- try {
1888
- var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1889
- var options = { method: tool.method, headers: headers };
1890
- var url = '/api/agentsbloom/' + encodeURIComponent(tool.name);
1891
- if (tool.method === 'GET') {
1892
- var queryParams = new URLSearchParams(args).toString();
1893
- if (queryParams) url += '?' + queryParams;
1894
- } else {
1895
- options.body = JSON.stringify(args);
1896
- }
1897
- var response = await fetch(url, options);
1898
- return await response.json();
1899
- } catch (e) {
1900
- return { error: e.message };
1901
- }
1902
- }
1903
- });
1904
- })(tools[i]);
1905
- }
1906
- })();
1907
- </script>
1908
- `;
1909
-
1910
- if (body.toLowerCase().includes('</head>')) {
1911
- body = body.replace(/<\/head>/i, `${jsonLdScript}\n</head>`);
1912
- } else {
1913
- body = body + jsonLdScript;
1914
- }
1915
-
1916
- body = body.replace(/<\/body>/i, `${webMcpScript}\n</body>`);
1917
- }
1918
-
1919
- let outputBuffer = Buffer.from(body);
1920
-
1921
- // Re-compress if gzipped
1922
- if (isGzipped) {
1923
- try {
1924
- outputBuffer = zlib.gzipSync(outputBuffer);
1925
- } catch (err) {
1926
- console.error("AgentsBloom compression error:", err);
1927
- }
1928
- }
1929
-
1930
- res.setHeader('Content-Length', outputBuffer.length);
1931
- originalWrite.call(res, outputBuffer);
1932
- return originalEnd.call(res);
1933
- }
1934
- return originalEnd.apply(res, arguments);
1935
- };
1936
-
1937
- next();
1938
- };
1939
- }
1940
-
1941
- // --- UNIFIED PROTOCOL ROUTER ---
1942
- export function resolveProtocol(req) {
1943
- const accept = (req.headers && req.headers['accept']) || '';
1944
- const xProtocol = (req.headers && req.headers['x-protocol']) || '';
1945
- const path = req.path || req.url || '';
1946
-
1947
- if (accept.includes('application/mcp+json') || path.startsWith('/mcp')) {
1948
- return 'WEBMCP';
1949
- }
1950
- if (xProtocol.toLowerCase() === 'ucp' || path.startsWith('/.well-known/ucp') || path.startsWith('/ucp')) {
1951
- return 'UCP';
1952
- }
1953
- if (xProtocol.toLowerCase() === 'acp' || path.startsWith('/acp')) {
1954
- return 'ACP';
1955
- }
1956
- if (req.headers && (req.headers['x-ap2-mandate'] || path.startsWith('/ap2'))) {
1957
- return 'AP2';
1958
- }
1959
- return 'AGENTSBLOOM_REST';
1960
- }
1961
-
1962
- // --- AP2 (AGENT PAYMENTS PROTOCOL) SD-JWT MANDATE VERIFIER ---
1963
- //
1964
- // Thin, backward-compatible wrapper over the hardened implementation in
1965
- // lib/ap2.js. The original signature `verifyAP2Mandates(headers, body,
1966
- // publicKey)` treated an unsigned/unverifiable mandate as "valid but
1967
- // unverified" and let downstream code (and, worse, the /ap2/checkout gate
1968
- // itself - see the truthiness-check fix above) treat that as authorization
1969
- // to check out. lib/ap2.js's verifyAp2Mandate instead REJECTS any mandate
1970
- // it cannot cryptographically verify.
1971
- //
1972
- // Both call shapes are supported for backward compatibility:
1973
- // verifyAP2Mandates(headers, body, publicKey) // legacy
1974
- // verifyAP2Mandates(headers, body, { trustedPublicKey, expectedAudience, ... }) // current
1975
- export function verifyAP2Mandates(headers = {}, body = {}, publicKeyOrOptions = null) {
1976
- let options;
1977
- if (publicKeyOrOptions === null || publicKeyOrOptions === undefined) {
1978
- options = {};
1979
- } else if (
1980
- publicKeyOrOptions instanceof crypto.KeyObject ||
1981
- Buffer.isBuffer(publicKeyOrOptions) ||
1982
- typeof publicKeyOrOptions === 'string'
1983
- ) {
1984
- // Legacy call shape: third argument is a raw public key.
1985
- options = { trustedPublicKey: publicKeyOrOptions };
1986
- } else {
1987
- // Current call shape: third argument is an options object.
1988
- options = publicKeyOrOptions;
1989
- }
1990
- return verifyAp2Mandate(headers, body, options);
1991
- }
1992
-
25
+ import { createOutcomeReporter, stripeEventToOutcome } from './lib/outcomes.js';
26
+ import {
27
+ verifyHttpMessageSignature,
28
+ createJwksCache,
29
+ isBlockedSsrfHostname,
30
+ SUPPORTED_SIGNATURE_ALGORITHMS,
31
+ STRICT_REQUIRED_COMPONENTS,
32
+ } from './lib/http-signatures.js';
33
+ import {
34
+ requestContextFromExpress,
35
+ normalizeAuthority as normalizeAuthorityForScheme,
36
+ PROFILE_STRICT,
37
+ } from './lib/signature-base.js';
38
+ import { compareAmounts, normalizeCurrencyCode, parseAmount } from './lib/money.js';
39
+
40
+ const tracer = trace.getTracer('agentsbloom-sdk');
41
+ const meter = metrics.getMeter('agentsbloom-sdk');
42
+
43
+ const agentRequestsCounter = meter.createCounter('agent_visits_total', { description: 'Total AI Visits' });
44
+ const agentRevenueCounter = meter.createCounter('agent_revenue_usd', { description: 'Total AI Revenue', valueType: ValueType.DOUBLE });
45
+
46
+ /**
47
+ * Initialize OpenTelemetry with OTLP exporters.
48
+ * Call this BEFORE using the agentsbloom() middleware.
49
+ *
50
+ * @param {Object} options
51
+ * @param {string} options.otlpEndpoint - OTLP collector URL (default: http://localhost:4318)
52
+ * @param {string} options.serviceName - Service name for traces (default: agentsbloom-merchant)
53
+ * @param {number} options.samplingRatio - Trace sampling ratio 0.0-1.0 (default: 1.0)
54
+ * @param {string} options.apiKey - API key for authenticating with the collector
55
+ */
56
+ export async function setupTelemetry(options = {}) {
57
+ const {
58
+ otlpEndpoint = process.env.AGENTSBLOOM_OTEL_ENDPOINT || 'http://localhost:4318',
59
+ serviceName = 'agentsbloom-merchant',
60
+ samplingRatio = parseFloat(process.env.AGENTSBLOOM_SAMPLING_RATIO || '1.0'),
61
+ apiKey = process.env.AGENTSBLOOM_API_KEY || '',
62
+ } = options;
63
+
64
+ // Refuse to ship the collector credential over cleartext. The default
65
+ // endpoint is a local collector, which is fine; anything remote must be TLS.
66
+ let endpointIsLocal = false;
67
+ try {
68
+ const parsed = new URL(otlpEndpoint);
69
+ endpointIsLocal = parsed.hostname === 'localhost'
70
+ || parsed.hostname === '127.0.0.1'
71
+ || parsed.hostname === '::1'
72
+ || parsed.hostname === '[::1]';
73
+ if (apiKey && parsed.protocol !== 'https:' && !endpointIsLocal) {
74
+ throw new Error(
75
+ `refusing to send the telemetry API key to ${parsed.origin} over ${parsed.protocol.replace(':', '')}; use https`,
76
+ );
77
+ }
78
+ } catch (err) {
79
+ if (err instanceof TypeError) {
80
+ throw new Error(`AgentsBloom: otlpEndpoint is not a valid URL: ${otlpEndpoint}`);
81
+ }
82
+ throw err;
83
+ }
84
+
85
+ // Store NON-SECRET config for lazy initialization when the OTel SDK packages
86
+ // are available. The apiKey used to be parked on globalThis alongside it,
87
+ // where any code in the process (including a compromised transitive
88
+ // dependency) could read the merchant's collector credential.
89
+ globalThis.__agentsbloom_otel_config = { otlpEndpoint, serviceName, samplingRatio };
90
+
91
+ const handle = await initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey });
92
+ globalThis.__agentsbloom_otel_handle = handle;
93
+
94
+ const effectiveRatio = Number.isFinite(samplingRatio) ? Math.min(1, Math.max(0, samplingRatio)) : 1;
95
+ console.log(`🌸 AgentsBloom: Telemetry configured (sampling: ${(effectiveRatio * 100).toFixed(0)}%)`);
96
+ return { otlpEndpoint, serviceName, samplingRatio: effectiveRatio };
97
+ }
98
+ // v4 security hardening: there is deliberately NO default RFC 9421 JWKS
99
+ // source anymore. The previous default pointed at a third-party provider's
100
+ // live key set, which silently accepted that provider's signing keys for
101
+ // MERCHANT write actions whenever a merchant forgot to configure their own
102
+ // trust root. Verification now fails closed until the merchant configures
103
+ // `agentJwks` (inline JWKS object) or `agentJwksUrl` (HTTPS JWKS endpoint).
104
+ const LEGACY_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
105
+ const RFC_SIGNATURE_CLOCK_SKEW_MS = 30 * 1000;
106
+ const SIGNATURE_REPLAY_CACHE_MAX_SIZE = 50_000;
107
+ const MAX_RATE_TRACKED_CLIENTS = 50_000;
108
+
109
+ /**
110
+ * Bounded, swept, negatively-cached JWKS store shared by every middleware
111
+ * instance in the process. The cache and the SSRF guard now live in
112
+ * lib/http-signatures.js alongside the verification that uses them, so there
113
+ * is exactly one implementation of each rule instead of one per call site.
114
+ */
115
+ const jwksCache = createJwksCache();
116
+
117
+ /**
118
+ * Caps the cardinality of a value used as a telemetry label.
119
+ *
120
+ * Agent-supplied headers were passed straight through as OTel metric
121
+ * dimensions and span attributes, so a caller could mint unbounded
122
+ * time series inside the merchant's own monitoring pipeline.
123
+ */
124
+ const MAX_LABEL_LENGTH = 64;
125
+ function boundedLabel(value, fallback = 'unknown') {
126
+ if (typeof value !== 'string' || value.length === 0) return fallback;
127
+ const cleaned = value.replace(/[^\x20-\x7e]/g, '');
128
+ if (cleaned.length === 0) return fallback;
129
+ if (cleaned.length <= MAX_LABEL_LENGTH) return cleaned;
130
+ // Keep a stable, readable prefix plus a digest so distinct long values stay
131
+ // distinguishable without being unbounded.
132
+ const digest = crypto.createHash('sha256').update(cleaned).digest('base64url').slice(0, 8);
133
+ return `${cleaned.slice(0, MAX_LABEL_LENGTH - 9)}~${digest}`;
134
+ }
135
+
136
+ /** Escapes JSON for embedding inside an HTML <script> element. */
137
+ function toSafeJson(value) {
138
+ return JSON.stringify(value)
139
+ .replace(/</g, '\\u003c')
140
+ .replace(/>/g, '\\u003e')
141
+ .replace(/&/g, '\\u0026')
142
+ .replace(/\u2028/g, '\\u2028')
143
+ .replace(/\u2029/g, '\\u2029');
144
+ }
145
+
146
+ /**
147
+ * Normalizes a client key for rate limiting: strips the IPv6-mapped IPv4
148
+ * prefix (::ffff:127.0.0.1 -> 127.0.0.1) so the same client cannot get a
149
+ * fresh bucket by alternating address representations.
150
+ */
151
+ function normalizeRateLimitKey(ip) {
152
+ const raw = String(ip || '');
153
+ return raw.startsWith('::ffff:') ? raw.slice(7) : raw;
154
+ }
155
+
156
+ function verifySignature(signature, payload, secret) {
157
+ try {
158
+ if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/i.test(signature)) return false;
159
+ const computed = crypto.createHmac('sha256', secret).update(payload).digest();
160
+ const provided = Buffer.from(signature, 'hex');
161
+ return crypto.timingSafeEqual(computed, provided);
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ function buildLegacySignaturePayload(req, identifier, timestamp, nonce) {
168
+ return JSON.stringify([
169
+ identifier,
170
+ String(req.method || '').toUpperCase(),
171
+ req.originalUrl || req.path,
172
+ timestamp,
173
+ nonce,
174
+ req.body ?? null,
175
+ ]);
176
+ }
177
+
178
+ // Content-Digest computation and verification moved to lib/signature-base.js.
179
+ //
180
+ // The old helper here silently fell back to `Buffer.from(JSON.stringify(req.body))`
181
+ // when `req.rawBody` was absent, which turned "this digest proves what the
182
+ // client sent" into "this digest matches our re-serialization of what our
183
+ // parser produced". An agent computing its digest the same way satisfied it
184
+ // even when the bytes on the wire differed. `resolveBodyBytes` now requires
185
+ // the real bytes and fails closed without them.
186
+
187
+ /**
188
+ * v4: validates/coerces action parameters against the action's declared
189
+ * `params` type map. Shared by the REST action route AND the MCP tools/call
190
+ * handler - previously the MCP path passed arguments straight through with
191
+ * no validation at all, so the type guarantees advertised in discovery
192
+ * documents were not actually enforced there.
193
+ *
194
+ * @returns {{ ok: boolean, params?: object, message?: string }}
195
+ */
196
+ function validateActionParams(rawParams, action) {
197
+ const params = { ...rawParams };
198
+ if (!action.params) return { ok: true, params };
199
+ for (const [key, type] of Object.entries(action.params)) {
200
+ const val = params[key];
201
+ if (val === undefined) continue;
202
+ if (type === 'any') continue;
203
+ if (type === 'number') {
204
+ if (typeof val === 'number' && !isNaN(val)) {
205
+ // valid
206
+ } else if (typeof val === 'string' && val.trim() !== '' && !isNaN(Number(val))) {
207
+ params[key] = Number(val);
208
+ } else {
209
+ return { ok: false, message: `Expected ${type} for parameter ${key}` };
210
+ }
211
+ } else if (type === 'boolean') {
212
+ if (typeof val === 'boolean') {
213
+ // valid
214
+ } else if (typeof val === 'string' && (val === 'true' || val === 'false' || val === '1' || val === '0')) {
215
+ params[key] = val === 'true' || val === '1';
216
+ } else {
217
+ return { ok: false, message: `Expected ${type} for parameter ${key}` };
218
+ }
219
+ } else if (type === 'string') {
220
+ if (typeof val !== 'string') {
221
+ return { ok: false, message: `Expected ${type} for parameter ${key}` };
222
+ }
223
+ } else if (typeof val !== type && !(typeof type === 'string' && type.includes(typeof val))) {
224
+ return { ok: false, message: `Expected ${type} for parameter ${key}` };
225
+ }
226
+ }
227
+ return { ok: true, params };
228
+ }
229
+
230
+ // In-memory rate limiting map
231
+ const rateLimitMap = new Map();
232
+ // v4: bounded count of live MCP SSE sessions (see /mcp handler).
233
+ let activeMcpSessions = 0;
234
+ /**
235
+ * Live MCP SSE transports by session id, so `POST /mcp/messages` can be routed
236
+ * to the stream that opened it. Bounded implicitly by `mcp.maxSessions`;
237
+ * entries are deleted when the SSE response closes.
238
+ */
239
+ const mcpTransports = new Map();
240
+
241
+ // --- v4 medium pass (V15): the SaaS quota gate is now WIRED ---
242
+ // Previously `quotaExceededUntil` was a per-instance variable initialized
243
+ // to 0 that nothing ever set - dead code implying enforcement that did not
244
+ // exist. The hosted gateway (or any operator tooling) can now trip every
245
+ // middleware instance in the process via setQuotaExceededUntil(), or set
246
+ // AGENTSBLOOM_QUOTA_EXCEEDED_UNTIL (epoch ms) in the environment.
247
+ let quotaExceededUntilMs = Number(process.env.AGENTSBLOOM_QUOTA_EXCEEDED_UNTIL || 0) || 0;
248
+
249
+ /** Marks quota exceeded process-wide until the given epoch-ms timestamp. */
250
+ export function setQuotaExceededUntil(untilEpochMs) {
251
+ const value = Number(untilEpochMs);
252
+ quotaExceededUntilMs = Number.isFinite(value) ? value : 0;
253
+ }
254
+
255
+ /** Clears the process-wide quota-exceeded state immediately. */
256
+ export function clearQuotaExceeded() {
257
+ quotaExceededUntilMs = 0;
258
+ }
259
+ const rateLimitInterval = setInterval(() => {
260
+ const now = Date.now();
261
+ for (const [key, val] of rateLimitMap.entries()) {
262
+ if (val.resetTime < now) rateLimitMap.delete(key);
263
+ }
264
+ }, 60 * 1000);
265
+ rateLimitInterval.unref();
266
+
267
+ // In-memory Idempotency cache (per-instance optimization; order-level
268
+ // idempotency is enforced downstream by deterministic order refs).
269
+ // Signature nonces live in a replay cache that becomes cluster-wide when
270
+ // an Upstash REST endpoint is configured (lib/shared-store.js).
271
+ const idempotencyMap = new Map();
272
+ const signatureNonceMap = createReplayCache('agentsbloom:sig:nonce', SIGNATURE_REPLAY_CACHE_MAX_SIZE);
273
+ const idempotencyInterval = setInterval(() => {
274
+ const now = Date.now();
275
+ for (const [key, val] of idempotencyMap.entries()) {
276
+ if (val.expiry < now) idempotencyMap.delete(key);
277
+ }
278
+ }, 60 * 1000);
279
+ idempotencyInterval.unref();
280
+
281
+ let otelShutdownPromise = null;
282
+
283
+ // --- OpenAPI 3.1 document generation ---
284
+ //
285
+ // Generated from the merchant's declared actions so every action the store
286
+ // actually mounts appears in /openapi.json. Well-known commerce actions
287
+ // keep their curated, example-rich schemas; anything else a merchant
288
+ // declares is derived from its `params` type map. AP2 and protocol
289
+ // discovery endpoints are documented alongside the REST surface, and the
290
+ // security schemes describe the exact headers RFC 9421 / legacy /
291
+ // AP2 callers must send.
292
+
293
+ const OPENAPI_PARAM_TYPE_MAP = {
294
+ string: 'string',
295
+ number: 'number',
296
+ integer: 'integer',
297
+ boolean: 'boolean',
298
+ any: null, // schema-less when the action accepts anything
299
+ };
300
+
301
+ function openApiSchemaForParamType(type) {
302
+ const mapped = OPENAPI_PARAM_TYPE_MAP[type];
303
+ return mapped ? { type: mapped } : {};
304
+ }
305
+
306
+ /** Common error responses referenced via $ref from every generated operation. */
307
+ function openApiErrorRefs() {
308
+ return {
309
+ "401": { $ref: '#/components/responses/VerificationRequired' },
310
+ "403": { $ref: '#/components/responses/Forbidden' },
311
+ "429": { $ref: '#/components/responses/RateLimited' },
312
+ };
313
+ }
314
+
315
+ /** Curated, example-rich entries for the canonical commerce actions. */
316
+ function curatedOpenApiPaths() {
317
+ return {
318
+ "/api/agentsbloom/products": {
319
+ get: {
320
+ operationId: "listProducts",
321
+ summary: "Retrieve product catalog or filter by category/query",
322
+ parameters: [
323
+ { name: "category", in: "query", schema: { type: "string" }, description: "Category filter (e.g. shoes, apparel, accessories, electronics, home, books)" },
324
+ { name: "query", in: "query", schema: { type: "string" }, description: "Search term" },
325
+ { name: "limit", in: "query", schema: { type: "integer" }, description: "Maximum products to return" }
326
+ ],
327
+ responses: { "200": { description: "List of matching products with price, sizes, stock, rating" }, ...openApiErrorRefs() }
328
+ }
329
+ },
330
+ "/api/agentsbloom/search": {
331
+ get: {
332
+ operationId: "searchProducts",
333
+ summary: "Search products by natural language query",
334
+ parameters: [
335
+ { name: "query", in: "query", schema: { type: "string" }, description: "Product name or search keywords" },
336
+ { name: "category", in: "query", schema: { type: "string" }, description: "Category filter" }
337
+ ],
338
+ responses: { "200": { description: "Search results with count and product details" }, ...openApiErrorRefs() }
339
+ }
340
+ },
341
+ "/api/agentsbloom/cart": {
342
+ get: {
343
+ operationId: "getCart",
344
+ summary: "Retrieve current cart contents and total price",
345
+ responses: { "200": { description: "Cart items, item count, and price breakdown" }, ...openApiErrorRefs() }
346
+ }
347
+ },
348
+ "/api/agentsbloom/addToCart": {
349
+ post: {
350
+ operationId: "addToCart",
351
+ summary: "Add a product variant to cart",
352
+ security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }],
353
+ requestBody: {
354
+ required: true,
355
+ content: {
356
+ "application/json": {
357
+ schema: {
358
+ type: "object",
359
+ required: ["productId"],
360
+ properties: {
361
+ productId: { type: "string", description: "Product ID (e.g. puma-velocity-3, trail-master-x2, agent-pro-backpack)" },
362
+ size: { type: "string", description: "Product size or variant (e.g. 10, M, One Size)" },
363
+ quantity: { type: "integer", default: 1, description: "Quantity to add" }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ },
369
+ responses: { "200": { description: "Success confirmation and updated cart size" }, ...openApiErrorRefs() }
370
+ }
371
+ },
372
+ "/api/agentsbloom/checkout": {
373
+ post: {
374
+ operationId: "checkout",
375
+ summary: "Create a secure checkout link for payment",
376
+ security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }],
377
+ requestBody: {
378
+ required: false,
379
+ content: {
380
+ "application/json": {
381
+ schema: {
382
+ type: "object",
383
+ properties: {
384
+ address: { type: "string", description: "Customer shipping destination address" },
385
+ gateway: { type: "string", default: "stripe", description: "Payment gateway: stripe, razorpay, or paddle" }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ },
391
+ responses: { "200": { description: "Secure payment URL and session ID" }, ...openApiErrorRefs() }
392
+ }
393
+ }
394
+ };
395
+ }
396
+
397
+ /** Derives an operation entry for any declared action from its params map. */
398
+ function openApiOperationForAction(key, action, signatureAuthEnabled) {
399
+ const httpMethod = String(action.method || 'POST').toLowerCase();
400
+ const isWrite = ['post', 'put', 'patch', 'delete'].includes(httpMethod);
401
+ const paramSchema = {
402
+ type: "object",
403
+ properties: Object.fromEntries(
404
+ Object.entries(action.params || {}).map(([pkey, pval]) => [pkey, openApiSchemaForParamType(pval)])
405
+ ),
406
+ };
407
+
408
+ const operation = {
409
+ operationId: key,
410
+ summary: action.description || key,
411
+ ...(isWrite && signatureAuthEnabled
412
+ ? { security: [{ HttpSignatureAuth: [] }, { LegacyAgentSignature: [] }] }
413
+ : {}),
414
+ ...(httpMethod === 'get'
415
+ ? {
416
+ parameters: Object.entries(action.params || {}).map(([pkey, pval]) => ({
417
+ name: pkey,
418
+ in: "query",
419
+ schema: openApiSchemaForParamType(pval),
420
+ })),
421
+ }
422
+ : {
423
+ requestBody: {
424
+ required: true,
425
+ content: { "application/json": { schema: paramSchema } },
426
+ },
427
+ }),
428
+ responses: {
429
+ "200": { description: action.description ? `${action.description} result` : `${key} result` },
430
+ ...openApiErrorRefs(),
431
+ },
432
+ };
433
+
434
+ return { [httpMethod]: operation };
435
+ }
436
+
437
+ /**
438
+ * Builds the full OpenAPI 3.1 document: generated action paths, curated
439
+ * canonical entries, AP2 endpoints, discovery endpoints, reusable security
440
+ * schemes, and error responses.
441
+ */
442
+ function buildOpenApiDocument({ name, description, requestUrl, actions = {}, signatureAuthEnabled = true }) {
443
+ const paths = {};
444
+
445
+ // Every declared action gets a path (curated entries override generated
446
+ // ones for the canonical five).
447
+ for (const [key, action] of Object.entries(actions)) {
448
+ paths[`/api/agentsbloom/${key}`] = openApiOperationForAction(key, action, signatureAuthEnabled);
449
+ }
450
+ Object.assign(paths, curatedOpenApiPaths());
451
+
452
+ // AP2 protocol surface.
453
+ paths["/ap2/capabilities"] = {
454
+ get: {
455
+ operationId: "ap2Capabilities",
456
+ summary: "AP2 capabilities, mandate types, and verification methods",
457
+ responses: { "200": { description: "AP2 capability document" }, ...openApiErrorRefs() },
458
+ },
459
+ };
460
+ paths["/ap2/intent"] = {
461
+ post: {
462
+ operationId: "ap2Intent",
463
+ summary: "Announce purchase intent with a verified AP2 Intent Mandate",
464
+ security: [{ Ap2MandateAuth: [] }],
465
+ requestBody: {
466
+ required: false,
467
+ content: {
468
+ "application/json": {
469
+ schema: {
470
+ type: "object",
471
+ properties: {
472
+ requestedCategories: { type: "array", items: { type: "string" }, description: "Cart categories the intent covers" },
473
+ },
474
+ },
475
+ },
476
+ },
477
+ },
478
+ responses: {
479
+ "200": { description: "Intent accepted with mandate verification evidence" },
480
+ "401": { $ref: '#/components/responses/VerificationRequired' },
481
+ "403": { $ref: '#/components/responses/Forbidden' },
482
+ },
483
+ },
484
+ };
485
+ paths["/ap2/checkout"] = {
486
+ post: {
487
+ operationId: "ap2Checkout",
488
+ summary: "Mandate-gated checkout: creates a payment URL only when a verified AP2 mandate with a positive maxBudget covers the order total",
489
+ security: [{ Ap2MandateAuth: [] }],
490
+ requestBody: {
491
+ required: true,
492
+ content: {
493
+ "application/json": {
494
+ schema: {
495
+ type: "object",
496
+ required: ["orderTotal"],
497
+ properties: {
498
+ orderTotal: { type: "number", description: "Order total that must be covered by the mandate's maxBudget" },
499
+ address: { type: "string", description: "Customer shipping destination address" },
500
+ gateway: { type: "string", default: "stripe", description: "Payment gateway: stripe, razorpay, or paddle" },
501
+ },
502
+ },
503
+ },
504
+ },
505
+ },
506
+ responses: {
507
+ "200": { description: "Authorized checkout with payment URL, session id, and budget-enforcement evidence" },
508
+ "401": { $ref: '#/components/responses/VerificationRequired' },
509
+ "403": { $ref: '#/components/responses/Forbidden' },
510
+ },
511
+ },
512
+ };
513
+
514
+ // Protocol discovery surface.
515
+ paths["/.well-known/agent-spec"] = {
516
+ get: {
517
+ operationId: "agentSpec",
518
+ summary: "Legacy/compatibility discovery document",
519
+ responses: { "200": { description: "Agent spec manifest" } },
520
+ },
521
+ };
522
+ paths["/.well-known/ucp"] = {
523
+ get: {
524
+ operationId: "ucpProfile",
525
+ summary: "UCP (Universal Commerce Protocol) profile",
526
+ responses: { "200": { description: "UCP profile with capabilities and endpoint declarations" } },
527
+ },
528
+ };
529
+ paths["/ai-catalog.json"] = {
530
+ get: {
531
+ operationId: "aiCatalog",
532
+ summary: "WebMCP/ARD-style action catalog",
533
+ responses: { "200": { description: "Action catalog document" } },
534
+ },
535
+ };
536
+ paths["/llms.txt"] = {
537
+ get: {
538
+ operationId: "llmsDoc",
539
+ summary: "LLM-oriented developer guide",
540
+ responses: { "200": { description: "Plain-text guide for LLM agents" } },
541
+ },
542
+ };
543
+
544
+ return {
545
+ openapi: "3.1.0",
546
+ info: { title: name, description, version: "1.0.0" },
547
+ servers: [{ url: requestUrl }],
548
+ // Reads are open; writes carry their own operation-level security.
549
+ security: [],
550
+ tags: [
551
+ { name: "catalog", description: "Product browsing and search" },
552
+ { name: "cart", description: "Cart operations (write actions require agent signatures)" },
553
+ { name: "checkout", description: "Checkout link creation" },
554
+ { name: "ap2", description: "AP2 mandate-gated payment authorization" },
555
+ { name: "discovery", description: "Protocol discovery documents" },
556
+ ],
557
+ paths,
558
+ components: {
559
+ securitySchemes: {
560
+ HttpSignatureAuth: {
561
+ type: "apiKey",
562
+ description: "RFC 9421 HTTP Message Signatures: send `Signature` and `Signature-Input` headers; the signature must cover @method, @path, and content-digest for bodied requests, with created/expires/nonce parameters and a keyid resolvable through the configured JWKS.",
563
+ in: "header",
564
+ name: "Signature",
565
+ },
566
+ LegacyAgentSignature: {
567
+ type: "apiKey",
568
+ description: "Legacy HMAC scheme: X-Agent-Signature plus X-Agent-Identifier, X-Agent-Timestamp, X-Agent-Nonce headers, signed with the shared agent secret.",
569
+ in: "header",
570
+ name: "X-Agent-Signature",
571
+ },
572
+ Ap2MandateAuth: {
573
+ type: "apiKey",
574
+ description: "AP2 budget mandate: an SD-JWT with intentMandate.maxBudget, audience-bound to this store, sent as `X-AP2-Mandate: Bearer <sd-jwt>`.",
575
+ in: "header",
576
+ name: "X-AP2-Mandate",
577
+ },
578
+ },
579
+ responses: {
580
+ VerificationRequired: {
581
+ description: "Credential missing - write actions need an agent signature; AP2 endpoints need a mandate.",
582
+ content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, message: { type: "string" } } } } },
583
+ },
584
+ Forbidden: {
585
+ description: "Credential presented but invalid (bad signature, expired mandate, budget exceeded, wrong audience, replay detected).",
586
+ content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, reason: { type: "string" }, protocol: { type: "string" } } } } },
587
+ },
588
+ RateLimited: {
589
+ description: "Too many requests - respect X-RateLimit-Reset and Retry-After.",
590
+ content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, retryAfter: { type: "number" } } } } },
591
+ },
592
+ },
593
+ },
594
+ };
595
+ }
596
+
597
+
598
+ export async function shutdown() {
599
+ clearInterval(rateLimitInterval);
600
+ clearInterval(idempotencyInterval);
601
+ rateLimitMap.clear();
602
+ idempotencyMap.clear();
603
+ signatureNonceMap.clear();
604
+ jwksCache.clear();
605
+ stopAp2ReplayCleanup();
606
+
607
+ if (otelShutdownPromise) {
608
+ return otelShutdownPromise;
609
+ }
610
+
611
+ const otelHandle = globalThis.__agentsbloom_otel_handle;
612
+ if (!otelHandle) return;
613
+
614
+ // Clear the handle before awaiting so a repeated/concurrent shutdown cannot
615
+ // start a second provider shutdown, even if the first one rejects.
616
+ globalThis.__agentsbloom_otel_handle = null;
617
+ otelShutdownPromise = Promise.resolve().then(() => otelHandle.provider.shutdown());
618
+ try {
619
+ await otelShutdownPromise;
620
+ } finally {
621
+ otelShutdownPromise = null;
622
+ }
623
+ }
624
+
625
+ export function agentsbloom(config = {}) {
626
+ const {
627
+ apiKey = null,
628
+ name = "My Agent-Ready Store",
629
+ description = "An e-commerce store optimized for human and machine AI agents.",
630
+ actions = {},
631
+ llmsDoc = "",
632
+ baseUrl = ""
633
+ } = config;
634
+
635
+ if (!apiKey) {
636
+ console.error("🌸 AgentsBloom SDK Error: Missing `apiKey`. You must provide an API Key to use the SDK. Get one at dashboard.agentsbloom.com");
637
+ }
638
+ if (!baseUrl) {
639
+ console.error("🌸 AgentsBloom SDK Error: Missing `baseUrl` in config. This is required for secure telemetry.");
640
+ }
641
+
642
+ const MAX_REQUESTS = config.rateLimit?.max || 30;
643
+ const RATE_LIMIT_WINDOW = config.rateLimit?.windowMs || 60 * 1000;
644
+ const IDEMPOTENCY_TTL = config.idempotency?.ttlMs || 5 * 60 * 1000;
645
+ // v4 medium pass (V18): bound the per-process idempotency cache so
646
+ // unique-key spam cannot grow memory without limit between TTL sweeps.
647
+ const IDEMPOTENCY_MAX_ENTRIES = Number.isFinite(config.idempotency?.maxEntries) && config.idempotency.maxEntries > 0
648
+ ? config.idempotency.maxEntries
649
+ : 10_000;
650
+ const signatureMaxAgeMs = Number.isFinite(config.signature?.maxAgeMs) && config.signature.maxAgeMs > 0
651
+ ? config.signature.maxAgeMs
652
+ : LEGACY_SIGNATURE_MAX_AGE_MS;
653
+ // Host binding is now REQUIRED by default. Without `@authority` in the
654
+ // covered components, a signature captured at store A is structurally valid
655
+ // at store B for the same path a real cross-merchant replay whenever two
656
+ // stores trust the same JWKS. Opting out re-opens that hole knowingly.
657
+ const signatureRequireAuthority = config.signature?.requireAuthority !== false;
658
+ // The pre-hardening (non-conformant) signature base is accepted only when a
659
+ // merchant explicitly opts in for a migration window. See
660
+ // lib/signature-base.js for exactly how the two profiles differ.
661
+ const acceptLegacySignatureProfile = config.signature?.acceptLegacyProfile === true;
662
+ // Escape hatch for applications that cannot capture `req.rawBody`. Hashing a
663
+ // re-serialized body does not prove what the client sent, so this is off by
664
+ // default and loud when enabled.
665
+ const allowReserializedBody = config.signature?.allowReserializedBody === true;
666
+ if (allowReserializedBody) {
667
+ console.warn(
668
+ "🌸 AgentsBloom Warning: signature.allowReserializedBody is enabled. Content-Digest will be computed from a RE-SERIALIZED body, "
669
+ + 'which does not prove what the client actually sent. Prefer express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }).',
670
+ );
671
+ }
672
+ if (acceptLegacySignatureProfile) {
673
+ console.warn(
674
+ "🌸 AgentsBloom Warning: signature.acceptLegacyProfile is enabled. The pre-0.6 signature base (lowercased @method, query folded into @path, "
675
+ + 'unbindable authority) is accepted as a fallback. Remove this once your agents sign the conformant RFC 9421 base.',
676
+ );
677
+ }
678
+ if (config.signature?.requireAuthority === false) {
679
+ console.warn(
680
+ '🌸 AgentsBloom Warning: signature.requireAuthority is disabled. Signatures will not bind the request host, so a signature captured '
681
+ + 'at another store that trusts the same keys can be replayed here.',
682
+ );
683
+ }
684
+
685
+ const configuredAgentSecret = config.agentSecret ?? process.env.AGENTSBLOOM_SECRET;
686
+ const agentSecret = typeof configuredAgentSecret === 'string' && configuredAgentSecret.length > 0
687
+ ? configuredAgentSecret
688
+ : null;
689
+ // Previous secret kept verify-live during rotation (see verifySignature
690
+ // call sites) so rolling AGENTSBLOOM_SECRET doesn't hard-drop every
691
+ // in-flight agent.
692
+ const configuredPreviousSecret = config.agentSecretPrevious ?? process.env.AGENTSBLOOM_SECRET_PREVIOUS;
693
+ const agentSecretPrevious = typeof configuredPreviousSecret === 'string' && configuredPreviousSecret.length > 0
694
+ ? configuredPreviousSecret
695
+ : null;
696
+ const signatureAuthEnabled = config.disableSignatureAuth !== true && config.demoMode !== true;
697
+
698
+ // --- Replay-cache namespace (was: crypto.randomUUID()) ---
699
+ //
700
+ // This prefix is part of every nonce and idempotency cache key. Generating
701
+ // it randomly per `agentsbloom()` call silently DEFEATED cluster-wide replay
702
+ // protection: with Upstash configured, instance A stored
703
+ // `<uuid-A>:rfc:<keyid>:<nonce>` while instance B looked for
704
+ // `<uuid-B>:rfc:<keyid>:<nonce>`, so `SET NX` never collided and a captured
705
+ // signature replayed cleanly on any other pod — and on the same pod after a
706
+ // restart. (Mandate jtis were not namespaced at all, so the two paths
707
+ // disagreed about whether replay protection was cluster-wide.)
708
+ //
709
+ // The namespace is now DERIVED and stable across instances of the same
710
+ // store: explicit config first, then the merchant's `baseUrl`, then a fixed
711
+ // default. Distinct stores sharing one Redis still get distinct prefixes.
712
+ const cacheNamespace = (() => {
713
+ const explicit = config.cacheNamespace;
714
+ if (typeof explicit === 'string' && explicit.trim().length > 0) return explicit.trim();
715
+ if (baseUrl) {
716
+ return `ab:${crypto.createHash('sha256').update(normalizeAudience(baseUrl)).digest('base64url').slice(0, 16)}`;
717
+ }
718
+ console.warn(
719
+ '🌸 AgentsBloom Warning: neither `baseUrl` nor `cacheNamespace` is set, so replay-cache keys fall back to a shared default. '
720
+ + 'If several distinct stores share one replay store, set `cacheNamespace` per store.',
721
+ );
722
+ return 'ab:default';
723
+ })();
724
+
725
+ // --- v4 medium pass (V11): per-agent keys and revocation ---
726
+ // The single shared merchant secret meant one compromised agent
727
+ // compromised every agent: any holder could forge requests as ANY
728
+ // identifier, and there was no way to revoke one agent without rotating
729
+ // the global secret for all of them. `config.agentKeys` maps identifier
730
+ // -> per-agent secret (checked first); `config.revokedIdentifiers`
731
+ // rejects an identifier outright. The shared secret remains the
732
+ // fallback so existing deployments keep working.
733
+ const agentKeys = config.agentKeys && typeof config.agentKeys === 'object'
734
+ ? Object.fromEntries(
735
+ Object.entries(config.agentKeys).filter(([, v]) => typeof v === 'string' && v.length > 0)
736
+ )
737
+ : null;
738
+ const revokedIdentifiers = Array.isArray(config.revokedIdentifiers)
739
+ ? new Set(config.revokedIdentifiers)
740
+ : null;
741
+
742
+ // --- v4 medium pass (V12): per-action authorization ---
743
+ // Any verified identity could previously invoke ANY write action,
744
+ // checkout included - the permission matrix existed only in the hosted
745
+ // gateway. Merchants can now require a verified identity per action and
746
+ // restrict which identities may call it.
747
+ const actionAccess = config.actionAccess && typeof config.actionAccess === 'object' ? config.actionAccess : null;
748
+ const actionIdentities = config.actionIdentities && typeof config.actionIdentities === 'object' ? config.actionIdentities : null;
749
+
750
+ // --- v4 medium pass (V20): CORS origin policy ---
751
+ // Array = exact-match allow-list with per-request reflection; string =
752
+ // legacy single-value behavior ('*' by default). The wildcard default is
753
+ // loud about itself so merchants notice and restrict it.
754
+ const allowedOriginList = Array.isArray(config.corsOrigin)
755
+ ? config.corsOrigin.filter((o) => typeof o === 'string' && o.length > 0)
756
+ : null;
757
+ const allowedOriginString = allowedOriginList ? null : (config.corsOrigin || '*');
758
+ if (!config.corsOrigin) {
759
+ console.warn("🌸 AgentsBloom Warning: corsOrigin defaults to '*' - any web origin can call this store's API, including write endpoints reachable from a victim browser. Set corsOrigin to an explicit origin or array of origins for production.");
760
+ }
761
+
762
+ /**
763
+ * Returns a rejection descriptor when `identity` (verified cache identity
764
+ * string like `rfc:<keyid>` / `legacy:<identifier>`, or null) may not
765
+ * invoke `actionName`; null when allowed.
766
+ */
767
+ function authorizeAction(actionName, identity) {
768
+ if (actionAccess?.[actionName] === 'authenticated' && !identity) {
769
+ return {
770
+ status: 401,
771
+ body: {
772
+ error: "Authentication Required",
773
+ message: `Action ${actionName} requires a verified agent signature (RFC 9421 or X-Agent-Signature).`
774
+ },
775
+ };
776
+ }
777
+ const allowed = actionIdentities?.[actionName];
778
+ if (Array.isArray(allowed) && allowed.length > 0) {
779
+ const matched = Boolean(identity) && allowed.some((pattern) => (
780
+ pattern === identity || (typeof pattern === 'string' && pattern.endsWith(':') && identity.startsWith(pattern))
781
+ ));
782
+ if (!matched) {
783
+ return {
784
+ status: 403,
785
+ body: {
786
+ error: "Forbidden",
787
+ message: `Verified identity ${identity || '(anonymous)'} is not authorized to invoke ${actionName}.`
788
+ },
789
+ };
790
+ }
791
+ }
792
+ return null;
793
+ }
794
+
795
+ if (!agentSecret && !agentKeys && signatureAuthEnabled) {
796
+ console.warn("🌸 AgentsBloom Warning: Neither AGENTSBLOOM_SECRET nor config.agentKeys is set. Legacy signed write requests will be rejected until a secret is configured.");
797
+ }
798
+
799
+ // --- Host-header policy (resolved once, at construction) ---
800
+ //
801
+ // Used for two distinct purposes:
802
+ // 1. Discovery URLs: a request whose Host is not on the list gets its
803
+ // URLs built from the merchant's trusted `baseUrl` instead of the
804
+ // attacker-controlled Host.
805
+ // 2. Signature authority binding: `@authority` canonicalizes the request's
806
+ // own Host header, so covering it only prevents cross-store replay if
807
+ // the verifier ALSO knows which authorities are its own. Otherwise an
808
+ // attacker replaying store A's signature here just sends
809
+ // `Host: store-a.com` and the signature base reconstructs identically.
810
+ const allowedHosts = Array.isArray(config.allowedHosts)
811
+ ? config.allowedHosts.filter((h) => typeof h === 'string' && h.length > 0).map((h) => h.toLowerCase())
812
+ : null;
813
+
814
+ const expectedAuthorities = (() => {
815
+ const authorities = new Set();
816
+ for (const host of allowedHosts || []) {
817
+ authorities.add(normalizeAuthorityForScheme(host, 'https:'));
818
+ authorities.add(normalizeAuthorityForScheme(host, 'http:'));
819
+ }
820
+ if (baseUrl) {
821
+ try {
822
+ const parsed = new URL(baseUrl);
823
+ authorities.add(normalizeAuthorityForScheme(parsed.host, parsed.protocol));
824
+ } catch {
825
+ // A malformed baseUrl is already reported elsewhere; don't add it.
826
+ }
827
+ }
828
+ authorities.delete('');
829
+ if (authorities.size === 0 && signatureAuthEnabled && signatureRequireAuthority) {
830
+ console.warn(
831
+ '🌸 AgentsBloom Warning: neither `baseUrl` nor `allowedHosts` is set, so the Host header cannot be validated. '
832
+ + 'Signatures still have to COVER @authority, but a replayed signature from another store will match if the attacker '
833
+ + 'forwards that store\'s Host header. Set `baseUrl` (or `allowedHosts`) to make host binding effective.',
834
+ );
835
+ return null;
836
+ }
837
+ return authorities.size > 0 ? authorities : null;
838
+ })();
839
+
840
+ // v4 (Host-header poisoning defense): warn when the AP2 audience would be
841
+ // derived from the request's own Host header - that fallback is spoofable.
842
+ if (!config.ap2?.expectedAudience && !baseUrl) {
843
+ console.warn("🌸 AgentsBloom Warning: Neither `baseUrl` nor `ap2.expectedAudience` is configured. The AP2 mandate audience will fall back to each request's Host header, which an attacker can spoof. Set one of them for production.");
844
+ }
845
+
846
+ return async (req, res, next) => {
847
+ // v4 medium pass (V10): the 1MB gate used to check the Content-Length
848
+ // header only - a chunked transfer with no Content-Length sailed past
849
+ // it. The cap is now also enforced against the ACTUAL received bytes
850
+ // (req.rawBody) once the body parser has run, and is configurable.
851
+ const maxBodyBytes = Number.isFinite(config.maxBodyBytes) && config.maxBodyBytes > 0
852
+ ? config.maxBodyBytes
853
+ : 1024 * 1024;
854
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
855
+ if (contentLength > maxBodyBytes) {
856
+ return res.status(413).json({ error: "Payload Too Large", message: `Request body exceeds ${maxBodyBytes} byte limit.` });
857
+ }
858
+ let receivedBodyBytes;
859
+ if (Buffer.isBuffer(req.rawBody)) {
860
+ receivedBodyBytes = req.rawBody.length;
861
+ } else if (typeof req.rawBody === 'string') {
862
+ receivedBodyBytes = Buffer.byteLength(req.rawBody);
863
+ }
864
+ if (receivedBodyBytes !== undefined && receivedBodyBytes > maxBodyBytes) {
865
+ return res.status(413).json({
866
+ error: "Payload Too Large",
867
+ message: `Request body exceeds ${maxBodyBytes} byte limit.`,
868
+ code: "body_size_exceeded"
869
+ });
870
+ }
871
+
872
+ if (req.path === '/health' && req.method === 'GET') {
873
+ return res.json({ status: "ok", version: "0.4.0", uptime: process.uptime() });
874
+ }
875
+
876
+ const rawHost = req.get('host') || '';
877
+ const hostIsAllowed = !allowedHosts || !rawHost || allowedHosts.includes(rawHost.toLowerCase());
878
+ const host = hostIsAllowed ? rawHost : '';
879
+ // v4: only well-known protocol values are honored from x-forwarded-proto.
880
+ const forwardedProtoCandidate = String(req.get('x-forwarded-proto') || '').split(',')[0].trim().toLowerCase();
881
+ const forwardedProto = forwardedProtoCandidate === 'http' || forwardedProtoCandidate === 'https'
882
+ ? forwardedProtoCandidate
883
+ : '';
884
+ const isTlsTunnel = host.includes('.life') || host.includes('.loca.lt') || host.includes('.trycloudflare.com') || host.includes('.ngrok');
885
+ const protocol = forwardedProto || (isTlsTunnel ? 'https' : (req.protocol || 'http'));
886
+ const requestUrl = host ? `${protocol}://${host}` : (baseUrl || `${protocol}://localhost:3000`);
887
+ const ip = req.ip || req.socket.remoteAddress || '127.0.0.1';
888
+ const now = Date.now();
889
+
890
+ // --- CORS HEADERS ---
891
+ // v4 medium pass (V20): `corsOrigin` now accepts an array of exact
892
+ // origins; requests whose Origin matches get it reflected (with
893
+ // Vary: Origin), everyone else gets NO Access-Control-Allow-Origin at
894
+ // all - a real allow-list instead of `*` for everything including
895
+ // writes. The string form behaves exactly as before. The wildcard
896
+ // default is retained for backward compatibility but warns once.
897
+ if (Array.isArray(allowedOriginList)) {
898
+ const requestOrigin = req.headers.origin;
899
+ if (typeof requestOrigin === 'string' && allowedOriginList.includes(requestOrigin)) {
900
+ res.setHeader('Access-Control-Allow-Origin', requestOrigin);
901
+ res.setHeader('Vary', 'Origin');
902
+ }
903
+ } else {
904
+ res.setHeader('Access-Control-Allow-Origin', allowedOriginString);
905
+ }
906
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
907
+ 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');
908
+
909
+ // --- SaaS QUOTA ENFORCEMENT (Zero Latency Cache) ---
910
+ if (now < quotaExceededUntilMs) {
911
+ res.setHeader('Content-Type', 'application/json');
912
+ return res.status(402).json({
913
+ error: "AgentsBloom Quota Exceeded. Please upgrade your API plan to continue serving AI Agents.",
914
+ code: "api_quota_exceeded"
915
+ });
916
+ }
917
+
918
+ // --- 1. DDoS PROTECTION (RATE LIMITING) ---
919
+ // v4: OPTIONS preflights are now counted toward the same bucket (they
920
+ // used to bypass the limiter entirely, making free preflight floods
921
+ // possible), and the client key is normalized so IPv6-mapped IPv4
922
+ // representations cannot mint fresh buckets.
923
+ const rateKey = normalizeRateLimitKey(ip);
924
+ let limit = rateLimitMap.get(rateKey);
925
+ const rateKeyNormalized = rateKey;
926
+ if (!limit || now > limit.resetTime) {
927
+ // v4: bound the tracking map. When at capacity, sweep expired
928
+ // entries first; if still full, drop the oldest-tracked client so
929
+ // attacker-controlled IP rotation cannot grow memory unboundedly.
930
+ if (!rateLimitMap.has(rateKeyNormalized) && rateLimitMap.size >= MAX_RATE_TRACKED_CLIENTS) {
931
+ for (const [key, val] of rateLimitMap.entries()) {
932
+ if (val.resetTime < now) rateLimitMap.delete(key);
933
+ }
934
+ if (rateLimitMap.size >= MAX_RATE_TRACKED_CLIENTS) {
935
+ const oldestKey = rateLimitMap.keys().next().value;
936
+ if (oldestKey !== undefined) rateLimitMap.delete(oldestKey);
937
+ }
938
+ }
939
+ limit = { count: 1, resetTime: now + RATE_LIMIT_WINDOW };
940
+ rateLimitMap.set(rateKeyNormalized, limit);
941
+ } else {
942
+ limit.count++;
943
+ }
944
+
945
+ const remaining = Math.max(0, MAX_REQUESTS - limit.count);
946
+ res.setHeader('X-RateLimit-Limit', String(MAX_REQUESTS));
947
+ res.setHeader('X-RateLimit-Remaining', String(remaining));
948
+ res.setHeader('X-RateLimit-Reset', String(Math.ceil((limit.resetTime - now) / 1000)));
949
+
950
+ if (limit.count > MAX_REQUESTS) {
951
+ res.setHeader('Content-Type', 'application/json');
952
+ res.setHeader('Retry-After', String(Math.ceil((limit.resetTime - now) / 1000)));
953
+ return res.status(429).json({
954
+ error: "Too Many Requests",
955
+ message: "Rate limit exceeded. Please slow down.",
956
+ retryAfter: Math.ceil((limit.resetTime - now) / 1000)
957
+ });
958
+ }
959
+
960
+ // OPTIONS preflights are answered AFTER being counted against the
961
+ // caller's rate bucket (v4 fix for unthrottled preflight floods).
962
+ if (req.method === 'OPTIONS') {
963
+ return res.status(204).end();
964
+ }
965
+
966
+ // --- 2. SERVE SPEC ENDPOINTS ---
967
+
968
+ // Serve /.well-known/agent-spec & /v1/agent/spec (API Versioning)
969
+ if (req.path === '/.well-known/agent-spec' || req.path === '/v1/agent/spec') {
970
+ res.setHeader('Content-Type', 'application/json');
971
+ res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
972
+ return res.json({
973
+ name,
974
+ description,
975
+ version: "1.0.0",
976
+ agentsbloomVersion: "0.4.0",
977
+ discoveryUrl: `${requestUrl}/v1/agent/spec`,
978
+ catalogUrl: `${requestUrl}/v1/agent/catalog`,
979
+ llmsUrl: `${requestUrl}/llms.txt`,
980
+ security: {
981
+ rateLimiting: { maxRequestsPerMin: MAX_REQUESTS },
982
+ captchaBypassing: { supported: true, authHeader: "X-Agent-Signature", webBotAuth: true },
983
+ idempotency: { supported: true, header: "Idempotency-Key", ttlSeconds: 300 }
984
+ },
985
+ actions: Object.entries(actions).reduce((acc, [key, val]) => {
986
+ acc[key] = {
987
+ endpoint: `/api/agentsbloom/${key}`,
988
+ method: val.method || 'POST',
989
+ description: val.description,
990
+ params: val.params || {}
991
+ };
992
+ return acc;
993
+ }, {}),
994
+ authentication: { type: "http-message-signatures-or-x-agent-signature", requiredForWrites: true }
995
+ });
996
+ }
997
+
998
+ // Serve /.well-known/http-message-signatures-directory
999
+ // v4 medium pass (V16): the old placeholder key ("placeholder-merchant-key")
1000
+ // was a fake trust anchor - agents could "verify" nothing real against
1001
+ // it while believing they had a genuine merchant key set. Fail closed
1002
+ // instead until the merchant configures their JWKS.
1003
+ if (req.path === '/.well-known/http-message-signatures-directory') {
1004
+ if (!config.merchantJwks) {
1005
+ return res.status(503).json({
1006
+ error: 'JWKS Not Configured',
1007
+ message: 'This store has not published a merchant JWKS. Provide `merchantJwks` in the AgentsBloom SDK config to serve /.well-known/http-message-signatures-directory.'
1008
+ });
1009
+ }
1010
+ res.setHeader('Content-Type', 'application/json');
1011
+ res.setHeader('Cache-Control', 'public, max-age=86400');
1012
+ return res.json(config.merchantJwks);
1013
+ }
1014
+
1015
+ // Serve /.well-known/ucp (Universal Commerce Protocol Profile)
1016
+ if (req.path === '/.well-known/ucp') {
1017
+ res.setHeader('Content-Type', 'application/json');
1018
+ res.setHeader('Cache-Control', 'public, max-age=86400');
1019
+ return res.json({
1020
+ protocol: "ucp",
1021
+ version: "1.0.0",
1022
+ store: { name, description, baseUrl: requestUrl },
1023
+ capabilities: [
1024
+ "dev.ucp.shopping",
1025
+ "dev.ucp.shopping.checkout",
1026
+ "dev.ucp.common.identity_linking"
1027
+ ],
1028
+ endpoints: {
1029
+ catalog: `${requestUrl}/ai-catalog.json`,
1030
+ search: `${requestUrl}/api/agentsbloom/search`,
1031
+ products: `${requestUrl}/api/agentsbloom/products`,
1032
+ cart: `${requestUrl}/api/agentsbloom/cart`,
1033
+ checkout: `${requestUrl}/api/agentsbloom/checkout/acp`
1034
+ },
1035
+ actions: Object.entries(actions).reduce((acc, [key, val]) => {
1036
+ acc[key] = {
1037
+ endpoint: `/api/agentsbloom/${key}`,
1038
+ method: val.method || 'POST',
1039
+ description: val.description,
1040
+ params: val.params || {}
1041
+ };
1042
+ return acc;
1043
+ }, {}),
1044
+ auth: {
1045
+ methods: ["http-message-signatures", "x-agent-signature"]
1046
+ }
1047
+ });
1048
+ }
1049
+
1050
+ // Serve /ai-catalog.json & /v1/agent/catalog (ARD / UCP Compliant)
1051
+ if (req.path === '/ai-catalog.json' || req.path === '/.well-known/ai-catalog.json' || req.path === '/v1/agent/catalog') {
1052
+ res.setHeader('Content-Type', 'application/json');
1053
+ res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
1054
+ return res.json({
1055
+ $schema: "https://universalcommerce.org/schemas/catalog.json",
1056
+ name,
1057
+ description,
1058
+ version: "1.0.0",
1059
+ auth: {
1060
+ supported: ["http-message-signatures", "x-agent-signature"]
1061
+ },
1062
+ items: Object.entries(actions).map(([key, val]) => ({
1063
+ id: key,
1064
+ type: "action",
1065
+ title: key,
1066
+ description: val.description,
1067
+ actionUrl: `${requestUrl}/api/agentsbloom/${key}`
1068
+ }))
1069
+ });
1070
+ }
1071
+
1072
+ // Serve /openapi.json & /schema.json (OpenAPI 3.1 for custom GPT
1073
+ // actions and any OpenAPI-consuming agent). The document is generated
1074
+ // from the merchant's declared actions - previously it hardcoded five
1075
+ // paths, so any custom action (removeFromCart, clearCart, or anything
1076
+ // a merchant added) silently vanished from the OpenAPI surface, and
1077
+ // AP2 endpoints were entirely undocumented.
1078
+ if (req.path === '/openapi.json' || req.path === '/schema.json') {
1079
+ res.setHeader('Content-Type', 'application/json');
1080
+ res.setHeader('Cache-Control', 'public, max-age=86400');
1081
+ return res.json(buildOpenApiDocument({ name, description, requestUrl, actions, signatureAuthEnabled }));
1082
+ }
1083
+
1084
+ // --- Verified-identity bindings, declared BEFORE any closure reads them ---
1085
+ //
1086
+ // The MCP `tools/call` handler below closes over `authenticatedCacheIdentity`
1087
+ // to enforce per-action authorization. That binding used to be declared
1088
+ // ~40 lines LOWER with `let`, after this branch had already returned, so
1089
+ // the closure ran while the binding was still in its temporal dead zone:
1090
+ // every single MCP tool invocation threw
1091
+ // ReferenceError: Cannot access 'authenticatedCacheIdentity' before initialization
1092
+ // regardless of configuration. Hoisting it here fixes the crash and keeps
1093
+ // the authorization check meaningful.
1094
+ let authenticatedCacheIdentity = null;
1095
+ let signatureProfileUsed = null;
1096
+
1097
+ // Serve MCP SSE Endpoint for Tool Calling
1098
+ // v4: concurrent SSE sessions are bounded (config.mcp.maxSessions,
1099
+ // default 100). Previously every GET /mcp created an unbounded new
1100
+ // transport with no accounting - a trivial socket/memory exhaustion
1101
+ // vector.
1102
+ if (req.path === '/mcp') {
1103
+ const maxMcpSessions = Number.isFinite(config.mcp?.maxSessions) && config.mcp.maxSessions > 0
1104
+ ? config.mcp.maxSessions
1105
+ : 100;
1106
+ if (activeMcpSessions >= maxMcpSessions) {
1107
+ return res.status(503).json({
1108
+ error: "Too Many MCP Sessions",
1109
+ message: `Concurrent MCP session limit (${maxMcpSessions}) reached. Retry later.`
1110
+ });
1111
+ }
1112
+ const transport = new SSEServerTransport('/mcp/messages', res);
1113
+ activeMcpSessions += 1;
1114
+ // The slot must be released exactly once. `res.on('close')` and the
1115
+ // connect() catch below could both fire for one session, drifting the
1116
+ // counter negative and loosening the concurrency cap over time.
1117
+ let sessionSlotReleased = false;
1118
+ const releaseMcpSession = () => {
1119
+ if (sessionSlotReleased) return;
1120
+ sessionSlotReleased = true;
1121
+ activeMcpSessions = Math.max(0, activeMcpSessions - 1);
1122
+ if (transport.sessionId) mcpTransports.delete(transport.sessionId);
1123
+ };
1124
+ res.on('close', releaseMcpSession);
1125
+ // Register the transport so POST /mcp/messages can reach it. Without
1126
+ // this the MCP surface was advertised in every discovery document but
1127
+ // unusable: `/mcp/messages` fell through to next() and no tool call
1128
+ // could ever be delivered. The registry is bounded by the same
1129
+ // maxMcpSessions cap and entries are removed when the stream closes.
1130
+ if (transport.sessionId) mcpTransports.set(transport.sessionId, transport);
1131
+ const mcpServer = new Server({ name: name, version: "1.0.0" }, { capabilities: { tools: {} } });
1132
+ // Auto-generate MCP tool declarations from actions
1133
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
1134
+ tools: Object.entries(actions).map(([key, val]) => ({
1135
+ name: key,
1136
+ description: val.description,
1137
+ inputSchema: {
1138
+ type: "object",
1139
+ properties: Object.entries(val.params || {}).reduce((acc, [pkey, pval]) => {
1140
+ acc[pkey] = { type: pval };
1141
+ return acc;
1142
+ }, {})
1143
+ }
1144
+ }))
1145
+ }));
1146
+
1147
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
1148
+ const action = actions[request.params.name];
1149
+ if (!action) throw new Error(`Tool not found: ${request.params.name}`);
1150
+ // v4 medium pass (V12): the same per-action authorization model
1151
+ // applies to MCP tool invocations.
1152
+ //
1153
+ // The identity is read from the TRANSPORT, not from this closure's
1154
+ // captured `authenticatedCacheIdentity`. That variable belongs to the
1155
+ // GET /mcp request that opened the SSE stream which is not a
1156
+ // protected action path, so it is always null. Authorizing against it
1157
+ // meant `actionAccess: 'authenticated'` could never be satisfied and
1158
+ // `actionIdentities` could never match. The verified identity of the
1159
+ // POST /mcp/messages request that actually carries the tool call is
1160
+ // stashed on the transport by that handler.
1161
+ const mcpIdentity = transport.__agentsbloomIdentity ?? null;
1162
+ req.agentIdentity = mcpIdentity;
1163
+ const accessRejection = authorizeAction(request.params.name, mcpIdentity);
1164
+ if (accessRejection) throw new Error(accessRejection.body.message);
1165
+ // v4: enforce the declared parameter contract. The REST route has
1166
+ // always validated/coerced params; the MCP path now does too, so
1167
+ // merchants get identical type guarantees on both surfaces.
1168
+ const validation = validateActionParams(request.params.arguments || {}, action);
1169
+ if (!validation.ok) throw new Error(validation.message);
1170
+ const result = await action.handler(validation.params, req, res);
1171
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1172
+ });
1173
+
1174
+ // Second-pass review (N4): if connect() rejects, release the session
1175
+ // slot immediately instead of waiting for a close event that may not
1176
+ // fire for a transport that never started.
1177
+ try {
1178
+ return await mcpServer.connect(transport);
1179
+ } catch (err) {
1180
+ releaseMcpSession();
1181
+ throw err;
1182
+ }
1183
+ }
1184
+
1185
+ // Serve /llms.txt
1186
+ if (req.path === '/llms.txt') {
1187
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1188
+ res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
1189
+ 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- GET /openapi.json : OpenAPI 3.1 schema\n`;
1190
+ const docContent = typeof llmsDoc === 'function' ? llmsDoc() : (llmsDoc || defaultLlmDoc);
1191
+ return res.send(docContent);
1192
+ }
1193
+
1194
+ // --- 3. IDEMPOTENCY METADATA FOR WRITES (POST/PUT/PATCH/DELETE) ---
1195
+ const method = String(req.method || '').toUpperCase();
1196
+ const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
1197
+ const rawIdempotencyKey = req.headers['idempotency-key'];
1198
+ const idempotencyHeader = typeof rawIdempotencyKey === 'string' ? rawIdempotencyKey : null;
1199
+ let idempotencyKey = null;
1200
+ // `authenticatedCacheIdentity` / `signatureProfileUsed` are declared above
1201
+ // the /mcp branch so its tool-call closure can read them.
1202
+
1203
+ // --- 4. CRYPTOGRAPHIC CAPTCHA BYPASSING (Web Bot Auth + Legacy HMAC) ---
1204
+ const isMcpMessage = req.path === '/mcp/messages' && method === 'POST';
1205
+ // Second-pass review (N3): AP2 intent/checkout POSTs are now part of the
1206
+ // signature gate. The agent-side SDK has always signed these posts, but
1207
+ // the merchant gate ignored them - so a mandate alone could check out
1208
+ // anonymously, and per-action authorization policies could never see a
1209
+ // verified identity on the most valuable endpoint. A verified mandate
1210
+ // proves budget INTENT; a signature proves who is spending it.
1211
+ const isAp2WritePath = method === 'POST' && (
1212
+ req.path === '/ap2/intent'
1213
+ || req.path === '/ap2/checkout'
1214
+ || req.path === '/v1/ap2/intent'
1215
+ || req.path === '/v1/ap2/checkout'
1216
+ );
1217
+ const isAgentAction = req.path.startsWith('/api/agentsbloom/')
1218
+ || req.path.startsWith('/v1/agent/actions/')
1219
+ || isMcpMessage
1220
+ || isAp2WritePath;
1221
+ // v4 medium pass (V12): resolve which action (if any) this path targets
1222
+ // so per-action authorization can require verification even on reads.
1223
+ let requestedActionName = null;
1224
+ if (req.path.startsWith('/api/agentsbloom/')) {
1225
+ requestedActionName = req.path.slice('/api/agentsbloom/'.length);
1226
+ } else if (req.path.startsWith('/v1/agent/actions/')) {
1227
+ requestedActionName = req.path.slice('/v1/agent/actions/'.length);
1228
+ }
1229
+ const needsAuthenticatedRead = Boolean(
1230
+ requestedActionName && actionAccess?.[requestedActionName] === 'authenticated'
1231
+ );
1232
+ if (isAgentAction && (isWrite || needsAuthenticatedRead) && signatureAuthEnabled) {
1233
+ const signature = req.headers['x-agent-signature'];
1234
+ const identifier = req.headers['x-agent-identifier'];
1235
+ const timestamp = req.headers['x-agent-timestamp'];
1236
+ const nonce = req.headers['x-agent-nonce'];
1237
+
1238
+ const rfcSignature = req.headers['signature'];
1239
+ const rfcSignatureInput = req.headers['signature-input'];
1240
+ const hasRfcSignature = typeof rfcSignature === 'string' && rfcSignature.length > 0;
1241
+ const hasRfcSignatureInput = typeof rfcSignatureInput === 'string' && rfcSignatureInput.length > 0;
1242
+
1243
+ if (!signature && !hasRfcSignature && !hasRfcSignatureInput) {
1244
+ return res.status(401).json({
1245
+ error: "Verification Required",
1246
+ message: "CAPTCHA check required. Please provide standard RFC 9421 HTTP Message Signatures or the legacy X-Agent-Signature."
1247
+ });
1248
+ }
1249
+
1250
+ if (hasRfcSignature !== hasRfcSignatureInput) {
1251
+ return res.status(403).json({
1252
+ error: "Verification Failed",
1253
+ message: "Signature and Signature-Input headers must be provided together."
1254
+ });
1255
+ }
1256
+
1257
+ if (hasRfcSignature) {
1258
+ // RFC 9421 verification now lives in lib/http-signatures.js: real
1259
+ // structured-field parsing, canonical signature base, curve/modulus
1260
+ // pinning, ECDSA P1363, authority validation, digest over the received
1261
+ // bytes, and a replay record that outlives the acceptance window.
1262
+ const verification = await verifyHttpMessageSignature({
1263
+ req,
1264
+ requestContext: requestContextFromExpress(req, { forwardedProto }),
1265
+ signatureHeader: rfcSignature,
1266
+ signatureInputHeader: rfcSignatureInput,
1267
+ jwks: config.agentJwks || null,
1268
+ jwksUrl: typeof config.agentJwksUrl === 'string' ? config.agentJwksUrl : null,
1269
+ jwksCache,
1270
+ nonceCache: signatureNonceMap,
1271
+ nonceNamespace: cacheNamespace,
1272
+ maxAgeMs: signatureMaxAgeMs,
1273
+ clockSkewMs: RFC_SIGNATURE_CLOCK_SKEW_MS,
1274
+ requireAuthority: signatureRequireAuthority,
1275
+ expectedAuthorities,
1276
+ acceptLegacyProfile: acceptLegacySignatureProfile,
1277
+ allowReserializedBody,
1278
+ });
1279
+
1280
+ if (!verification.ok) {
1281
+ // Verifier internals (expected values, raw crypto error text, which
1282
+ // JWKS key was missing) stay server-side. The caller gets a stable
1283
+ // machine-readable code and a generic message.
1284
+ console.error(
1285
+ `🌸 AgentsBloom: RFC 9421 signature verification failed [${verification.code}]: ${verification.detail}`,
1286
+ );
1287
+ return res.status(verification.status).json({
1288
+ error: verification.status === 503 ? 'Service Unavailable' : 'Forbidden',
1289
+ code: verification.code,
1290
+ message: verification.publicMessage,
1291
+ });
1292
+ }
1293
+ authenticatedCacheIdentity = verification.identity;
1294
+ signatureProfileUsed = verification.profile;
1295
+ } else {
1296
+ // v4 medium pass (V11): resolve a PER-AGENT secret when one is
1297
+ // configured, and reject revoked identifiers outright.
1298
+ if (revokedIdentifiers && typeof identifier === 'string' && revokedIdentifiers.has(identifier)) {
1299
+ return res.status(403).json({
1300
+ error: "Verification Failed",
1301
+ message: "X-Agent-Identifier has been revoked."
1302
+ });
1303
+ }
1304
+ const perAgentSecret = agentKeys && typeof identifier === 'string' ? agentKeys[identifier] : undefined;
1305
+ const effectiveSecret = (typeof perAgentSecret === 'string' && perAgentSecret.length > 0)
1306
+ ? perAgentSecret
1307
+ : agentSecret;
1308
+
1309
+ const headerValuePattern = /^[\x21-\x7e]+$/;
1310
+ if (
1311
+ !effectiveSecret ||
1312
+ typeof signature !== 'string' ||
1313
+ typeof identifier !== 'string' ||
1314
+ typeof timestamp !== 'string' ||
1315
+ typeof nonce !== 'string' ||
1316
+ identifier.length > 256 ||
1317
+ nonce.length < 16 ||
1318
+ nonce.length > 256 ||
1319
+ !headerValuePattern.test(identifier) ||
1320
+ !headerValuePattern.test(timestamp) ||
1321
+ !headerValuePattern.test(nonce)
1322
+ ) {
1323
+ return res.status(403).json({
1324
+ error: "Verification Failed",
1325
+ message: "X-Agent-Signature requires a configured secret, identifier, timestamp, and nonce."
1326
+ });
1327
+ }
1328
+
1329
+ // Bound the timestamp string before Number() so a 10,000-digit value
1330
+ // never reaches the parser.
1331
+ const timestampSeconds = timestamp.length <= 20 ? Number(timestamp) : NaN;
1332
+ const timestampMs = timestampSeconds * 1000;
1333
+ const nowMs = Date.now();
1334
+ if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowMs - timestampMs) > signatureMaxAgeMs) {
1335
+ return res.status(403).json({
1336
+ error: "Verification Failed",
1337
+ message: "X-Agent-Signature is expired or has an invalid timestamp."
1338
+ });
1339
+ }
1340
+
1341
+ const replayKey = `${cacheNamespace}:legacy:${identifier}:${nonce}`;
1342
+
1343
+ let signaturePayload;
1344
+ try {
1345
+ signaturePayload = buildLegacySignaturePayload(req, identifier, timestamp, nonce);
1346
+ } catch {
1347
+ return res.status(400).json({
1348
+ error: "Invalid Request",
1349
+ message: "Request body cannot be serialized for signature verification."
1350
+ });
1351
+ }
1352
+ // Rotation window: a signature minted under the PREVIOUS secret
1353
+ // still verifies while merchants roll AGENTSBLOOM_SECRET. Per-agent
1354
+ // keys (V11) are checked first; the shared secret(s) remain the
1355
+ // fallback so existing agents keep working during migration.
1356
+ if (!verifySignature(signature, signaturePayload, effectiveSecret)
1357
+ && !(agentSecret && agentSecret !== effectiveSecret && verifySignature(signature, signaturePayload, agentSecret))
1358
+ && !(agentSecretPrevious && verifySignature(signature, signaturePayload, agentSecretPrevious))) {
1359
+ return res.status(403).json({
1360
+ error: "Verification Failed",
1361
+ message: "X-Agent-Signature is invalid. Access denied."
1362
+ });
1363
+ }
1364
+
1365
+ // v4: atomic claim AFTER successful verification - failed attempts
1366
+ // never burn the nonce, and concurrent replays cannot slip through
1367
+ // the old has()/set() race window.
1368
+ //
1369
+ // The record must outlive the window in which this signature could
1370
+ // still be accepted. `timestampMs + signatureMaxAgeMs` alone was too
1371
+ // short for a timestamp already near the edge of the window: the
1372
+ // nonce expired while the signature was still considered fresh,
1373
+ // re-opening replay. Floor it at now + the full window.
1374
+ const legacyRetainUntilMs = Math.max(
1375
+ timestampMs + signatureMaxAgeMs,
1376
+ nowMs + signatureMaxAgeMs,
1377
+ );
1378
+ const claimed = await signatureNonceMap.claim(replayKey, legacyRetainUntilMs);
1379
+ if (!claimed) {
1380
+ return res.status(403).json({
1381
+ error: "Verification Failed",
1382
+ message: "X-Agent-Signature nonce has already been used."
1383
+ });
1384
+ }
1385
+ authenticatedCacheIdentity = `legacy:${identifier}`;
1386
+ }
1387
+ }
1388
+
1389
+ // Expose the verified signature identity (rfc:<keyid> or legacy:<id>)
1390
+ // to merchant handlers: stores persist it on orders to correlate
1391
+ // purchases with the agent-reputation system. Null for unsigned
1392
+ // traffic - never trust the raw self-asserted header alone.
1393
+ req.agentIdentity = authenticatedCacheIdentity;
1394
+ req.agentSignatureProfile = signatureProfileUsed;
1395
+
1396
+ // --- MCP message delivery ---
1397
+ //
1398
+ // Routed here, AFTER the signature gate, so a tool call carries a verified
1399
+ // identity. Previously this path was never wired at all: `/mcp` handed the
1400
+ // client a `/mcp/messages` endpoint, but a POST to it fell straight through
1401
+ // to next(), so the MCP surface advertised in every discovery document
1402
+ // could not actually execute a tool.
1403
+ if (isMcpMessage) {
1404
+ const sessionId = typeof req.query?.sessionId === 'string' ? req.query.sessionId : null;
1405
+ const transport = sessionId ? mcpTransports.get(sessionId) : null;
1406
+ if (!transport) {
1407
+ return res.status(404).json({
1408
+ error: 'Unknown MCP Session',
1409
+ message: 'No open MCP SSE session matches this sessionId. Open GET /mcp first and reuse the endpoint it returns.',
1410
+ });
1411
+ }
1412
+ // Hand the verified identity to the tool-call handler, which runs in the
1413
+ // SSE request's closure and therefore cannot see this request directly.
1414
+ transport.__agentsbloomIdentity = authenticatedCacheIdentity;
1415
+ try {
1416
+ return await transport.handlePostMessage(req, res, req.body);
1417
+ } catch (err) {
1418
+ console.error('🌸 AgentsBloom: MCP message delivery failed:', err?.message);
1419
+ if (!res.headersSent) {
1420
+ return res.status(400).json({ error: 'Invalid MCP Message' });
1421
+ }
1422
+ return undefined;
1423
+ }
1424
+ }
1425
+
1426
+ // --- 3b. IDEMPOTENCY CHECKS FOR WRITES (POST/PUT/PATCH/DELETE) ---
1427
+ // Perform this lookup only after the protected-route authentication gate.
1428
+ // Cache keys are scoped to this middleware instance and the verified agent
1429
+ // identity so one caller cannot replay another caller's cached response.
1430
+ if (isWrite && idempotencyHeader) {
1431
+ const requestedIdentity = typeof req.headers['x-agent-identifier'] === 'string'
1432
+ ? req.headers['x-agent-identifier']
1433
+ : 'anonymous';
1434
+ const cacheIdentity = authenticatedCacheIdentity || `anonymous:${requestedIdentity}`;
1435
+ let serializedRequestBody;
1436
+ try {
1437
+ serializedRequestBody = JSON.stringify(req.body ?? null);
1438
+ } catch {
1439
+ return res.status(400).json({
1440
+ error: "Invalid Request",
1441
+ message: "Request body cannot be serialized for idempotency verification."
1442
+ });
1443
+ }
1444
+ const cacheKeyMaterial = [
1445
+ cacheNamespace,
1446
+ cacheIdentity,
1447
+ method,
1448
+ req.originalUrl || req.path,
1449
+ serializedRequestBody,
1450
+ idempotencyHeader,
1451
+ ].join('\u0000');
1452
+ idempotencyKey = `${cacheNamespace}:${crypto.createHash('sha256').update(cacheKeyMaterial).digest('hex')}`;
1453
+
1454
+ for (const [key, val] of idempotencyMap.entries()) {
1455
+ if (val.expiry < now) {
1456
+ idempotencyMap.delete(key);
1457
+ }
1458
+ }
1459
+
1460
+ const cachedResponse = idempotencyMap.get(idempotencyKey);
1461
+ if (cachedResponse) {
1462
+ res.setHeader('X-Cache', 'Idempotent-Hit');
1463
+ res.setHeader('Content-Type', cachedResponse.headers['content-type'] || 'application/json');
1464
+ return res.status(cachedResponse.status).send(cachedResponse.responseBody);
1465
+ }
1466
+ }
1467
+
1468
+ /**
1469
+ * Installs store-and-replay capture for the current Idempotency-Key.
1470
+ *
1471
+ * Previously this patch was installed ONLY inside the REST action-routing
1472
+ * branch, so `/ap2/checkout` and `/api/agentsbloom/checkout/acp` — the two
1473
+ * endpoints that mint payment links — accepted an Idempotency-Key, served
1474
+ * no replay for it, and re-ran the checkout handler on every retry.
1475
+ *
1476
+ * Safe to call more than once; the patch installs at most once per
1477
+ * response.
1478
+ */
1479
+ function captureIdempotentResponse(response) {
1480
+ if (!isWrite || !idempotencyKey || response.__agentsbloomIdempotencyPatched) return;
1481
+ response.__agentsbloomIdempotencyPatched = true;
1482
+ const originalSend = response.send;
1483
+ response.send = function patchedSend(body) {
1484
+ if (response.statusCode >= 200 && response.statusCode < 300) {
1485
+ // v4 (V18): evict the oldest entry at capacity instead of growing
1486
+ // without bound between TTL sweeps.
1487
+ if (!idempotencyMap.has(idempotencyKey) && idempotencyMap.size >= IDEMPOTENCY_MAX_ENTRIES) {
1488
+ const oldestKey = idempotencyMap.keys().next().value;
1489
+ if (oldestKey !== undefined) idempotencyMap.delete(oldestKey);
1490
+ }
1491
+ idempotencyMap.set(idempotencyKey, {
1492
+ responseBody: body,
1493
+ status: response.statusCode,
1494
+ headers: { 'content-type': response.getHeader('content-type') },
1495
+ timestamp: Date.now(),
1496
+ expiry: Date.now() + IDEMPOTENCY_TTL,
1497
+ });
1498
+ }
1499
+ return originalSend.apply(this, arguments);
1500
+ };
1501
+ }
1502
+
1503
+ // --- 4b. AP2 MANDATE VERIFICATION (Wired into middleware) ---
1504
+ // v4: the legacy `authorization` header fallback is now only honored on
1505
+ // requests that are ALREADY AP2-shaped (an /ap2 path or x-protocol AP2).
1506
+ // Previously ANY request carrying a dotted Authorization value - which
1507
+ // includes every ordinary OAuth/JWT bearer token - was parsed as an AP2
1508
+ // SD-JWT, failed verification, and had the whole request rejected 403,
1509
+ // breaking unrelated authenticated routes. The explicit
1510
+ // `x-ap2-mandate` header works everywhere as before.
1511
+ const detectedProtocol = resolveProtocol(req);
1512
+ const rawAuthorizationHeader = req.headers['authorization'];
1513
+ const ap2MandateHeader = req.headers['x-ap2-mandate']
1514
+ || (detectedProtocol === 'AP2' && typeof rawAuthorizationHeader === 'string'
1515
+ ? rawAuthorizationHeader
1516
+ : null);
1517
+ let ap2MandateResult = null;
1518
+
1519
+ if (detectedProtocol === 'AP2' || (ap2MandateHeader && ap2MandateHeader.includes('.'))) {
1520
+ // Second-pass review (N2): the mandate's single-use jti must only be
1521
+ // consumed where the mandate is actually USED. Verification still
1522
+ // runs wherever a mandate is presented (so merchant handlers keep
1523
+ // receiving req.ap2Mandate), but a mandate riding along on an
1524
+ // unrelated cart-add no longer burns itself.
1525
+ const isAp2Endpoint = req.path.startsWith('/ap2/') || req.path.startsWith('/v1/ap2/');
1526
+ // Cart Mandate binding: only the checkout consumer recomputes the
1527
+ // merchant's canonical cart hash. A merchant that configures
1528
+ // ap2.computeCartHash(req) gets a hard reject when a cart-bound
1529
+ // mandate is presented against different cart contents; without the
1530
+ // callback, binding stays 'unverified' (surfaced, never silent).
1531
+ const isAp2CheckoutPath = req.path === '/ap2/checkout' || req.path === '/v1/ap2/checkout';
1532
+ let expectedCartHash = null;
1533
+ let cartHashComputationFailed = false;
1534
+ if (isAp2CheckoutPath && typeof config.ap2?.computeCartHash === 'function') {
1535
+ try {
1536
+ expectedCartHash = config.ap2.computeCartHash(req) || null;
1537
+ } catch (err) {
1538
+ // A throwing hook used to be swallowed into `null`, which silently
1539
+ // downgraded a cart-bound mandate to 'unverified' exactly the
1540
+ // state the checkout gate is supposed to refuse. Record the failure
1541
+ // so the gate can reject instead of guessing.
1542
+ cartHashComputationFailed = true;
1543
+ expectedCartHash = null;
1544
+ console.error('🌸 AgentsBloom: ap2.computeCartHash threw; treating the session cart as unverifiable:', err?.message);
1545
+ }
1546
+ }
1547
+ // Verify the SD-JWT mandate before allowing any write action. Uses
1548
+ // either a merchant-configured trusted public key (config.ap2PublicKey)
1549
+ // or, when absent, derives a key from the mandate's own did:key issuer
1550
+ // (self-certifying - see lib/ap2.js for why an unverifiable mandate is
1551
+ // now rejected outright rather than passed through as "valid").
1552
+ ap2MandateResult = await verifyAP2Mandates(req.headers, req.body || {}, {
1553
+ trustedPublicKey: config.ap2PublicKey || null,
1554
+ // v4: production-grade trust policy. When ap2.trustedIssuersOnly is
1555
+ // set, self-certifying did:key mandates (which anyone can mint for
1556
+ // any budget) are rejected in favor of the merchant-trusted key.
1557
+ allowSelfCertifying: config.ap2?.trustedIssuersOnly !== true,
1558
+ // Audience binding prefers explicit configuration, then the
1559
+ // merchant-configured baseUrl (a trusted deployment constant), and
1560
+ // only falls back to the Host-derived requestUrl when neither is
1561
+ // set - deriving the expected audience purely from the request's
1562
+ // own Host header would let an attacker align a stolen mandate's
1563
+ // audience with a spoofed Host.
1564
+ expectedAudience: config.ap2?.expectedAudience || (baseUrl ? baseUrl : requestUrl),
1565
+ maxMandateLifetimeSec: config.ap2?.maxMandateLifetimeSec,
1566
+ requireJti: config.ap2?.requireJti,
1567
+ requestedCategories: req.body?.requestedCategories || config.ap2?.requestedCategories,
1568
+ expectedCurrency: config.ap2?.expectedCurrency,
1569
+ requireCurrency: config.ap2?.requireCurrency !== false,
1570
+ clockSkewSec: config.ap2?.clockSkewSec,
1571
+ consumeJti: isAp2Endpoint,
1572
+ ...(isAp2CheckoutPath ? { expectedCartHash } : {}),
1573
+ });
1574
+
1575
+ if (!ap2MandateResult.valid) {
1576
+ // Disclosure policy: an agent must be able to learn that its budget is
1577
+ // too low or its cart does not match, so it can self-correct. It must
1578
+ // NOT learn this store's expected audience or currency, which JWKS key
1579
+ // was missing, or raw crypto error text. lib/ap2.js classifies each
1580
+ // rejection; the HTTP layer honors that classification.
1581
+ const discloseReason = config.ap2?.exposeReasons === true || ap2MandateResult.disclose === true;
1582
+ if (!discloseReason) {
1583
+ console.error(
1584
+ `🌸 AgentsBloom: AP2 mandate rejected [${ap2MandateResult.code}]: ${ap2MandateResult.reason}`,
1585
+ );
1586
+ }
1587
+ return res.status(403).json({
1588
+ error: "AP2 Mandate Rejected",
1589
+ protocol: "AP2",
1590
+ code: ap2MandateResult.code,
1591
+ reason: discloseReason ? ap2MandateResult.reason : ap2MandateResult.publicReason,
1592
+ message: "The Verifiable Intent mandate failed validation. The agent's payment authorization is invalid."
1593
+ });
1594
+ }
1595
+
1596
+ // Attach mandate info to request for downstream handlers
1597
+ req.ap2Mandate = ap2MandateResult;
1598
+ ap2MandateResult.cartHashComputationFailed = cartHashComputationFailed;
1599
+ }
1600
+
1601
+ // --- AP2 Discovery Endpoint ---
1602
+ if (req.path === '/ap2/capabilities' || req.path === '/v1/ap2/capabilities') {
1603
+ return res.json({
1604
+ protocol: "AP2",
1605
+ version: "1.0.0",
1606
+ store: { name, description, baseUrl: requestUrl },
1607
+ mandateTypes: ["intentMandate", "cartMandate", "paymentMandate"],
1608
+ verificationMethods: ["sd-jwt", "jwt"],
1609
+ features: {
1610
+ budgetEnforcement: true,
1611
+ cartBinding: true,
1612
+ currencyBinding: true,
1613
+ replayProtection: true
1614
+ },
1615
+ endpoints: {
1616
+ capabilities: `${requestUrl}/ap2/capabilities`,
1617
+ intent: `${requestUrl}/ap2/intent`,
1618
+ checkout: `${requestUrl}/ap2/checkout`,
1619
+ actions: Object.keys(actions).map(k => `${requestUrl}/v1/agent/actions/${k}`)
1620
+ },
1621
+ budgetEnforcement: true,
1622
+ // Advertise what the verifier actually accepts. The old hardcoded list
1623
+ // claimed ES256/384/512 support while ECDSA verification was broken
1624
+ // outright (DER vs raw r||s), so an agent that followed the discovery
1625
+ // document could never authenticate.
1626
+ signatureAlgorithms: [...SUPPORTED_MANDATE_ALGORITHMS]
1627
+ });
1628
+ }
1629
+
1630
+ // --- AP2 Intent Endpoint (agent announces what it wants to do) ---
1631
+ if ((req.path === '/ap2/intent' || req.path === '/v1/ap2/intent') && req.method === 'POST') {
1632
+ // Must check `.verified`, not mere truthiness: ap2MandateResult is a
1633
+ // truthy object even when no mandate header was ever presented
1634
+ // (`{ valid: true, verified: false, note: '...' }`), so a bare
1635
+ // `if (!ap2MandateResult)` check would let unauthenticated requests
1636
+ // reach this mandate-gated endpoint.
1637
+ if (!ap2MandateResult?.verified) {
1638
+ return res.status(401).json({
1639
+ error: "AP2 Mandate Required",
1640
+ message: "Send x-ap2-mandate: Bearer <sd-jwt> header with a valid Intent Mandate."
1641
+ });
1642
+ }
1643
+ return res.json({
1644
+ protocol: "AP2",
1645
+ intentAccepted: true,
1646
+ mandateVerified: ap2MandateResult.verified,
1647
+ mandates: ap2MandateResult.mandates || {},
1648
+ availableActions: Object.entries(actions).map(([key, val]) => ({
1649
+ action: key,
1650
+ endpoint: `/v1/agent/actions/${key}`,
1651
+ method: val.method || 'POST',
1652
+ description: val.description
1653
+ })),
1654
+ budgetRemaining: ap2MandateResult.mandates?.intentMandate?.maxBudget || "unlimited"
1655
+ });
1656
+ }
1657
+
1658
+ // --- AP2 Checkout Endpoint (mandate-gated checkout) ---
1659
+ if ((req.path === '/ap2/checkout' || req.path === '/v1/ap2/checkout') && req.method === 'POST') {
1660
+ // Same fix as /ap2/intent above: this MUST require a genuinely
1661
+ // verified mandate, not just a truthy result object. Previously,
1662
+ // hitting /ap2/checkout with no x-ap2-mandate header at all still
1663
+ // produced a truthy `ap2MandateResult` (valid:true, verified:false),
1664
+ // which passed this check, then found no `.mandates.intentMandate`
1665
+ // to read a maxBudget from - so checkout proceeded with ZERO budget
1666
+ // enforcement. Requiring `.verified` closes that bypass.
1667
+ if (!ap2MandateResult?.verified) {
1668
+ return res.status(401).json({
1669
+ error: "AP2 Payment Mandate Required",
1670
+ message: "AP2 checkout requires x-ap2-mandate header with a valid Payment Mandate."
1671
+ });
1672
+ }
1673
+
1674
+ // Hardened Cart Mandate verification: If the mandate declares an exact
1675
+ // Cart Mandate (signed cartHash), the store MUST verify that cartHash
1676
+ // against the actual session cart. Unverified cart mandates are rejected
1677
+ // unless explicitly allowed by config (allowUnverifiedCartMandates: true).
1678
+ const hasDeclaredCartMandate = Boolean(ap2MandateResult.mandates?.cartMandate?.cartHash);
1679
+ if (
1680
+ hasDeclaredCartMandate &&
1681
+ ap2MandateResult.cartBinding === 'unverified' &&
1682
+ config.ap2?.allowUnverifiedCartMandates !== true
1683
+ ) {
1684
+ return res.status(403).json({
1685
+ error: "AP2 Cart Mandate Verification Required",
1686
+ protocol: "AP2",
1687
+ code: 'cart_binding_unverified',
1688
+ reason: ap2MandateResult.cartHashComputationFailed
1689
+ ? "This mandate is cryptographically bound to an exact cart hash, but this store's computeCartHash hook failed, so the session cart could not be verified."
1690
+ : "This mandate is cryptographically bound to an exact cart hash, but this store has not verified the session cart (configure config.ap2.computeCartHash).",
1691
+ message: "Cart Mandate verification failed: the store cannot confirm the session cart matches the signed mandate."
1692
+ });
1693
+ }
1694
+
1695
+ const checkoutAction = actions['checkout'];
1696
+ if (!checkoutAction || typeof checkoutAction.handler !== 'function') {
1697
+ return res.status(404).json({ error: 'Checkout action not configured for this store.' });
1698
+ }
1699
+
1700
+ // v4 medium pass (V12): mandate verification proves budget intent,
1701
+ // not identity authorization - the checkout action still respects
1702
+ // the merchant's per-action policy.
1703
+ const checkoutAccessRejection = authorizeAction('checkout', authenticatedCacheIdentity);
1704
+ if (checkoutAccessRejection) {
1705
+ return res.status(checkoutAccessRejection.status).json(checkoutAccessRejection.body);
1706
+ }
1707
+
1708
+ // The declared parameter contract was enforced on every REST action and
1709
+ // on MCP tool calls, but NOT here — the highest-value endpoint in the
1710
+ // SDK passed `req.body` to the handler unvalidated.
1711
+ const checkoutValidation = validateActionParams({ ...(req.body || {}) }, checkoutAction);
1712
+ if (!checkoutValidation.ok) {
1713
+ return res.status(400).json({
1714
+ error: "Invalid Parameter",
1715
+ protocol: "AP2",
1716
+ message: checkoutValidation.message,
1717
+ });
1718
+ }
1719
+
1720
+ // --- Budget enforcement ---
1721
+ //
1722
+ // A checkout mandate MUST declare a positive maxBudget: a capless
1723
+ // mandate authorizes unlimited spend.
1724
+ //
1725
+ // Note `??` rather than `||`. With `||`, an `orderTotal` of 0 fell
1726
+ // through to `total`, and a `maxBudget` of 0 fell through to the payment
1727
+ // mandate's — both silent value substitutions on a money field.
1728
+ const rawOrderTotal = req.body?.orderTotal ?? req.body?.total ?? 0;
1729
+ const rawMaxBudget = ap2MandateResult.mandates?.intentMandate?.maxBudget
1730
+ ?? ap2MandateResult.mandates?.paymentMandate?.maxBudget;
1731
+
1732
+ const maxBudgetAmount = parseAmount(rawMaxBudget, { allowZero: false });
1733
+ if (maxBudgetAmount === null) {
1734
+ return res.status(403).json({
1735
+ error: "AP2 Mandate Rejected",
1736
+ protocol: "AP2",
1737
+ code: 'budget_missing',
1738
+ reason: "Mandate must declare a positive intentMandate.maxBudget for checkout",
1739
+ message: "A checkout mandate without a spending cap authorizes unlimited spend and is rejected. Reissue the mandate with maxBudget."
1740
+ });
1741
+ }
1742
+
1743
+ // The client-declared total is a fast-fail convenience only. It is
1744
+ // attacker-controlled and never the basis of authorization — but it must
1745
+ // still be well-formed, because `Number('abc')` is NaN and every
1746
+ // comparison against NaN is false, so a malformed total used to PASS
1747
+ // this check instead of failing it.
1748
+ const clientTotalAmount = parseAmount(rawOrderTotal, { allowZero: true });
1749
+ if (clientTotalAmount === null) {
1750
+ return res.status(400).json({
1751
+ error: "Invalid Request",
1752
+ protocol: "AP2",
1753
+ code: 'order_total_invalid',
1754
+ message: "orderTotal must be a non-negative number.",
1755
+ });
1756
+ }
1757
+
1758
+ // v4 medium pass (V17): a budget number without a matching currency is
1759
+ // unitless. lib/ap2.js already rejects a mandate that declares a budget
1760
+ // with no currency (`requireCurrency`); these checks pin it to the
1761
+ // store's and the order's currency.
1762
+ const mandateCurrency = normalizeCurrencyCode(
1763
+ ap2MandateResult.mandates?.intentMandate?.currency
1764
+ ?? ap2MandateResult.mandates?.paymentMandate?.currency,
1765
+ );
1766
+ const expectedCheckoutCurrency = normalizeCurrencyCode(config.ap2?.expectedCurrency);
1767
+ if (mandateCurrency && expectedCheckoutCurrency && mandateCurrency !== expectedCheckoutCurrency) {
1768
+ // Echoing the store's configured currency back is a config disclosure;
1769
+ // log it, tell the caller only that there is a mismatch.
1770
+ console.error(
1771
+ `🌸 AgentsBloom: AP2 checkout currency mismatch: mandate "${mandateCurrency}" vs store "${expectedCheckoutCurrency}"`,
1772
+ );
1773
+ return res.status(403).json({
1774
+ error: "AP2 Mandate Rejected",
1775
+ protocol: "AP2",
1776
+ code: 'currency_mismatch_store',
1777
+ reason: config.ap2?.exposeReasons === true
1778
+ ? `Mandate currency "${mandateCurrency}" does not match this store's expected currency "${expectedCheckoutCurrency}"`
1779
+ : 'The mandate was issued for a different currency than this store accepts.',
1780
+ message: "The mandate was issued for a different currency than this store accepts."
1781
+ });
1782
+ }
1783
+ const orderCurrency = req.body?.currency === undefined || req.body?.currency === null
1784
+ ? null
1785
+ : normalizeCurrencyCode(req.body.currency);
1786
+ if (req.body?.currency !== undefined && req.body?.currency !== null && !orderCurrency) {
1787
+ return res.status(400).json({
1788
+ error: "Invalid Request",
1789
+ protocol: "AP2",
1790
+ code: 'order_currency_invalid',
1791
+ message: "currency must be a valid ISO 4217 alphabetic code.",
1792
+ });
1793
+ }
1794
+ if (mandateCurrency && orderCurrency && mandateCurrency !== orderCurrency) {
1795
+ return res.status(403).json({
1796
+ error: "AP2 Budget Exceeded",
1797
+ protocol: "AP2",
1798
+ code: 'currency_mismatch_order',
1799
+ reason: `Order currency "${orderCurrency}" does not match the mandate's currency "${mandateCurrency}"`,
1800
+ message: "Order currency does not match the mandate's declared currency."
1801
+ });
1802
+ }
1803
+
1804
+ // Exact comparison. `Number(a) > Number(b)` on doubles could accept a
1805
+ // total a fraction of a cent over the authorized cap.
1806
+ if (compareAmounts(clientTotalAmount.decimal, maxBudgetAmount.decimal) === 1) {
1807
+ return res.status(403).json({
1808
+ error: "AP2 Budget Exceeded",
1809
+ protocol: "AP2",
1810
+ code: 'budget_exceeded',
1811
+ orderTotal: Number(clientTotalAmount.decimal),
1812
+ maxBudget: Number(maxBudgetAmount.decimal),
1813
+ message: `Order total ${clientTotalAmount.decimal} exceeds mandate budget limit of ${maxBudgetAmount.decimal}.`
1814
+ });
1815
+ }
1816
+
1817
+ const issuerTrust = ap2MandateResult.selfCertifying ? 'self-certifying' : 'merchant-trusted';
1818
+ // Store-and-replay for the Idempotency-Key. Every REST action installed
1819
+ // this; /ap2/checkout did not, so a retried checkout re-ran the handler.
1820
+ captureIdempotentResponse(res);
1821
+
1822
+ return Promise.resolve(checkoutAction.handler(checkoutValidation.params, req, res))
1823
+ .then(result => {
1824
+ const rawServerTotal = result && (result.total ?? result.totalPrice);
1825
+ const serverTotalAmount = rawServerTotal === undefined || rawServerTotal === null
1826
+ ? null
1827
+ : parseAmount(rawServerTotal, { allowZero: true });
1828
+
1829
+ // --- The authoritative check ---
1830
+ //
1831
+ // This is the bypass that mattered most. The old code computed
1832
+ // effectiveTotal = serverTotal !== undefined ? Number(serverTotal) : Number(orderTotal)
1833
+ // and then only enforced the cap `if (Number.isFinite(effectiveTotal))`.
1834
+ // So a handler that returned no total at all, or a non-numeric one,
1835
+ // produced NaN, `Number.isFinite(NaN)` was false, and the budget
1836
+ // check was SKIPPED ENTIRELY — a payment URL was issued with zero
1837
+ // enforcement while the response still reported budgetEnforced:true.
1838
+ //
1839
+ // A total we cannot parse is now a hard failure, never a pass.
1840
+ if (serverTotalAmount === null) {
1841
+ if (config.ap2?.requireHandlerTotal === false) {
1842
+ // Explicit opt-out: fall back to the client-declared total,
1843
+ // which was already validated and compared above. Reported
1844
+ // honestly as such so nobody mistakes it for enforcement.
1845
+ if (!res.headersSent) {
1846
+ res.json(buildAp2CheckoutResponse({
1847
+ result,
1848
+ totalSource: 'client-declared',
1849
+ orderTotalDecimal: clientTotalAmount.decimal,
1850
+ }));
1851
+ }
1852
+ return undefined;
1853
+ }
1854
+ console.error(
1855
+ '🌸 AgentsBloom: the checkout handler returned no usable `total`/`totalPrice`, so the AP2 mandate budget '
1856
+ + `cannot be enforced (received: ${JSON.stringify(rawServerTotal)}). Return a numeric total, or set `
1857
+ + 'ap2.requireHandlerTotal:false to authorize against the client-declared total instead.',
1858
+ );
1859
+ if (!res.headersSent) {
1860
+ res.status(500).json({
1861
+ error: 'AP2 Checkout Misconfigured',
1862
+ protocol: 'AP2',
1863
+ code: 'handler_total_missing',
1864
+ message: 'This store could not compute an authoritative order total, so the mandate budget could not be enforced. No checkout link was issued.',
1865
+ });
1866
+ }
1867
+ return undefined;
1868
+ }
1869
+
1870
+ if (compareAmounts(serverTotalAmount.decimal, maxBudgetAmount.decimal) === 1) {
1871
+ if (!res.headersSent) {
1872
+ res.status(403).json({
1873
+ error: "AP2 Budget Exceeded",
1874
+ protocol: "AP2",
1875
+ code: 'budget_exceeded_server',
1876
+ reason: "Server-computed order total exceeds the mandate's maxBudget; the client-declared orderTotal is never authoritative.",
1877
+ orderTotal: Number(serverTotalAmount.decimal),
1878
+ totalSource: 'handler',
1879
+ maxBudget: Number(maxBudgetAmount.decimal),
1880
+ message: `Order total ${serverTotalAmount.decimal} computed by the store exceeds mandate budget limit of ${maxBudgetAmount.decimal}. No checkout link was issued.`
1881
+ });
1882
+ }
1883
+ return undefined;
1884
+ }
1885
+
1886
+ if (!res.headersSent) {
1887
+ res.json(buildAp2CheckoutResponse({
1888
+ result,
1889
+ totalSource: 'handler',
1890
+ orderTotalDecimal: serverTotalAmount.decimal,
1891
+ }));
1892
+ }
1893
+ return undefined;
1894
+ })
1895
+ .catch(err => {
1896
+ console.error("AP2 Checkout error:", err);
1897
+ if (!res.headersSent) {
1898
+ res.status(500).json({ error: 'Internal AP2 checkout error', protocol: 'AP2' });
1899
+ }
1900
+ });
1901
+
1902
+ /** Shapes the authorized-checkout response with honest evidence. */
1903
+ function buildAp2CheckoutResponse({ result, totalSource, orderTotalDecimal }) {
1904
+ return {
1905
+ protocol: "AP2",
1906
+ verifiableIntent: {
1907
+ mandateVerified: ap2MandateResult.verified,
1908
+ // Reports what actually happened rather than `!!maxBudget`: the
1909
+ // cap was compared against a store-computed total only when the
1910
+ // handler supplied one.
1911
+ budgetEnforced: totalSource === 'handler',
1912
+ issuerTrust,
1913
+ cartBinding: ap2MandateResult.cartBinding || 'absent',
1914
+ maxBudget: Number(maxBudgetAmount.decimal),
1915
+ currency: mandateCurrency || null,
1916
+ orderTotal: Number(orderTotalDecimal),
1917
+ totalSource,
1918
+ signatureIdentity: authenticatedCacheIdentity,
1919
+ ...(signatureProfileUsed ? { signatureProfile: signatureProfileUsed } : {}),
1920
+ },
1921
+ session_id: result?.sessionId || `ap2_sess_${crypto.randomUUID()}`,
1922
+ payment_url: result?.paymentUrl,
1923
+ status: "authorized",
1924
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
1925
+ checkout: result
1926
+ };
1927
+ }
1928
+ }
1929
+
1930
+ // --- 5. HANDLE ACTION ROUTING ---
1931
+ // Agentic Commerce Protocol (ACP) Checkout Wrapper
1932
+ //
1933
+ // This branch returns before the action-routing branch below, so it used
1934
+ // to skip BOTH `authorizeAction` and `validateActionParams`: a store that
1935
+ // configured `actionAccess.checkout = 'authenticated'` or restricted
1936
+ // `actionIdentities.checkout` had those policies silently ignored on this
1937
+ // path, and the handler received an unvalidated body. The RFC/legacy
1938
+ // signature gate did apply (the path is under /api/agentsbloom/), but
1939
+ // per-action authorization did not.
1940
+ if (req.path === '/api/agentsbloom/checkout/acp' && req.method === 'POST') {
1941
+ const checkoutAction = actions['checkout'];
1942
+ if (checkoutAction && typeof checkoutAction.handler === 'function') {
1943
+ const acpAccessRejection = authorizeAction('checkout', authenticatedCacheIdentity);
1944
+ if (acpAccessRejection) {
1945
+ return res.status(acpAccessRejection.status).json(acpAccessRejection.body);
1946
+ }
1947
+ const acpValidation = validateActionParams({ ...(req.body || {}) }, checkoutAction);
1948
+ if (!acpValidation.ok) {
1949
+ return res.status(400).json({ error: "Invalid Parameter", message: acpValidation.message });
1950
+ }
1951
+ captureIdempotentResponse(res);
1952
+ return Promise.resolve(checkoutAction.handler(acpValidation.params, req, res))
1953
+ .then(result => {
1954
+ if (!res.headersSent) {
1955
+ res.json({
1956
+ session_id: result?.sessionId || `acp_sess_${crypto.randomUUID()}`,
1957
+ payment_url: result?.paymentUrl,
1958
+ status: "open",
1959
+ expires_at: Math.floor(Date.now() / 1000) + 3600
1960
+ });
1961
+ }
1962
+ })
1963
+ .catch(err => {
1964
+ console.error(`ACP Checkout execution error:`, err);
1965
+ if (!res.headersSent) {
1966
+ res.status(500).json({ error: 'Internal ACP checkout error' });
1967
+ }
1968
+ });
1969
+ } else {
1970
+ return res.status(404).json({ error: 'ACP Checkout not configured for this store.' });
1971
+ }
1972
+ }
1973
+
1974
+ if (req.path.startsWith('/api/agentsbloom/') || req.path.startsWith('/v1/agent/actions/')) {
1975
+ const actionName = requestedActionName;
1976
+ const action = actions[actionName];
1977
+
1978
+ if (action && typeof action.handler === 'function') {
1979
+ const configuredMethod = String(action.method || 'POST').toUpperCase();
1980
+ if (method !== configuredMethod) {
1981
+ res.setHeader('Allow', configuredMethod);
1982
+ return res.status(405).json({
1983
+ error: "Method Not Allowed",
1984
+ message: `Action ${actionName} only accepts ${configuredMethod} requests.`,
1985
+ allowedMethod: configuredMethod,
1986
+ });
1987
+ }
1988
+
1989
+ // v4 medium pass (V12): per-action authorization - the SDK had no
1990
+ // authorization model at all; any verified identity could invoke
1991
+ // any write action including checkout.
1992
+ const accessRejection = authorizeAction(actionName, authenticatedCacheIdentity);
1993
+ if (accessRejection) {
1994
+ return res.status(accessRejection.status).json(accessRejection.body);
1995
+ }
1996
+
1997
+ const params = method === 'GET' ? { ...req.query } : { ...req.body };
1998
+
1999
+ // v4: shared with the MCP tools/call path so both surfaces enforce
2000
+ // the same declared parameter contract.
2001
+ const validation = validateActionParams(params, action);
2002
+ if (!validation.ok) {
2003
+ return res.status(400).json({ error: "Invalid Parameter", message: validation.message });
2004
+ }
2005
+ const validatedParams = validation.params;
2006
+
2007
+ captureIdempotentResponse(res);
2008
+
2009
+ const startTime = Date.now();
2010
+ // Telemetry labels must be bounded. Passing a raw agent-supplied header
2011
+ // through as an OTel metric dimension let a caller mint unbounded time
2012
+ // series inside the merchant's own monitoring pipeline. Prefer the
2013
+ // VERIFIED identity when we have one; fall back to a bounded rendering
2014
+ // of the self-asserted header.
2015
+ const agentName = boundedLabel(
2016
+ authenticatedCacheIdentity
2017
+ || req.headers['x-agent-identifier']
2018
+ || req.headers['signature-agent'],
2019
+ 'Unknown Agent',
2020
+ );
2021
+
2022
+ // Extract W3C Trace Context from incoming request (HIGH-16).
2023
+ // The span name is a metric dimension too: `actionName` comes from the
2024
+ // URL path, so bound it even though it matched a declared action.
2025
+ const parentContext = propagation.extract(context.active(), req.headers);
2026
+ const span = tracer.startSpan(`agent_request:${boundedLabel(actionName, 'unknown')}`, {}, parentContext);
2027
+
2028
+ return Promise.resolve(action.handler(validatedParams, req, res))
2029
+ .then(result => {
2030
+ if (!res.headersSent) {
2031
+ res.json(result);
2032
+ }
2033
+
2034
+ const latencyMs = Date.now() - startTime;
2035
+ // Revenue is a merchant-supplied number; a non-finite value would
2036
+ // poison the counter permanently.
2037
+ const revenueAmount = parseAmount(result?.totalPrice, { allowZero: false });
2038
+
2039
+ // OpenTelemetry Native Instrumentation
2040
+ span.setAttribute('agent.name', agentName);
2041
+ span.setAttribute('agent.route', `/api/agentsbloom/${boundedLabel(actionName, 'unknown')}`);
2042
+ span.setAttribute('http.status_code', res.statusCode);
2043
+ span.setAttribute('http.latency_ms', latencyMs);
2044
+
2045
+ agentRequestsCounter.add(1, { agent: agentName });
2046
+ if (revenueAmount) agentRevenueCounter.add(Number(revenueAmount.decimal), { agent: agentName });
2047
+ span.end();
2048
+
2049
+ // Telemetry is handled by OTLP exporters configured via setupTelemetry().
2050
+ // The span.end() call above will auto-export to the OTLP collector.
2051
+ })
2052
+ .catch(err => {
2053
+ span.setAttribute('error', true);
2054
+ span.end();
2055
+ console.error(`AgentsBloom action execution error (${actionName}):`, err);
2056
+ if (!res.headersSent) {
2057
+ res.status(500).json({ error: 'Internal agent endpoint error' });
2058
+ }
2059
+ });
2060
+ } else {
2061
+ return res.status(404).json({ error: `Unknown AgentsBloom action.` });
2062
+ }
2063
+ }
2064
+
2065
+ // --- 6. HTML INJECTION WITH COMPRESSION SUPPORT ---
2066
+ // Opt-out via config.disableHtmlInjection (MED-12)
2067
+ if (config.disableHtmlInjection) {
2068
+ return next();
2069
+ }
2070
+
2071
+ // --- HTML injection buffering limits ---
2072
+ //
2073
+ // This path buffers the ENTIRE HTML response in memory so it can splice in
2074
+ // JSON-LD and the WebMCP loader. Previously it did so with no size cap and
2075
+ // then called `zlib.gunzipSync` / `zlib.gzipSync`, which means:
2076
+ // * any large or streamed page was fully materialized in memory,
2077
+ // * a compressed upstream response was a decompression-amplification
2078
+ // vector (a few MB of gzip expands to gigabytes),
2079
+ // * the synchronous zlib calls blocked the event loop for every request,
2080
+ // * `res.write` always returned `true`, discarding backpressure.
2081
+ //
2082
+ // Now: buffering stops at a cap and the response passes through unmodified
2083
+ // beyond it, decompression is bounded by `maxOutputLength`, and the
2084
+ // recompression happens off the main path.
2085
+ const htmlInjectionMaxBytes = Number.isFinite(config.htmlInjection?.maxBytes) && config.htmlInjection.maxBytes > 0
2086
+ ? config.htmlInjection.maxBytes
2087
+ : 2 * 1024 * 1024;
2088
+
2089
+ const originalWrite = res.write;
2090
+ const originalEnd = res.end;
2091
+ let chunks = [];
2092
+ let bufferedBytes = 0;
2093
+ let isHtml = false;
2094
+ // Once the cap is exceeded we stop rewriting and flush what we hold,
2095
+ // becoming a transparent pass-through for the rest of the response.
2096
+ let passThrough = false;
2097
+
2098
+ const originalWriteHead = res.writeHead;
2099
+ res.writeHead = function (statusCode, headers) {
2100
+ const contentType = res.getHeader('Content-Type') || (headers && headers['content-type']) || '';
2101
+ if (typeof contentType === 'string' && contentType.includes('text/html')) {
2102
+ isHtml = true;
2103
+ res.removeHeader('Content-Length');
2104
+ if (headers) delete headers['content-length'];
2105
+ }
2106
+ return originalWriteHead.apply(this, arguments);
2107
+ };
2108
+
2109
+ /** Flushes buffered chunks and stops intercepting. */
2110
+ function abandonInjection() {
2111
+ passThrough = true;
2112
+ const pending = chunks;
2113
+ chunks = [];
2114
+ bufferedBytes = 0;
2115
+ for (const chunk of pending) originalWrite.call(res, chunk);
2116
+ }
2117
+
2118
+ res.write = function (chunk, ...rest) {
2119
+ const contentType = res.getHeader('Content-Type') || '';
2120
+ if (!passThrough && (isHtml || (typeof contentType === 'string' && contentType.includes('text/html')))) {
2121
+ isHtml = true;
2122
+ const buffered = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk ?? '');
2123
+ if (bufferedBytes + buffered.length > htmlInjectionMaxBytes) {
2124
+ // Too large to rewrite safely: flush and hand back control, so
2125
+ // backpressure and streaming behave normally from here on.
2126
+ abandonInjection();
2127
+ return originalWrite.call(res, buffered, ...rest);
2128
+ }
2129
+ chunks.push(buffered);
2130
+ bufferedBytes += buffered.length;
2131
+ return true;
2132
+ }
2133
+ return originalWrite.apply(res, arguments);
2134
+ };
2135
+
2136
+ res.end = function (chunk, ...rest) {
2137
+ const contentType = res.getHeader('Content-Type') || '';
2138
+ if (!passThrough && (isHtml || (typeof contentType === 'string' && contentType.includes('text/html')))) {
2139
+ isHtml = true;
2140
+ if (chunk) {
2141
+ const buffered = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2142
+ if (bufferedBytes + buffered.length > htmlInjectionMaxBytes) {
2143
+ abandonInjection();
2144
+ return originalEnd.call(res, buffered, ...rest);
2145
+ }
2146
+ chunks.push(buffered);
2147
+ bufferedBytes += buffered.length;
2148
+ }
2149
+
2150
+ let bodyBuffer = Buffer.concat(chunks);
2151
+ const encoding = res.getHeader('Content-Encoding');
2152
+ const isGzipped = typeof encoding === 'string' && encoding.includes('gzip');
2153
+
2154
+ // Decompress if gzipped, with a hard output bound so a compression
2155
+ // bomb cannot allocate unbounded memory.
2156
+ if (isGzipped) {
2157
+ try {
2158
+ bodyBuffer = zlib.gunzipSync(bodyBuffer, { maxOutputLength: htmlInjectionMaxBytes });
2159
+ } catch (err) {
2160
+ // Includes ERR_BUFFER_TOO_LARGE when the bound is hit: leave the
2161
+ // response exactly as the application produced it.
2162
+ console.error('AgentsBloom: skipping HTML injection (decompression failed or exceeded the size cap):', err?.message);
2163
+ return originalEnd.call(res, Buffer.concat(chunks), ...rest);
2164
+ }
2165
+ }
2166
+
2167
+ let body = bodyBuffer.toString('utf8');
2168
+
2169
+ if (body.toLowerCase().includes('</body>')) {
2170
+ // v4 (stored-XSS fix): merchant-controlled strings (store name,
2171
+ // description, action names/descriptions) are no longer spliced
2172
+ // into inline JavaScript or raw JSON.stringify output. JSON.stringify
2173
+ // does NOT escape `</script>`, so a crafted description used to be
2174
+ // able to break out of the script tag and execute on every page.
2175
+ // All dynamic data is now serialized through toSafeJson() (hex-
2176
+ // escaped `<`, `>`, `&`, U+2028/2029) and consumed as DATA by a
2177
+ // static loader - the same approach as the hardened next-sdk.
2178
+ const jsonLdData = {
2179
+ "@context": "https://schema.org",
2180
+ "@type": "WebPage",
2181
+ "name": name,
2182
+ "description": description,
2183
+ "potentialAction": Object.entries(actions).map(([key]) => ({
2184
+ "@type": "SearchAction",
2185
+ "name": key,
2186
+ "target": `${requestUrl}/api/agentsbloom/${key}`
2187
+ }))
2188
+ };
2189
+
2190
+ const jsonLdScript = `\n<script type="application/ld+json">\n${toSafeJson(jsonLdData)}\n</script>`;
2191
+
2192
+ const webMcpTools = Object.entries(actions).map(([key, val]) => ({
2193
+ name: key,
2194
+ description: val.description || '',
2195
+ method: String(val.method || 'POST').toUpperCase(),
2196
+ inputSchema: {
2197
+ type: 'object',
2198
+ properties: Object.fromEntries(
2199
+ Object.entries(val.params || {}).map(([pkey, pval]) => [pkey, { type: pval }])
2200
+ ),
2201
+ },
2202
+ }));
2203
+ const webMcpScript = `
2204
+ <meta name="webmcp" content="active">
2205
+ <script>
2206
+ // Auto-generated WebMCP Declarative Actions by AgentsBloom
2207
+ (function() {
2208
+ if (typeof navigator === 'undefined' || !navigator.ai || typeof navigator.ai.registerTool !== 'function') return;
2209
+ var tools = ${toSafeJson(webMcpTools)};
2210
+ for (var i = 0; i < tools.length; i++) {
2211
+ (function(tool) {
2212
+ navigator.ai.registerTool({
2213
+ name: tool.name,
2214
+ description: tool.description,
2215
+ inputSchema: tool.inputSchema,
2216
+ handler: async function(args) {
2217
+ try {
2218
+ var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
2219
+ var options = { method: tool.method, headers: headers };
2220
+ var url = '/api/agentsbloom/' + encodeURIComponent(tool.name);
2221
+ if (tool.method === 'GET') {
2222
+ var queryParams = new URLSearchParams(args).toString();
2223
+ if (queryParams) url += '?' + queryParams;
2224
+ } else {
2225
+ options.body = JSON.stringify(args);
2226
+ }
2227
+ var response = await fetch(url, options);
2228
+ return await response.json();
2229
+ } catch (e) {
2230
+ return { error: e.message };
2231
+ }
2232
+ }
2233
+ });
2234
+ })(tools[i]);
2235
+ }
2236
+ })();
2237
+ </script>
2238
+ `;
2239
+
2240
+ if (body.toLowerCase().includes('</head>')) {
2241
+ body = body.replace(/<\/head>/i, `${jsonLdScript}\n</head>`);
2242
+ } else {
2243
+ body = body + jsonLdScript;
2244
+ }
2245
+
2246
+ body = body.replace(/<\/body>/i, `${webMcpScript}\n</body>`);
2247
+ }
2248
+
2249
+ let outputBuffer = Buffer.from(body, 'utf8');
2250
+
2251
+ // Re-compress if gzipped. On failure, fall back to sending the
2252
+ // uncompressed body and drop the now-inaccurate Content-Encoding
2253
+ // rather than shipping a body the client cannot decode.
2254
+ if (isGzipped) {
2255
+ try {
2256
+ outputBuffer = zlib.gzipSync(outputBuffer);
2257
+ } catch (err) {
2258
+ console.error('AgentsBloom compression error:', err?.message);
2259
+ res.removeHeader('Content-Encoding');
2260
+ }
2261
+ }
2262
+
2263
+ res.setHeader('Content-Length', outputBuffer.length);
2264
+ originalWrite.call(res, outputBuffer);
2265
+ // Forward res.end's own arguments (encoding, completion callback).
2266
+ // Calling `originalEnd.call(res)` bare dropped the callback, so
2267
+ // frameworks awaiting response completion never resolved.
2268
+ return originalEnd.call(res, ...(chunk !== undefined ? rest : []));
2269
+ }
2270
+ return originalEnd.apply(res, arguments);
2271
+ };
2272
+
2273
+ next();
2274
+ };
2275
+ }
2276
+
2277
+ // --- UNIFIED PROTOCOL ROUTER ---
2278
+ export function resolveProtocol(req) {
2279
+ const accept = String((req.headers && req.headers['accept']) || '');
2280
+ const xProtocol = String((req.headers && req.headers['x-protocol']) || '').toLowerCase();
2281
+ const rawPath = String(req.path || req.url || '');
2282
+ // Strip any query string so `/ap2?x=1` classifies like `/ap2`.
2283
+ const path = rawPath.split('?')[0];
2284
+
2285
+ /**
2286
+ * Matches a path PREFIX on a segment boundary.
2287
+ *
2288
+ * `path.startsWith('/ap2')` also matched `/ap2foo` and `/ap2-internal`, so
2289
+ * unrelated merchant routes were classified as AP2 and inherited the
2290
+ * `Authorization`-header-as-mandate fallback — turning an ordinary bearer
2291
+ * token on such a route into a failed mandate and a 403.
2292
+ */
2293
+ const underSegment = (prefix) => path === prefix || path.startsWith(`${prefix}/`);
2294
+
2295
+ if (accept.includes('application/mcp+json') || underSegment('/mcp')) {
2296
+ return 'WEBMCP';
2297
+ }
2298
+ if (xProtocol === 'ucp' || underSegment('/.well-known/ucp') || underSegment('/ucp')) {
2299
+ return 'UCP';
2300
+ }
2301
+ if (xProtocol === 'acp' || underSegment('/acp')) {
2302
+ return 'ACP';
2303
+ }
2304
+ if ((req.headers && req.headers['x-ap2-mandate']) || underSegment('/ap2') || underSegment('/v1/ap2')) {
2305
+ return 'AP2';
2306
+ }
2307
+ return 'AGENTSBLOOM_REST';
2308
+ }
2309
+
2310
+ // --- AP2 (AGENT PAYMENTS PROTOCOL) SD-JWT MANDATE VERIFIER ---
2311
+ //
2312
+ // Thin, backward-compatible wrapper over the hardened implementation in
2313
+ // lib/ap2.js. The original signature `verifyAP2Mandates(headers, body,
2314
+ // publicKey)` treated an unsigned/unverifiable mandate as "valid but
2315
+ // unverified" and let downstream code (and, worse, the /ap2/checkout gate
2316
+ // itself - see the truthiness-check fix above) treat that as authorization
2317
+ // to check out. lib/ap2.js's verifyAp2Mandate instead REJECTS any mandate
2318
+ // it cannot cryptographically verify.
2319
+ //
2320
+ // Both call shapes are supported for backward compatibility:
2321
+ // verifyAP2Mandates(headers, body, publicKey) // legacy
2322
+ // verifyAP2Mandates(headers, body, { trustedPublicKey, expectedAudience, ... }) // current
2323
+ export function verifyAP2Mandates(headers = {}, body = {}, publicKeyOrOptions = null) {
2324
+ let options;
2325
+ if (publicKeyOrOptions === null || publicKeyOrOptions === undefined) {
2326
+ options = {};
2327
+ } else if (
2328
+ publicKeyOrOptions instanceof crypto.KeyObject ||
2329
+ Buffer.isBuffer(publicKeyOrOptions) ||
2330
+ typeof publicKeyOrOptions === 'string'
2331
+ ) {
2332
+ // Legacy call shape: third argument is a raw public key.
2333
+ options = { trustedPublicKey: publicKeyOrOptions };
2334
+ } else {
2335
+ // Current call shape: third argument is an options object.
2336
+ options = publicKeyOrOptions;
2337
+ }
2338
+ return verifyAp2Mandate(headers, body, options);
2339
+ }
2340
+
1993
2341
  export { createAp2Mandate, didKeyFromEd25519PublicKey, ed25519PublicKeyFromDidKey, resetAp2ReplayCache };
1994
- export { createOutcomeReporter, stripeEventToOutcome };
1995
-
2342
+ export { canonicalCartHash, stableStringify };
2343
+ export { createOutcomeReporter, stripeEventToOutcome };
2344
+ // `normalizeAudience` has been declared in index.d.ts all along but was never
2345
+ // re-exported here, so a TypeScript consumer that imported it got `undefined`
2346
+ // at runtime. Merchants need it to compute the audience string their configured
2347
+ // `baseUrl` will be compared against.
2348
+ export { normalizeAudience };
2349
+ // The accepted algorithm sets, so a merchant (or a port of this SDK) can assert
2350
+ // against the same lists the verifier enforces instead of hardcoding them.
2351
+ export { SUPPORTED_SIGNATURE_ALGORITHMS, SUPPORTED_MANDATE_ALGORITHMS };
2352
+