@agentsbloom/sdk 0.2.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/LICENSE +21 -21
- package/README.md +184 -254
- package/SECURITY.md +24 -19
- package/index.d.ts +355 -124
- package/index.js +1582 -342
- package/lib/ap2.js +1017 -422
- package/lib/http-signatures.js +874 -0
- package/lib/money.js +283 -0
- package/lib/outcomes.js +108 -0
- package/lib/protocol.d.ts +229 -0
- package/lib/protocol.js +85 -0
- package/lib/shared-store.js +298 -0
- package/lib/signature-base.js +436 -0
- package/lib/structured-fields.js +398 -0
- package/package.json +95 -81
- package/telemetry.js +77 -50
- package/assets/logo-mark.svg +0 -25
package/index.js
CHANGED
|
@@ -2,6 +2,11 @@ import zlib from 'zlib';
|
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
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';
|
|
5
10
|
import { trace, metrics, ValueType, context, propagation } from '@opentelemetry/api';
|
|
6
11
|
import { initExporter } from './telemetry.js';
|
|
7
12
|
import {
|
|
@@ -11,7 +16,26 @@ import {
|
|
|
11
16
|
ed25519PublicKeyFromDidKey,
|
|
12
17
|
resetAp2ReplayCache,
|
|
13
18
|
stopAp2ReplayCleanup,
|
|
19
|
+
canonicalCartHash,
|
|
20
|
+
stableStringify,
|
|
21
|
+
normalizeAudience,
|
|
22
|
+
SUPPORTED_MANDATE_ALGORITHMS,
|
|
14
23
|
} from './lib/ap2.js';
|
|
24
|
+
import { createReplayCache } from './lib/shared-store.js';
|
|
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';
|
|
15
39
|
|
|
16
40
|
const tracer = trace.getTracer('agentsbloom-sdk');
|
|
17
41
|
const meter = metrics.getMeter('agentsbloom-sdk');
|
|
@@ -37,25 +61,97 @@ export async function setupTelemetry(options = {}) {
|
|
|
37
61
|
apiKey = process.env.AGENTSBLOOM_API_KEY || '',
|
|
38
62
|
} = options;
|
|
39
63
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 };
|
|
47
90
|
|
|
48
91
|
const handle = await initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey });
|
|
49
92
|
globalThis.__agentsbloom_otel_handle = handle;
|
|
50
93
|
|
|
51
|
-
|
|
52
|
-
|
|
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 };
|
|
53
97
|
}
|
|
54
|
-
|
|
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).
|
|
55
104
|
const LEGACY_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
56
105
|
const RFC_SIGNATURE_CLOCK_SKEW_MS = 30 * 1000;
|
|
57
106
|
const SIGNATURE_REPLAY_CACHE_MAX_SIZE = 50_000;
|
|
58
|
-
const
|
|
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
|
+
}
|
|
59
155
|
|
|
60
156
|
function verifySignature(signature, payload, secret) {
|
|
61
157
|
try {
|
|
@@ -79,28 +175,87 @@ function buildLegacySignaturePayload(req, identifier, timestamp, nonce) {
|
|
|
79
175
|
]);
|
|
80
176
|
}
|
|
81
177
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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}` };
|
|
94
225
|
}
|
|
95
|
-
bodyBytes = Buffer.alloc(0);
|
|
96
226
|
}
|
|
97
|
-
|
|
98
|
-
const digest = crypto.createHash('sha256').update(bodyBytes).digest('base64');
|
|
99
|
-
return `sha-256=:${digest}:`;
|
|
227
|
+
return { ok: true, params };
|
|
100
228
|
}
|
|
101
229
|
|
|
102
230
|
// In-memory rate limiting map
|
|
103
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
|
+
}
|
|
104
259
|
const rateLimitInterval = setInterval(() => {
|
|
105
260
|
const now = Date.now();
|
|
106
261
|
for (const [key, val] of rateLimitMap.entries()) {
|
|
@@ -109,22 +264,337 @@ const rateLimitInterval = setInterval(() => {
|
|
|
109
264
|
}, 60 * 1000);
|
|
110
265
|
rateLimitInterval.unref();
|
|
111
266
|
|
|
112
|
-
// In-memory Idempotency cache
|
|
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).
|
|
113
271
|
const idempotencyMap = new Map();
|
|
114
|
-
const signatureNonceMap =
|
|
272
|
+
const signatureNonceMap = createReplayCache('agentsbloom:sig:nonce', SIGNATURE_REPLAY_CACHE_MAX_SIZE);
|
|
115
273
|
const idempotencyInterval = setInterval(() => {
|
|
116
274
|
const now = Date.now();
|
|
117
275
|
for (const [key, val] of idempotencyMap.entries()) {
|
|
118
276
|
if (val.expiry < now) idempotencyMap.delete(key);
|
|
119
277
|
}
|
|
120
|
-
for (const [key, expiresAt] of signatureNonceMap.entries()) {
|
|
121
|
-
if (expiresAt < now) signatureNonceMap.delete(key);
|
|
122
|
-
}
|
|
123
278
|
}, 60 * 1000);
|
|
124
279
|
idempotencyInterval.unref();
|
|
125
280
|
|
|
126
281
|
let otelShutdownPromise = null;
|
|
127
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
|
+
|
|
128
598
|
export async function shutdown() {
|
|
129
599
|
clearInterval(rateLimitInterval);
|
|
130
600
|
clearInterval(idempotencyInterval);
|
|
@@ -163,57 +633,281 @@ export function agentsbloom(config = {}) {
|
|
|
163
633
|
} = config;
|
|
164
634
|
|
|
165
635
|
if (!apiKey) {
|
|
166
|
-
console.error("
|
|
636
|
+
console.error("🌸 AgentsBloom SDK Error: Missing `apiKey`. You must provide an API Key to use the SDK. Get one at dashboard.agentsbloom.com");
|
|
167
637
|
}
|
|
168
638
|
if (!baseUrl) {
|
|
169
|
-
console.error("
|
|
639
|
+
console.error("🌸 AgentsBloom SDK Error: Missing `baseUrl` in config. This is required for secure telemetry.");
|
|
170
640
|
}
|
|
171
641
|
|
|
172
|
-
let quotaExceededUntil = 0;
|
|
173
|
-
|
|
174
642
|
const MAX_REQUESTS = config.rateLimit?.max || 30;
|
|
175
643
|
const RATE_LIMIT_WINDOW = config.rateLimit?.windowMs || 60 * 1000;
|
|
176
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;
|
|
177
650
|
const signatureMaxAgeMs = Number.isFinite(config.signature?.maxAgeMs) && config.signature.maxAgeMs > 0
|
|
178
651
|
? config.signature.maxAgeMs
|
|
179
652
|
: LEGACY_SIGNATURE_MAX_AGE_MS;
|
|
180
|
-
|
|
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
|
+
|
|
181
685
|
const configuredAgentSecret = config.agentSecret ?? process.env.AGENTSBLOOM_SECRET;
|
|
182
686
|
const agentSecret = typeof configuredAgentSecret === 'string' && configuredAgentSecret.length > 0
|
|
183
687
|
? configuredAgentSecret
|
|
184
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;
|
|
185
696
|
const signatureAuthEnabled = config.disableSignatureAuth !== true && config.demoMode !== true;
|
|
186
|
-
const cacheNamespace = crypto.randomUUID();
|
|
187
697
|
|
|
188
|
-
|
|
189
|
-
|
|
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.");
|
|
190
844
|
}
|
|
191
845
|
|
|
192
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;
|
|
193
854
|
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
194
|
-
if (contentLength >
|
|
195
|
-
return res.status(413).json({ error: "Payload Too Large", message:
|
|
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
|
+
});
|
|
196
870
|
}
|
|
197
871
|
|
|
198
872
|
if (req.path === '/health' && req.method === 'GET') {
|
|
199
|
-
return res.json({ status: "ok", version: "0.
|
|
873
|
+
return res.json({ status: "ok", version: "0.4.0", uptime: process.uptime() });
|
|
200
874
|
}
|
|
201
875
|
|
|
202
|
-
const
|
|
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`);
|
|
203
887
|
const ip = req.ip || req.socket.remoteAddress || '127.0.0.1';
|
|
204
888
|
const now = Date.now();
|
|
205
889
|
|
|
206
890
|
// --- CORS HEADERS ---
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|
|
209
906
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
210
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');
|
|
211
|
-
if (req.method === 'OPTIONS') {
|
|
212
|
-
return res.status(204).end();
|
|
213
|
-
}
|
|
214
908
|
|
|
215
909
|
// --- SaaS QUOTA ENFORCEMENT (Zero Latency Cache) ---
|
|
216
|
-
if (now <
|
|
910
|
+
if (now < quotaExceededUntilMs) {
|
|
217
911
|
res.setHeader('Content-Type', 'application/json');
|
|
218
912
|
return res.status(402).json({
|
|
219
913
|
error: "AgentsBloom Quota Exceeded. Please upgrade your API plan to continue serving AI Agents.",
|
|
@@ -222,10 +916,28 @@ export function agentsbloom(config = {}) {
|
|
|
222
916
|
}
|
|
223
917
|
|
|
224
918
|
// --- 1. DDoS PROTECTION (RATE LIMITING) ---
|
|
225
|
-
|
|
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;
|
|
226
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
|
+
}
|
|
227
939
|
limit = { count: 1, resetTime: now + RATE_LIMIT_WINDOW };
|
|
228
|
-
rateLimitMap.set(
|
|
940
|
+
rateLimitMap.set(rateKeyNormalized, limit);
|
|
229
941
|
} else {
|
|
230
942
|
limit.count++;
|
|
231
943
|
}
|
|
@@ -245,6 +957,12 @@ export function agentsbloom(config = {}) {
|
|
|
245
957
|
});
|
|
246
958
|
}
|
|
247
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
|
+
|
|
248
966
|
// --- 2. SERVE SPEC ENDPOINTS ---
|
|
249
967
|
|
|
250
968
|
// Serve /.well-known/agent-spec & /v1/agent/spec (API Versioning)
|
|
@@ -255,7 +973,7 @@ export function agentsbloom(config = {}) {
|
|
|
255
973
|
name,
|
|
256
974
|
description,
|
|
257
975
|
version: "1.0.0",
|
|
258
|
-
agentsbloomVersion: "0.
|
|
976
|
+
agentsbloomVersion: "0.4.0",
|
|
259
977
|
discoveryUrl: `${requestUrl}/v1/agent/spec`,
|
|
260
978
|
catalogUrl: `${requestUrl}/v1/agent/catalog`,
|
|
261
979
|
llmsUrl: `${requestUrl}/llms.txt`,
|
|
@@ -278,24 +996,20 @@ export function agentsbloom(config = {}) {
|
|
|
278
996
|
}
|
|
279
997
|
|
|
280
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.
|
|
281
1003
|
if (req.path === '/.well-known/http-message-signatures-directory') {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
} else {
|
|
287
|
-
console.warn("🌸 AgentsBloom Warning: Serving placeholder JWKS. Provide `merchantJwks` in config for real keys.");
|
|
288
|
-
return res.json({
|
|
289
|
-
keys: [
|
|
290
|
-
{
|
|
291
|
-
kty: "OKP",
|
|
292
|
-
crv: "Ed25519",
|
|
293
|
-
kid: "placeholder-merchant-key",
|
|
294
|
-
x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPPRwX0"
|
|
295
|
-
}
|
|
296
|
-
]
|
|
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.'
|
|
297
1008
|
});
|
|
298
1009
|
}
|
|
1010
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1011
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
1012
|
+
return res.json(config.merchantJwks);
|
|
299
1013
|
}
|
|
300
1014
|
|
|
301
1015
|
// Serve /.well-known/ucp (Universal Commerce Protocol Profile)
|
|
@@ -313,9 +1027,20 @@ export function agentsbloom(config = {}) {
|
|
|
313
1027
|
],
|
|
314
1028
|
endpoints: {
|
|
315
1029
|
catalog: `${requestUrl}/ai-catalog.json`,
|
|
1030
|
+
search: `${requestUrl}/api/agentsbloom/search`,
|
|
1031
|
+
products: `${requestUrl}/api/agentsbloom/products`,
|
|
316
1032
|
cart: `${requestUrl}/api/agentsbloom/cart`,
|
|
317
1033
|
checkout: `${requestUrl}/api/agentsbloom/checkout/acp`
|
|
318
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
|
+
}, {}),
|
|
319
1044
|
auth: {
|
|
320
1045
|
methods: ["http-message-signatures", "x-agent-signature"]
|
|
321
1046
|
}
|
|
@@ -344,13 +1069,68 @@ export function agentsbloom(config = {}) {
|
|
|
344
1069
|
});
|
|
345
1070
|
}
|
|
346
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
|
+
|
|
347
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.
|
|
348
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
|
+
}
|
|
349
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);
|
|
350
1131
|
const mcpServer = new Server({ name: name, version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
351
|
-
|
|
352
1132
|
// Auto-generate MCP tool declarations from actions
|
|
353
|
-
mcpServer.setRequestHandler(
|
|
1133
|
+
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
354
1134
|
tools: Object.entries(actions).map(([key, val]) => ({
|
|
355
1135
|
name: key,
|
|
356
1136
|
description: val.description,
|
|
@@ -364,22 +1144,51 @@ export function agentsbloom(config = {}) {
|
|
|
364
1144
|
}))
|
|
365
1145
|
}));
|
|
366
1146
|
|
|
367
|
-
mcpServer.setRequestHandler(
|
|
1147
|
+
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
368
1148
|
const action = actions[request.params.name];
|
|
369
1149
|
if (!action) throw new Error(`Tool not found: ${request.params.name}`);
|
|
370
|
-
|
|
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);
|
|
371
1171
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
372
1172
|
});
|
|
373
1173
|
|
|
374
|
-
|
|
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
|
+
}
|
|
375
1183
|
}
|
|
376
1184
|
|
|
377
1185
|
// Serve /llms.txt
|
|
378
1186
|
if (req.path === '/llms.txt') {
|
|
379
1187
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
380
1188
|
res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
|
|
381
|
-
const defaultLlmDoc = `# ${name}\n\n${description}\n\n## Developer API Reference\n\n- GET /.well-known/agent-spec : Spec sheets\n- GET /ai-catalog.json : Catalog schemas\n`;
|
|
382
|
-
|
|
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);
|
|
383
1192
|
}
|
|
384
1193
|
|
|
385
1194
|
// --- 3. IDEMPOTENCY METADATA FOR WRITES (POST/PUT/PATCH/DELETE) ---
|
|
@@ -388,14 +1197,39 @@ export function agentsbloom(config = {}) {
|
|
|
388
1197
|
const rawIdempotencyKey = req.headers['idempotency-key'];
|
|
389
1198
|
const idempotencyHeader = typeof rawIdempotencyKey === 'string' ? rawIdempotencyKey : null;
|
|
390
1199
|
let idempotencyKey = null;
|
|
391
|
-
|
|
1200
|
+
// `authenticatedCacheIdentity` / `signatureProfileUsed` are declared above
|
|
1201
|
+
// the /mcp branch so its tool-call closure can read them.
|
|
392
1202
|
|
|
393
1203
|
// --- 4. CRYPTOGRAPHIC CAPTCHA BYPASSING (Web Bot Auth + Legacy HMAC) ---
|
|
394
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
|
+
);
|
|
395
1217
|
const isAgentAction = req.path.startsWith('/api/agentsbloom/')
|
|
396
1218
|
|| req.path.startsWith('/v1/agent/actions/')
|
|
397
|
-
|| isMcpMessage
|
|
398
|
-
|
|
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) {
|
|
399
1233
|
const signature = req.headers['x-agent-signature'];
|
|
400
1234
|
const identifier = req.headers['x-agent-identifier'];
|
|
401
1235
|
const timestamp = req.headers['x-agent-timestamp'];
|
|
@@ -421,134 +1255,60 @@ export function agentsbloom(config = {}) {
|
|
|
421
1255
|
}
|
|
422
1256
|
|
|
423
1257
|
if (hasRfcSignature) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const expiresSeconds = Number(expiresMatch[1]);
|
|
446
|
-
const createdMs = createdSeconds * 1000;
|
|
447
|
-
const expiresMs = expiresSeconds * 1000;
|
|
448
|
-
const nowMs = Date.now();
|
|
449
|
-
if (
|
|
450
|
-
!Number.isSafeInteger(createdSeconds) ||
|
|
451
|
-
!Number.isSafeInteger(expiresSeconds) ||
|
|
452
|
-
expiresSeconds <= createdSeconds ||
|
|
453
|
-
createdMs > nowMs + RFC_SIGNATURE_CLOCK_SKEW_MS ||
|
|
454
|
-
createdMs < nowMs - signatureMaxAgeMs ||
|
|
455
|
-
expiresMs < nowMs - RFC_SIGNATURE_CLOCK_SKEW_MS ||
|
|
456
|
-
expiresMs - createdMs > signatureMaxAgeMs
|
|
457
|
-
) {
|
|
458
|
-
throw new Error('HTTP Message Signature is expired or outside the allowed lifetime');
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const rfcNonce = nonceMatch[1];
|
|
462
|
-
if (!/^[\x21-\x7e]{16,256}$/.test(rfcNonce)) {
|
|
463
|
-
throw new Error('HTTP Message Signature nonce must be a printable value of 16 to 256 characters');
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
const contentDigest = req.headers['content-digest'];
|
|
467
|
-
if (typeof contentDigest !== 'string' || contentDigest !== buildRequestContentDigest(req)) {
|
|
468
|
-
throw new Error('HTTP Message Signature content-digest does not match the request body');
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const alg = algMatch[1];
|
|
472
|
-
const rfcReplayKey = `${cacheNamespace}:rfc:${keyid}:${rfcNonce}`;
|
|
473
|
-
|
|
474
|
-
if (/^https?:\/\//i.test(keyid)) {
|
|
475
|
-
throw new Error("Remote keyid URLs are not accepted; configure a trusted JWKS and use its exact key id");
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const trustedJwks = config.agentJwks;
|
|
479
|
-
const trustedJwksUrl = config.agentJwksUrl || DEFAULT_RFC_JWKS_URL;
|
|
480
|
-
const cacheKey = `${cacheNamespace}:${trustedJwks ? 'inline' : trustedJwksUrl}:${keyid}`;
|
|
481
|
-
let publicKey = null;
|
|
482
|
-
const cached = jwksCache.get(cacheKey);
|
|
483
|
-
if (cached && cached.expires > Date.now()) {
|
|
484
|
-
publicKey = cached.key;
|
|
485
|
-
} else {
|
|
486
|
-
let jwks;
|
|
487
|
-
if (trustedJwks) {
|
|
488
|
-
jwks = trustedJwks;
|
|
489
|
-
} else {
|
|
490
|
-
const parsedJwksUrl = new URL(trustedJwksUrl);
|
|
491
|
-
if (parsedJwksUrl.protocol !== 'https:') {
|
|
492
|
-
throw new Error("Configured agentJwksUrl must use HTTPS");
|
|
493
|
-
}
|
|
494
|
-
const jwksRes = await fetch(parsedJwksUrl.href, {
|
|
495
|
-
redirect: 'error',
|
|
496
|
-
signal: AbortSignal.timeout(5000),
|
|
497
|
-
});
|
|
498
|
-
if (!jwksRes.ok) throw new Error(`JWKS request failed with HTTP ${jwksRes.status}`);
|
|
499
|
-
jwks = await jwksRes.json();
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (!Array.isArray(jwks?.keys)) throw new Error("Configured JWKS is invalid");
|
|
503
|
-
const jwk = jwks.keys.find((candidate) => candidate && candidate.kid === keyid);
|
|
504
|
-
if (!jwk) throw new Error("Configured JWKS does not contain the requested key id");
|
|
505
|
-
publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
|
506
|
-
jwksCache.set(cacheKey, { key: publicKey, expires: Date.now() + 3600 * 1000 });
|
|
507
|
-
}
|
|
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
|
+
});
|
|
508
1279
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const signatureBuffer = Buffer.from(rawSig, 'base64');
|
|
522
|
-
|
|
523
|
-
const hashAlg = alg.includes('sha512') ? 'SHA512' : 'SHA256';
|
|
524
|
-
|
|
525
|
-
const isValid = crypto.verify(hashAlg, Buffer.from(signatureBase), publicKey, signatureBuffer);
|
|
526
|
-
|
|
527
|
-
if (!isValid) return res.status(403).json({ error: "Forbidden", message: "Invalid HTTP Message Signature." });
|
|
528
|
-
for (const [replayKey, expiresAt] of signatureNonceMap.entries()) {
|
|
529
|
-
if (expiresAt < nowMs) signatureNonceMap.delete(replayKey);
|
|
530
|
-
}
|
|
531
|
-
if (signatureNonceMap.has(rfcReplayKey)) {
|
|
532
|
-
return res.status(403).json({
|
|
533
|
-
error: "Forbidden",
|
|
534
|
-
message: "HTTP Message Signature nonce has already been used."
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
if (signatureNonceMap.size >= SIGNATURE_REPLAY_CACHE_MAX_SIZE) {
|
|
538
|
-
return res.status(503).json({
|
|
539
|
-
error: "Verification Unavailable",
|
|
540
|
-
message: "Signature replay protection is temporarily at capacity."
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
signatureNonceMap.set(rfcReplayKey, expiresMs);
|
|
544
|
-
authenticatedCacheIdentity = `rfc:${keyid}`;
|
|
545
|
-
} catch (err) {
|
|
546
|
-
return res.status(403).json({ error: "Forbidden", message: "Signature verification failed: " + err.message });
|
|
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
|
+
});
|
|
547
1292
|
}
|
|
1293
|
+
authenticatedCacheIdentity = verification.identity;
|
|
1294
|
+
signatureProfileUsed = verification.profile;
|
|
548
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
|
+
|
|
549
1309
|
const headerValuePattern = /^[\x21-\x7e]+$/;
|
|
550
1310
|
if (
|
|
551
|
-
!
|
|
1311
|
+
!effectiveSecret ||
|
|
552
1312
|
typeof signature !== 'string' ||
|
|
553
1313
|
typeof identifier !== 'string' ||
|
|
554
1314
|
typeof timestamp !== 'string' ||
|
|
@@ -566,7 +1326,9 @@ export function agentsbloom(config = {}) {
|
|
|
566
1326
|
});
|
|
567
1327
|
}
|
|
568
1328
|
|
|
569
|
-
|
|
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;
|
|
570
1332
|
const timestampMs = timestampSeconds * 1000;
|
|
571
1333
|
const nowMs = Date.now();
|
|
572
1334
|
if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowMs - timestampMs) > signatureMaxAgeMs) {
|
|
@@ -577,12 +1339,6 @@ export function agentsbloom(config = {}) {
|
|
|
577
1339
|
}
|
|
578
1340
|
|
|
579
1341
|
const replayKey = `${cacheNamespace}:legacy:${identifier}:${nonce}`;
|
|
580
|
-
if (signatureNonceMap.has(replayKey)) {
|
|
581
|
-
return res.status(403).json({
|
|
582
|
-
error: "Verification Failed",
|
|
583
|
-
message: "X-Agent-Signature nonce has already been used."
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
1342
|
|
|
587
1343
|
let signaturePayload;
|
|
588
1344
|
try {
|
|
@@ -593,27 +1349,80 @@ export function agentsbloom(config = {}) {
|
|
|
593
1349
|
message: "Request body cannot be serialized for signature verification."
|
|
594
1350
|
});
|
|
595
1351
|
}
|
|
596
|
-
|
|
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))) {
|
|
597
1359
|
return res.status(403).json({
|
|
598
1360
|
error: "Verification Failed",
|
|
599
1361
|
message: "X-Agent-Signature is invalid. Access denied."
|
|
600
1362
|
});
|
|
601
1363
|
}
|
|
602
1364
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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."
|
|
610
1383
|
});
|
|
611
1384
|
}
|
|
612
|
-
signatureNonceMap.set(replayKey, timestampMs + signatureMaxAgeMs);
|
|
613
1385
|
authenticatedCacheIdentity = `legacy:${identifier}`;
|
|
614
1386
|
}
|
|
615
1387
|
}
|
|
616
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
|
+
|
|
617
1426
|
// --- 3b. IDEMPOTENCY CHECKS FOR WRITES (POST/PUT/PATCH/DELETE) ---
|
|
618
1427
|
// Perform this lookup only after the protected-route authentication gate.
|
|
619
1428
|
// Cache keys are scoped to this middleware instance and the verified agent
|
|
@@ -650,43 +1459,143 @@ export function agentsbloom(config = {}) {
|
|
|
650
1459
|
|
|
651
1460
|
const cachedResponse = idempotencyMap.get(idempotencyKey);
|
|
652
1461
|
if (cachedResponse) {
|
|
653
|
-
console.log('🌸 AgentsBloom: Found cached response for an Idempotency Key');
|
|
654
1462
|
res.setHeader('X-Cache', 'Idempotent-Hit');
|
|
655
1463
|
res.setHeader('Content-Type', cachedResponse.headers['content-type'] || 'application/json');
|
|
656
1464
|
return res.status(cachedResponse.status).send(cachedResponse.responseBody);
|
|
657
1465
|
}
|
|
658
1466
|
}
|
|
659
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
|
+
|
|
660
1503
|
// --- 4b. AP2 MANDATE VERIFICATION (Wired into middleware) ---
|
|
661
|
-
|
|
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.
|
|
662
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);
|
|
663
1517
|
let ap2MandateResult = null;
|
|
664
1518
|
|
|
665
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
|
+
}
|
|
666
1547
|
// Verify the SD-JWT mandate before allowing any write action. Uses
|
|
667
1548
|
// either a merchant-configured trusted public key (config.ap2PublicKey)
|
|
668
1549
|
// or, when absent, derives a key from the mandate's own did:key issuer
|
|
669
1550
|
// (self-certifying - see lib/ap2.js for why an unverifiable mandate is
|
|
670
1551
|
// now rejected outright rather than passed through as "valid").
|
|
671
|
-
ap2MandateResult = verifyAP2Mandates(req.headers, req.body || {}, {
|
|
1552
|
+
ap2MandateResult = await verifyAP2Mandates(req.headers, req.body || {}, {
|
|
672
1553
|
trustedPublicKey: config.ap2PublicKey || null,
|
|
673
|
-
|
|
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),
|
|
674
1565
|
maxMandateLifetimeSec: config.ap2?.maxMandateLifetimeSec,
|
|
675
1566
|
requireJti: config.ap2?.requireJti,
|
|
676
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 } : {}),
|
|
677
1573
|
});
|
|
678
1574
|
|
|
679
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
|
+
}
|
|
680
1587
|
return res.status(403).json({
|
|
681
1588
|
error: "AP2 Mandate Rejected",
|
|
682
1589
|
protocol: "AP2",
|
|
683
|
-
|
|
1590
|
+
code: ap2MandateResult.code,
|
|
1591
|
+
reason: discloseReason ? ap2MandateResult.reason : ap2MandateResult.publicReason,
|
|
684
1592
|
message: "The Verifiable Intent mandate failed validation. The agent's payment authorization is invalid."
|
|
685
1593
|
});
|
|
686
1594
|
}
|
|
687
1595
|
|
|
688
1596
|
// Attach mandate info to request for downstream handlers
|
|
689
1597
|
req.ap2Mandate = ap2MandateResult;
|
|
1598
|
+
ap2MandateResult.cartHashComputationFailed = cartHashComputationFailed;
|
|
690
1599
|
}
|
|
691
1600
|
|
|
692
1601
|
// --- AP2 Discovery Endpoint ---
|
|
@@ -697,6 +1606,12 @@ export function agentsbloom(config = {}) {
|
|
|
697
1606
|
store: { name, description, baseUrl: requestUrl },
|
|
698
1607
|
mandateTypes: ["intentMandate", "cartMandate", "paymentMandate"],
|
|
699
1608
|
verificationMethods: ["sd-jwt", "jwt"],
|
|
1609
|
+
features: {
|
|
1610
|
+
budgetEnforcement: true,
|
|
1611
|
+
cartBinding: true,
|
|
1612
|
+
currencyBinding: true,
|
|
1613
|
+
replayProtection: true
|
|
1614
|
+
},
|
|
700
1615
|
endpoints: {
|
|
701
1616
|
capabilities: `${requestUrl}/ap2/capabilities`,
|
|
702
1617
|
intent: `${requestUrl}/ap2/intent`,
|
|
@@ -704,7 +1619,11 @@ export function agentsbloom(config = {}) {
|
|
|
704
1619
|
actions: Object.keys(actions).map(k => `${requestUrl}/v1/agent/actions/${k}`)
|
|
705
1620
|
},
|
|
706
1621
|
budgetEnforcement: true,
|
|
707
|
-
|
|
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]
|
|
708
1627
|
});
|
|
709
1628
|
}
|
|
710
1629
|
|
|
@@ -752,44 +1671,226 @@ export function agentsbloom(config = {}) {
|
|
|
752
1671
|
});
|
|
753
1672
|
}
|
|
754
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
|
+
|
|
755
1695
|
const checkoutAction = actions['checkout'];
|
|
756
1696
|
if (!checkoutAction || typeof checkoutAction.handler !== 'function') {
|
|
757
1697
|
return res.status(404).json({ error: 'Checkout action not configured for this store.' });
|
|
758
1698
|
}
|
|
759
1699
|
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
+
}
|
|
764
1742
|
|
|
765
|
-
|
|
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) {
|
|
766
1807
|
return res.status(403).json({
|
|
767
1808
|
error: "AP2 Budget Exceeded",
|
|
768
1809
|
protocol: "AP2",
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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}.`
|
|
772
1814
|
});
|
|
773
1815
|
}
|
|
774
1816
|
|
|
775
|
-
|
|
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))
|
|
776
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
|
+
|
|
777
1886
|
if (!res.headersSent) {
|
|
778
|
-
res.json({
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
maxBudget: maxBudget || null,
|
|
784
|
-
orderTotal: result.total || orderTotal
|
|
785
|
-
},
|
|
786
|
-
session_id: result.sessionId || `ap2_sess_${Date.now()}`,
|
|
787
|
-
payment_url: result.paymentUrl,
|
|
788
|
-
status: "authorized",
|
|
789
|
-
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
790
|
-
checkout: result
|
|
791
|
-
});
|
|
1887
|
+
res.json(buildAp2CheckoutResponse({
|
|
1888
|
+
result,
|
|
1889
|
+
totalSource: 'handler',
|
|
1890
|
+
orderTotalDecimal: serverTotalAmount.decimal,
|
|
1891
|
+
}));
|
|
792
1892
|
}
|
|
1893
|
+
return undefined;
|
|
793
1894
|
})
|
|
794
1895
|
.catch(err => {
|
|
795
1896
|
console.error("AP2 Checkout error:", err);
|
|
@@ -797,19 +1898,63 @@ export function agentsbloom(config = {}) {
|
|
|
797
1898
|
res.status(500).json({ error: 'Internal AP2 checkout error', protocol: 'AP2' });
|
|
798
1899
|
}
|
|
799
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
|
+
}
|
|
800
1928
|
}
|
|
801
1929
|
|
|
802
1930
|
// --- 5. HANDLE ACTION ROUTING ---
|
|
803
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.
|
|
804
1940
|
if (req.path === '/api/agentsbloom/checkout/acp' && req.method === 'POST') {
|
|
805
1941
|
const checkoutAction = actions['checkout'];
|
|
806
1942
|
if (checkoutAction && typeof checkoutAction.handler === 'function') {
|
|
807
|
-
|
|
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))
|
|
808
1953
|
.then(result => {
|
|
809
1954
|
if (!res.headersSent) {
|
|
810
1955
|
res.json({
|
|
811
|
-
session_id: result
|
|
812
|
-
payment_url: result
|
|
1956
|
+
session_id: result?.sessionId || `acp_sess_${crypto.randomUUID()}`,
|
|
1957
|
+
payment_url: result?.paymentUrl,
|
|
813
1958
|
status: "open",
|
|
814
1959
|
expires_at: Math.floor(Date.now() / 1000) + 3600
|
|
815
1960
|
});
|
|
@@ -827,7 +1972,7 @@ export function agentsbloom(config = {}) {
|
|
|
827
1972
|
}
|
|
828
1973
|
|
|
829
1974
|
if (req.path.startsWith('/api/agentsbloom/') || req.path.startsWith('/v1/agent/actions/')) {
|
|
830
|
-
const actionName =
|
|
1975
|
+
const actionName = requestedActionName;
|
|
831
1976
|
const action = actions[actionName];
|
|
832
1977
|
|
|
833
1978
|
if (action && typeof action.handler === 'function') {
|
|
@@ -841,55 +1986,64 @@ export function agentsbloom(config = {}) {
|
|
|
841
1986
|
});
|
|
842
1987
|
}
|
|
843
1988
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
}
|
|
851
|
-
}
|
|
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);
|
|
852
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;
|
|
853
2006
|
|
|
854
|
-
|
|
855
|
-
const originalSend = res.send;
|
|
856
|
-
res.send = function (body) {
|
|
857
|
-
if (isWrite && idempotencyKey && res.statusCode >= 200 && res.statusCode < 300) {
|
|
858
|
-
idempotencyMap.set(idempotencyKey, {
|
|
859
|
-
responseBody: body,
|
|
860
|
-
status: res.statusCode,
|
|
861
|
-
headers: { 'content-type': res.getHeader('content-type') },
|
|
862
|
-
timestamp: Date.now(),
|
|
863
|
-
expiry: Date.now() + IDEMPOTENCY_TTL
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
return originalSend.apply(this, arguments);
|
|
867
|
-
};
|
|
2007
|
+
captureIdempotentResponse(res);
|
|
868
2008
|
|
|
869
2009
|
const startTime = Date.now();
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
//
|
|
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.
|
|
873
2025
|
const parentContext = propagation.extract(context.active(), req.headers);
|
|
874
|
-
const span = tracer.startSpan(`agent_request:${actionName}`, {}, parentContext);
|
|
875
|
-
|
|
876
|
-
return Promise.resolve(action.handler(
|
|
2026
|
+
const span = tracer.startSpan(`agent_request:${boundedLabel(actionName, 'unknown')}`, {}, parentContext);
|
|
2027
|
+
|
|
2028
|
+
return Promise.resolve(action.handler(validatedParams, req, res))
|
|
877
2029
|
.then(result => {
|
|
878
2030
|
if (!res.headersSent) {
|
|
879
2031
|
res.json(result);
|
|
880
2032
|
}
|
|
881
2033
|
|
|
882
2034
|
const latencyMs = Date.now() - startTime;
|
|
883
|
-
|
|
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 });
|
|
884
2038
|
|
|
885
2039
|
// OpenTelemetry Native Instrumentation
|
|
886
2040
|
span.setAttribute('agent.name', agentName);
|
|
887
|
-
span.setAttribute('agent.route', `/api/agentsbloom/${actionName}`);
|
|
2041
|
+
span.setAttribute('agent.route', `/api/agentsbloom/${boundedLabel(actionName, 'unknown')}`);
|
|
888
2042
|
span.setAttribute('http.status_code', res.statusCode);
|
|
889
2043
|
span.setAttribute('http.latency_ms', latencyMs);
|
|
890
|
-
|
|
2044
|
+
|
|
891
2045
|
agentRequestsCounter.add(1, { agent: agentName });
|
|
892
|
-
if (
|
|
2046
|
+
if (revenueAmount) agentRevenueCounter.add(Number(revenueAmount.decimal), { agent: agentName });
|
|
893
2047
|
span.end();
|
|
894
2048
|
|
|
895
2049
|
// Telemetry is handled by OTLP exporters configured via setupTelemetry().
|
|
@@ -914,10 +2068,32 @@ export function agentsbloom(config = {}) {
|
|
|
914
2068
|
return next();
|
|
915
2069
|
}
|
|
916
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
|
+
|
|
917
2089
|
const originalWrite = res.write;
|
|
918
2090
|
const originalEnd = res.end;
|
|
919
2091
|
let chunks = [];
|
|
2092
|
+
let bufferedBytes = 0;
|
|
920
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;
|
|
921
2097
|
|
|
922
2098
|
const originalWriteHead = res.writeHead;
|
|
923
2099
|
res.writeHead = function (statusCode, headers) {
|
|
@@ -930,96 +2106,132 @@ export function agentsbloom(config = {}) {
|
|
|
930
2106
|
return originalWriteHead.apply(this, arguments);
|
|
931
2107
|
};
|
|
932
2108
|
|
|
933
|
-
|
|
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) {
|
|
934
2119
|
const contentType = res.getHeader('Content-Type') || '';
|
|
935
|
-
if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
|
|
2120
|
+
if (!passThrough && (isHtml || (typeof contentType === 'string' && contentType.includes('text/html')))) {
|
|
936
2121
|
isHtml = true;
|
|
937
|
-
|
|
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;
|
|
938
2131
|
return true;
|
|
939
2132
|
}
|
|
940
2133
|
return originalWrite.apply(res, arguments);
|
|
941
2134
|
};
|
|
942
2135
|
|
|
943
|
-
res.end = function (chunk) {
|
|
2136
|
+
res.end = function (chunk, ...rest) {
|
|
944
2137
|
const contentType = res.getHeader('Content-Type') || '';
|
|
945
|
-
if (isHtml || (typeof contentType === 'string' && contentType.includes('text/html'))) {
|
|
2138
|
+
if (!passThrough && (isHtml || (typeof contentType === 'string' && contentType.includes('text/html')))) {
|
|
946
2139
|
isHtml = true;
|
|
947
2140
|
if (chunk) {
|
|
948
|
-
|
|
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;
|
|
949
2148
|
}
|
|
950
|
-
|
|
2149
|
+
|
|
951
2150
|
let bodyBuffer = Buffer.concat(chunks);
|
|
952
2151
|
const encoding = res.getHeader('Content-Encoding');
|
|
953
2152
|
const isGzipped = typeof encoding === 'string' && encoding.includes('gzip');
|
|
954
2153
|
|
|
955
|
-
// Decompress if gzipped
|
|
2154
|
+
// Decompress if gzipped, with a hard output bound so a compression
|
|
2155
|
+
// bomb cannot allocate unbounded memory.
|
|
956
2156
|
if (isGzipped) {
|
|
957
2157
|
try {
|
|
958
|
-
bodyBuffer = zlib.gunzipSync(bodyBuffer);
|
|
2158
|
+
bodyBuffer = zlib.gunzipSync(bodyBuffer, { maxOutputLength: htmlInjectionMaxBytes });
|
|
959
2159
|
} catch (err) {
|
|
960
|
-
|
|
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);
|
|
961
2164
|
}
|
|
962
2165
|
}
|
|
963
2166
|
|
|
964
2167
|
let body = bodyBuffer.toString('utf8');
|
|
965
2168
|
|
|
966
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.
|
|
967
2178
|
const jsonLdData = {
|
|
968
2179
|
"@context": "https://schema.org",
|
|
969
2180
|
"@type": "WebPage",
|
|
970
2181
|
"name": name,
|
|
971
2182
|
"description": description,
|
|
972
|
-
"potentialAction": Object.entries(actions).map(([key
|
|
2183
|
+
"potentialAction": Object.entries(actions).map(([key]) => ({
|
|
973
2184
|
"@type": "SearchAction",
|
|
974
2185
|
"name": key,
|
|
975
2186
|
"target": `${requestUrl}/api/agentsbloom/${key}`
|
|
976
2187
|
}))
|
|
977
2188
|
};
|
|
978
2189
|
|
|
979
|
-
const jsonLdScript = `\n<script type="application/ld+json">\n${
|
|
980
|
-
|
|
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
|
+
}));
|
|
981
2203
|
const webMcpScript = `
|
|
982
2204
|
<meta name="webmcp" content="active">
|
|
983
2205
|
<script>
|
|
984
2206
|
// Auto-generated WebMCP Declarative Actions by AgentsBloom
|
|
985
2207
|
(function() {
|
|
986
|
-
if (typeof navigator
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
const queryParams = new URLSearchParams(args).toString();
|
|
1010
|
-
if (queryParams) url += '?' + queryParams;
|
|
1011
|
-
} else {
|
|
1012
|
-
fetchOptions.body = JSON.stringify(args);
|
|
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 };
|
|
1013
2231
|
}
|
|
1014
|
-
|
|
1015
|
-
const response = await fetch(url, fetchOptions);
|
|
1016
|
-
return await response.json();
|
|
1017
|
-
} catch (e) {
|
|
1018
|
-
return { error: e.message };
|
|
1019
2232
|
}
|
|
1020
|
-
}
|
|
1021
|
-
});
|
|
1022
|
-
`).join('\n')}
|
|
2233
|
+
});
|
|
2234
|
+
})(tools[i]);
|
|
1023
2235
|
}
|
|
1024
2236
|
})();
|
|
1025
2237
|
</script>
|
|
@@ -1034,20 +2246,26 @@ export function agentsbloom(config = {}) {
|
|
|
1034
2246
|
body = body.replace(/<\/body>/i, `${webMcpScript}\n</body>`);
|
|
1035
2247
|
}
|
|
1036
2248
|
|
|
1037
|
-
let outputBuffer = Buffer.from(body);
|
|
2249
|
+
let outputBuffer = Buffer.from(body, 'utf8');
|
|
1038
2250
|
|
|
1039
|
-
// Re-compress if gzipped
|
|
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.
|
|
1040
2254
|
if (isGzipped) {
|
|
1041
2255
|
try {
|
|
1042
2256
|
outputBuffer = zlib.gzipSync(outputBuffer);
|
|
1043
2257
|
} catch (err) {
|
|
1044
|
-
console.error(
|
|
2258
|
+
console.error('AgentsBloom compression error:', err?.message);
|
|
2259
|
+
res.removeHeader('Content-Encoding');
|
|
1045
2260
|
}
|
|
1046
2261
|
}
|
|
1047
2262
|
|
|
1048
2263
|
res.setHeader('Content-Length', outputBuffer.length);
|
|
1049
2264
|
originalWrite.call(res, outputBuffer);
|
|
1050
|
-
|
|
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 : []));
|
|
1051
2269
|
}
|
|
1052
2270
|
return originalEnd.apply(res, arguments);
|
|
1053
2271
|
};
|
|
@@ -1058,20 +2276,32 @@ export function agentsbloom(config = {}) {
|
|
|
1058
2276
|
|
|
1059
2277
|
// --- UNIFIED PROTOCOL ROUTER ---
|
|
1060
2278
|
export function resolveProtocol(req) {
|
|
1061
|
-
const accept = (req.headers && req.headers['accept']) || '';
|
|
1062
|
-
const xProtocol = (req.headers && req.headers['x-protocol']) || '';
|
|
1063
|
-
const
|
|
1064
|
-
|
|
1065
|
-
|
|
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')) {
|
|
1066
2296
|
return 'WEBMCP';
|
|
1067
2297
|
}
|
|
1068
|
-
if (xProtocol
|
|
2298
|
+
if (xProtocol === 'ucp' || underSegment('/.well-known/ucp') || underSegment('/ucp')) {
|
|
1069
2299
|
return 'UCP';
|
|
1070
2300
|
}
|
|
1071
|
-
if (xProtocol
|
|
2301
|
+
if (xProtocol === 'acp' || underSegment('/acp')) {
|
|
1072
2302
|
return 'ACP';
|
|
1073
2303
|
}
|
|
1074
|
-
if (req.headers &&
|
|
2304
|
+
if ((req.headers && req.headers['x-ap2-mandate']) || underSegment('/ap2') || underSegment('/v1/ap2')) {
|
|
1075
2305
|
return 'AP2';
|
|
1076
2306
|
}
|
|
1077
2307
|
return 'AGENTSBLOOM_REST';
|
|
@@ -1109,4 +2339,14 @@ export function verifyAP2Mandates(headers = {}, body = {}, publicKeyOrOptions =
|
|
|
1109
2339
|
}
|
|
1110
2340
|
|
|
1111
2341
|
export { createAp2Mandate, didKeyFromEd25519PublicKey, ed25519PublicKeyFromDidKey, resetAp2ReplayCache };
|
|
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 };
|
|
1112
2352
|
|