@agentsbloom/sdk 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ap2.js ADDED
@@ -0,0 +1,422 @@
1
+ import crypto from 'crypto';
2
+
3
+ /**
4
+ * Hardened AP2 (Agent Payments Protocol) mandate verification.
5
+ *
6
+ * This module replaces the previous "valid but unverified" bypass in
7
+ * packages/sdk/index.js's verifyAP2Mandates: a mandate that could not be
8
+ * cryptographically verified was still returned as `{ valid: true,
9
+ * verified: false }` and the checkout gate only checked `!ap2MandateResult`
10
+ * / budget, never `.verified`. That meant any attacker could hand-craft an
11
+ * unsigned (or badly-signed) mandate claiming an arbitrary maxBudget and
12
+ * have it treated as authorization to check out.
13
+ *
14
+ * The rules enforced here:
15
+ * 1. If no mandate header is present at all, AP2 simply isn't in use for
16
+ * this request - that's still `{ valid: true, verified: false }` so
17
+ * non-AP2 traffic (REST/ACP) is never blocked by AP2 logic.
18
+ * 2. If a mandate header IS present, it MUST verify successfully or the
19
+ * request is rejected outright (`valid: false`). There is no
20
+ * "valid but unverified" middle ground once a mandate is presented.
21
+ * 3. Signature verification uses either (a) a merchant-configured
22
+ * trusted public key (out-of-band trust, any of ES256/384/512,
23
+ * RS256/384/512, EdDSA), or (b) self-certifying did:key issuers
24
+ * (Ed25519/EdDSA only - the public key IS the issuer identifier, so
25
+ * no pre-registration is needed for arbitrary agents/wallets).
26
+ * 4. Mandates must carry a unique `jti` and are checked against a
27
+ * bounded in-memory replay cache - a captured valid mandate cannot be
28
+ * replayed to trigger a second charge.
29
+ * 5. `aud` is checked against the caller-supplied expected audience
30
+ * (the merchant's own base URL) when one is provided, so a mandate
31
+ * signed for store A cannot be replayed against store B.
32
+ * 6. Mandate lifetime (`exp - iat`) is bounded, so a "valid forever"
33
+ * mandate cannot be crafted even with a real signature.
34
+ */
35
+
36
+ // --- base58btc (Bitcoin alphabet) - no external dependency ---
37
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
38
+ const BASE58_MAP = new Map(BASE58_ALPHABET.split('').map((c, i) => [c, i]));
39
+
40
+ /** @param {Buffer} buffer @returns {string} */
41
+ function base58Encode(buffer) {
42
+ if (buffer.length === 0) return '';
43
+ let value = 0n;
44
+ for (const byte of buffer) value = value * 256n + BigInt(byte);
45
+
46
+ let encoded = '';
47
+ while (value > 0n) {
48
+ const remainder = value % 58n;
49
+ value = value / 58n;
50
+ encoded = BASE58_ALPHABET[Number(remainder)] + encoded;
51
+ }
52
+
53
+ let leadingZeros = 0;
54
+ for (const byte of buffer) {
55
+ if (byte === 0) leadingZeros++;
56
+ else break;
57
+ }
58
+ return BASE58_ALPHABET[0].repeat(leadingZeros) + encoded;
59
+ }
60
+
61
+ /** @param {string} str @returns {Buffer} */
62
+ function base58Decode(str) {
63
+ if (str.length === 0) return Buffer.alloc(0);
64
+ let value = 0n;
65
+ for (const char of str) {
66
+ const digit = BASE58_MAP.get(char);
67
+ if (digit === undefined) throw new Error(`Invalid base58 character: ${char}`);
68
+ value = value * 58n + BigInt(digit);
69
+ }
70
+
71
+ const bytes = [];
72
+ while (value > 0n) {
73
+ bytes.unshift(Number(value % 256n));
74
+ value = value / 256n;
75
+ }
76
+
77
+ let leadingZeros = 0;
78
+ for (const char of str) {
79
+ if (char === BASE58_ALPHABET[0]) leadingZeros++;
80
+ else break;
81
+ }
82
+ return Buffer.concat([Buffer.alloc(leadingZeros, 0), Buffer.from(bytes)]);
83
+ }
84
+
85
+ // Ed25519 multicodec (0xed) varint-encoded as [0xed, 0x01], per the did:key
86
+ // Ed25519 method spec (multicodec ed25519-pub prefix).
87
+ const ED25519_MULTICODEC_PREFIX = Buffer.from([0xed, 0x01]);
88
+
89
+ // Fixed 12-byte SPKI DER prefix for Ed25519 public keys (RFC 8410) - Ed25519
90
+ // has no algorithm parameters, so the DER encoding of any Ed25519 SPKI key
91
+ // is always this fixed prefix followed by the raw 32-byte public key.
92
+ const ED25519_SPKI_DER_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
93
+
94
+ /**
95
+ * Derives a did:key identifier from an Ed25519 public key.
96
+ * @param {crypto.KeyObject|Buffer} publicKey - a Node KeyObject or raw 32-byte public key
97
+ * @returns {string} e.g. "did:key:z6Mk..."
98
+ */
99
+ export function didKeyFromEd25519PublicKey(publicKey) {
100
+ const rawKey = Buffer.isBuffer(publicKey)
101
+ ? publicKey
102
+ : publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
103
+ const prefixed = Buffer.concat([ED25519_MULTICODEC_PREFIX, rawKey]);
104
+ return `did:key:z${base58Encode(prefixed)}`;
105
+ }
106
+
107
+ /**
108
+ * Derives an Ed25519 public KeyObject from a did:key identifier.
109
+ * @param {string} didKey - e.g. "did:key:z6Mk..."
110
+ * @returns {crypto.KeyObject|null} null if the identifier is not a
111
+ * well-formed Ed25519 did:key (caller treats this as "cannot verify")
112
+ */
113
+ export function ed25519PublicKeyFromDidKey(didKey) {
114
+ if (typeof didKey !== 'string' || !didKey.startsWith('did:key:z')) return null;
115
+ try {
116
+ const multibase = didKey.slice('did:key:'.length);
117
+ const decoded = base58Decode(multibase.slice(1)); // drop leading 'z' multibase prefix
118
+ if (
119
+ decoded.length !== ED25519_MULTICODEC_PREFIX.length + 32 ||
120
+ !decoded.subarray(0, 2).equals(ED25519_MULTICODEC_PREFIX)
121
+ ) {
122
+ return null;
123
+ }
124
+ const rawKey = decoded.subarray(2);
125
+ const der = Buffer.concat([ED25519_SPKI_DER_PREFIX, rawKey]);
126
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // --- Replay protection ---
133
+ // A bounded, TTL-cleaned cache of mandate `jti`s that have already been
134
+ // consumed. Sized generously; entries are removed once the mandate they
135
+ // belonged to would have expired anyway, so the cache cannot grow forever
136
+ // even under sustained attack traffic.
137
+ const seenMandateJtis = new Map(); // jti -> expiresAtMs
138
+ const REPLAY_CACHE_MAX_SIZE = 50_000;
139
+
140
+ const replayCleanupInterval = setInterval(() => {
141
+ const now = Date.now();
142
+ for (const [jti, expiresAtMs] of seenMandateJtis.entries()) {
143
+ if (expiresAtMs < now) seenMandateJtis.delete(jti);
144
+ }
145
+ }, 60 * 1000);
146
+ replayCleanupInterval.unref?.();
147
+
148
+ /** Clears all replay-tracking state. Exposed for tests and shutdown(). */
149
+ export function resetAp2ReplayCache() {
150
+ seenMandateJtis.clear();
151
+ }
152
+
153
+ /** Stops the background cleanup timer. Called from index.js's shutdown(). */
154
+ export function stopAp2ReplayCleanup() {
155
+ clearInterval(replayCleanupInterval);
156
+ }
157
+
158
+ function isReplay(jti, expMs) {
159
+ if (seenMandateJtis.has(jti)) return true;
160
+ if (seenMandateJtis.size < REPLAY_CACHE_MAX_SIZE) {
161
+ seenMandateJtis.set(jti, expMs);
162
+ }
163
+ return false;
164
+ }
165
+
166
+ // --- Signature verification helpers ---
167
+
168
+ /**
169
+ * Algorithms accepted when the merchant has configured an explicit,
170
+ * out-of-band-trusted public key. The merchant already trusts this key by
171
+ * configuring it, so we support the same breadth of algorithms the
172
+ * original implementation advertised.
173
+ */
174
+ const EXPLICIT_KEY_ALLOWED_ALGS = new Set(['EdDSA', 'ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512']);
175
+
176
+ function hashAlgForJwtAlg(alg) {
177
+ if (alg === 'EdDSA') return null; // Ed25519/Ed448 signature algorithm is built into the key
178
+ if (alg.includes('384')) return 'SHA384';
179
+ if (alg.includes('512')) return 'SHA512';
180
+ return 'SHA256';
181
+ }
182
+
183
+ /**
184
+ * Verifies an AP2 mandate SD-JWT presented in an incoming request.
185
+ *
186
+ * @param {Record<string, string>} [headers]
187
+ * @param {Record<string, unknown>} [body]
188
+ * @param {object} [options]
189
+ * @param {crypto.KeyObject|Buffer|string|null} [options.trustedPublicKey] - merchant-configured
190
+ * out-of-band-trusted key (any format accepted by crypto.createPublicKey, or an
191
+ * already-constructed KeyObject). When set, this key is used instead of deriving
192
+ * one from a did:key issuer, and the broader algorithm set is permitted.
193
+ * @param {string} [options.expectedAudience] - if set, `aud` must equal this value
194
+ * @param {number} [options.maxMandateLifetimeSec=3600] - maximum allowed `exp - iat`
195
+ * @param {boolean} [options.requireJti=true] - reject mandates without a `jti` claim
196
+ * @param {string[]} [options.requestedCategories] - if set and the mandate's
197
+ * `intentMandate.allowedCategories` is present, the two must overlap
198
+ * @returns {{ valid: boolean, verified: boolean, protocol: 'AP2', reason?: string, mandates?: object }}
199
+ */
200
+ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
201
+ const {
202
+ trustedPublicKey = null,
203
+ expectedAudience = null,
204
+ maxMandateLifetimeSec = 3600,
205
+ requireJti = true,
206
+ requestedCategories = null,
207
+ } = options;
208
+
209
+ const mandateHeader = headers['x-ap2-mandate'] || headers['authorization'];
210
+ if (!mandateHeader) {
211
+ // AP2 simply isn't in use for this request - not a security decision.
212
+ return { valid: true, protocol: 'AP2', verified: false, note: 'No AP2 Mandates attached' };
213
+ }
214
+
215
+ let header, payload, signatureB64;
216
+ try {
217
+ const token = String(mandateHeader).replace(/^Bearer\s+/i, '');
218
+ const parts = token.split('.');
219
+ if (parts.length < 3) return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
220
+ header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
221
+ payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
222
+ signatureB64 = parts[2];
223
+ } catch {
224
+ return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
225
+ }
226
+
227
+ // --- Required claims present ---
228
+ if (!payload.iss) return { valid: false, reason: 'Missing issuer', protocol: 'AP2' };
229
+ if (!payload.aud) return { valid: false, reason: 'Missing audience', protocol: 'AP2' };
230
+ if (typeof payload.iat !== 'number') return { valid: false, reason: 'Missing iat', protocol: 'AP2' };
231
+ if (typeof payload.exp !== 'number') return { valid: false, reason: 'Missing exp', protocol: 'AP2' };
232
+ if (requireJti && !payload.jti) {
233
+ return { valid: false, reason: 'Missing jti (required for replay protection)', protocol: 'AP2' };
234
+ }
235
+
236
+ // --- Temporal validity ---
237
+ const now = Math.floor(Date.now() / 1000);
238
+ if (payload.exp < now) return { valid: false, reason: 'Token expired', protocol: 'AP2' };
239
+ if (payload.iat > now) return { valid: false, reason: 'Token issued in the future', protocol: 'AP2' };
240
+
241
+ // --- Bounded mandate lifetime ---
242
+ if (payload.exp - payload.iat > maxMandateLifetimeSec) {
243
+ return { valid: false, reason: `Mandate lifetime exceeds maximum allowed (${maxMandateLifetimeSec}s)`, protocol: 'AP2' };
244
+ }
245
+
246
+ // --- Resolve verification key + algorithm policy ---
247
+ let publicKey = null;
248
+ let usingSelfCertifyingKey = false;
249
+
250
+ if (trustedPublicKey) {
251
+ try {
252
+ publicKey = trustedPublicKey instanceof crypto.KeyObject
253
+ ? trustedPublicKey
254
+ : crypto.createPublicKey(trustedPublicKey);
255
+ } catch {
256
+ return { valid: false, reason: 'Configured trusted public key is invalid', protocol: 'AP2' };
257
+ }
258
+ if (!EXPLICIT_KEY_ALLOWED_ALGS.has(header.alg)) {
259
+ return { valid: false, reason: `Unsupported signature algorithm: ${header.alg}`, protocol: 'AP2' };
260
+ }
261
+ } else if (String(payload.iss).startsWith('did:key:')) {
262
+ usingSelfCertifyingKey = true;
263
+ if (header.alg !== 'EdDSA') {
264
+ return { valid: false, reason: 'did:key issuers require the EdDSA algorithm', protocol: 'AP2' };
265
+ }
266
+ publicKey = ed25519PublicKeyFromDidKey(payload.iss);
267
+ if (!publicKey) {
268
+ return { valid: false, reason: 'Issuer is not a valid Ed25519 did:key identifier', protocol: 'AP2' };
269
+ }
270
+ } else {
271
+ // No merchant-trusted key configured, and the issuer isn't a
272
+ // self-certifying did:key we can derive a key from - there is no way
273
+ // to verify this mandate, so it must be rejected rather than passed
274
+ // through as "valid but unverified".
275
+ return {
276
+ valid: false,
277
+ reason: 'Cannot verify mandate: issuer is not a did:key and no trusted public key is configured',
278
+ protocol: 'AP2',
279
+ };
280
+ }
281
+
282
+ // --- Signature verification (mandatory) ---
283
+ try {
284
+ const [encodedHeader, encodedPayload] = String(mandateHeader).replace(/^Bearer\s+/i, '').split('.');
285
+ const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`);
286
+ const signature = Buffer.from(signatureB64, 'base64url');
287
+ const hashAlg = hashAlgForJwtAlg(header.alg);
288
+ const isValid = crypto.verify(hashAlg, signingInput, publicKey, signature);
289
+ if (!isValid) return { valid: false, reason: 'Invalid signature', protocol: 'AP2' };
290
+ } catch (err) {
291
+ return { valid: false, reason: `Signature verification failed: ${err.message}`, protocol: 'AP2' };
292
+ }
293
+
294
+ // --- Audience binding ---
295
+ if (expectedAudience && payload.aud !== expectedAudience) {
296
+ return {
297
+ valid: false,
298
+ reason: `Mandate audience "${payload.aud}" does not match this store ("${expectedAudience}")`,
299
+ protocol: 'AP2',
300
+ };
301
+ }
302
+
303
+ // --- merchantScope binding (if the mandate declares one, it must match too) ---
304
+ const merchantScope = payload.intentMandate?.merchantScope;
305
+ if (expectedAudience && merchantScope && merchantScope !== expectedAudience) {
306
+ return {
307
+ valid: false,
308
+ reason: `Mandate merchantScope "${merchantScope}" does not match this store ("${expectedAudience}")`,
309
+ protocol: 'AP2',
310
+ };
311
+ }
312
+
313
+ // --- Replay protection ---
314
+ if (payload.jti) {
315
+ if (isReplay(payload.jti, payload.exp * 1000)) {
316
+ return { valid: false, reason: 'Mandate has already been used (replay detected)', protocol: 'AP2' };
317
+ }
318
+ }
319
+
320
+ // --- Category enforcement (only if both the mandate and caller supply it) ---
321
+ const allowedCategories = payload.intentMandate?.allowedCategories;
322
+ if (Array.isArray(allowedCategories) && Array.isArray(requestedCategories) && requestedCategories.length > 0) {
323
+ const overlaps = requestedCategories.some((c) => allowedCategories.includes(c));
324
+ if (!overlaps) {
325
+ return {
326
+ valid: false,
327
+ reason: `Requested categories [${requestedCategories.join(', ')}] are not within the mandate's allowedCategories [${allowedCategories.join(', ')}]`,
328
+ protocol: 'AP2',
329
+ };
330
+ }
331
+ }
332
+
333
+ // --- Budget enforcement (checked here for the common case where the
334
+ // caller already knows the order total; the SDK middleware also
335
+ // re-checks this at the /ap2/checkout call site once the cart total is known) ---
336
+ const maxBudget = payload.intentMandate?.maxBudget ?? payload.paymentMandate?.maxBudget;
337
+ if (maxBudget !== undefined && body?.orderTotal !== undefined) {
338
+ if (Number(body.orderTotal) > Number(maxBudget)) {
339
+ return { valid: false, reason: 'Order total exceeds Intent Mandate maxBudget limit', protocol: 'AP2' };
340
+ }
341
+ }
342
+
343
+ return {
344
+ valid: true,
345
+ protocol: 'AP2',
346
+ verified: true,
347
+ selfCertifying: usingSelfCertifyingKey,
348
+ mandates: payload,
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Builds and signs a complete AP2 Intent+Payment Mandate SD-JWT for
354
+ * agent-side tooling (test harnesses, demos, or a real agent's own wallet
355
+ * integration). The issuer is a did:key derived from the signing keypair,
356
+ * so the resulting mandate self-certifies without any prior key
357
+ * registration with the merchant.
358
+ *
359
+ * @param {object} options
360
+ * @param {string} options.audience - the merchant/store base URL this mandate authorizes (required)
361
+ * @param {number} options.maxBudget - spending cap enforced by the merchant on checkout
362
+ * @param {string} [options.currency='USD']
363
+ * @param {string[]} [options.allowedCategories] - optional category allowlist
364
+ * @param {string} [options.merchantScope] - defaults to `audience`
365
+ * @param {number} [options.lifetimeSec=3600] - mandate validity window
366
+ * @param {string} [options.paymentMethod='tokenized_card']
367
+ * @param {string} [options.subject='user_wallet_delegation']
368
+ * @param {crypto.KeyObject} [options.privateKey] - reuse an existing Ed25519 private key;
369
+ * a fresh ephemeral keypair is generated if omitted
370
+ * @returns {{ token: string, did: string, publicKey: crypto.KeyObject, privateKey: crypto.KeyObject }}
371
+ */
372
+ export function createAp2Mandate(options = {}) {
373
+ const {
374
+ audience,
375
+ maxBudget,
376
+ currency = 'USD',
377
+ allowedCategories,
378
+ merchantScope,
379
+ lifetimeSec = 3600,
380
+ paymentMethod = 'tokenized_card',
381
+ subject = 'user_wallet_delegation',
382
+ privateKey: providedPrivateKey,
383
+ } = options;
384
+
385
+ if (!audience) throw new Error('createAp2Mandate requires an `audience` (the target store base URL)');
386
+ if (maxBudget === undefined) throw new Error('createAp2Mandate requires a `maxBudget`');
387
+
388
+ const { privateKey, publicKey } = providedPrivateKey
389
+ ? { privateKey: providedPrivateKey, publicKey: crypto.createPublicKey(providedPrivateKey) }
390
+ : crypto.generateKeyPairSync('ed25519');
391
+
392
+ const did = didKeyFromEd25519PublicKey(publicKey);
393
+ const iat = Math.floor(Date.now() / 1000);
394
+ const exp = iat + lifetimeSec;
395
+
396
+ const header = { alg: 'EdDSA', typ: 'JWT' };
397
+ const payload = {
398
+ iss: did,
399
+ aud: audience,
400
+ sub: subject,
401
+ iat,
402
+ exp,
403
+ jti: crypto.randomUUID(),
404
+ intentMandate: {
405
+ maxBudget,
406
+ currency,
407
+ merchantScope: merchantScope || audience,
408
+ ...(allowedCategories ? { allowedCategories } : {}),
409
+ },
410
+ paymentMandate: {
411
+ paymentMethod,
412
+ currency,
413
+ },
414
+ };
415
+
416
+ const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
417
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
418
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
419
+ const signature = crypto.sign(null, Buffer.from(signingInput), privateKey).toString('base64url');
420
+
421
+ return { token: `${signingInput}.${signature}`, did, publicKey, privateKey };
422
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@agentsbloom/sdk",
3
+ "version": "0.2.0",
4
+ "description": "Universal Node.js/Express SDK to convert any e-commerce store into an Agent-Ready API (UCP, ACP, AP2, WebMCP, Web Bot Auth).",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js",
12
+ "default": "./index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "index.d.ts",
18
+ "telemetry.js",
19
+ "lib/ap2.js",
20
+ "README.md",
21
+ "LICENSE",
22
+ "SECURITY.md",
23
+ "assets/logo-mark.svg"
24
+ ],
25
+ "sideEffects": true,
26
+ "keywords": [
27
+ "agentsbloom",
28
+ "webmcp",
29
+ "ucp",
30
+ "acp",
31
+ "ap2",
32
+ "web-bot-auth",
33
+ "ai-agents"
34
+ ],
35
+ "author": "AgentsBloom <contact@agentsbloom.com>",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/AgentsBloom/sdk.git"
40
+ },
41
+ "homepage": "https://docs.agentsbloom.com",
42
+ "bugs": {
43
+ "url": "https://github.com/AgentsBloom/sdk/issues"
44
+ },
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "peerDependencies": {
52
+ "express": "^4.0.0"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.1.0",
56
+ "@opentelemetry/api": "^1.9.0"
57
+ },
58
+ "optionalDependencies": {
59
+ "@opentelemetry/sdk-trace-node": "1.30.1",
60
+ "@opentelemetry/sdk-trace-base": "1.30.1",
61
+ "@opentelemetry/exporter-trace-otlp-http": "0.57.2",
62
+ "@opentelemetry/resources": "1.30.1",
63
+ "@opentelemetry/semantic-conventions": "1.29.0"
64
+ },
65
+ "devDependencies": {
66
+ "@opentelemetry/sdk-trace-node": "1.30.1",
67
+ "@opentelemetry/sdk-trace-base": "1.30.1",
68
+ "@opentelemetry/exporter-trace-otlp-http": "0.57.2",
69
+ "@opentelemetry/resources": "1.30.1",
70
+ "@opentelemetry/semantic-conventions": "1.29.0",
71
+ "fast-check": "^3.23.2"
72
+ },
73
+ "scripts": {
74
+ "test": "node --test",
75
+ "lint": "node --check index.js && node --check telemetry.js && node --check lib/ap2.js && node --check scripts/check-package.mjs && node --check scripts/verify-consumer.mjs",
76
+ "check:package": "node scripts/check-package.mjs",
77
+ "verify:consumer": "node scripts/verify-consumer.mjs",
78
+ "release:check": "npm run lint && npm test && npm run check:package && npm run verify:consumer && npm pack --dry-run --ignore-scripts",
79
+ "prepublishOnly": "npm run release:check"
80
+ }
81
+ }
package/telemetry.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * OTLP trace exporter initialization for the AgentsBloom SDK.
3
+ *
4
+ * The OpenTelemetry SDK packages required to actually export spans
5
+ * (@opentelemetry/sdk-trace-node, sdk-trace-base, exporter-trace-otlp-http,
6
+ * resources, semantic-conventions) are loaded via dynamic import() so that
7
+ * a merchant application that never calls setupTelemetry() is never forced
8
+ * to install them. They are declared as optionalDependencies in
9
+ * package.json rather than dependencies.
10
+ */
11
+
12
+ /**
13
+ * Initialize an OTLP trace exporter/provider pair.
14
+ *
15
+ * @param {Object} options
16
+ * @param {string} options.otlpEndpoint - OTLP collector base URL (traces are posted to `${otlpEndpoint}/v1/traces`)
17
+ * @param {string} options.serviceName - Service name attached as the resource's service.name attribute
18
+ * @param {number} options.samplingRatio - Trace sampling ratio 0.0-1.0 (currently informational; not yet wired into a sampler)
19
+ * @param {string} options.apiKey - API key sent as a Bearer token in the exporter's Authorization header
20
+ * @returns {Promise<{ provider: import('@opentelemetry/sdk-trace-node').NodeTracerProvider, exporter: import('@opentelemetry/exporter-trace-otlp-http').OTLPTraceExporter } | null>}
21
+ * The initialized provider/exporter handle, or null if the optional OTel SDK packages are unavailable.
22
+ */
23
+ let optionalDependencyWarningLogged = false;
24
+
25
+ export async function initExporter({ otlpEndpoint, serviceName, samplingRatio, apiKey } = {}) {
26
+ try {
27
+ const { NodeTracerProvider } = await import('@opentelemetry/sdk-trace-node');
28
+ const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
29
+ const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
30
+ const { Resource } = await import('@opentelemetry/resources');
31
+ const { SemanticResourceAttributes } = await import('@opentelemetry/semantic-conventions');
32
+
33
+ const exporter = new OTLPTraceExporter({
34
+ url: `${otlpEndpoint}/v1/traces`,
35
+ headers: { Authorization: `Bearer ${apiKey}` },
36
+ });
37
+ const provider = new NodeTracerProvider({
38
+ resource: new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: serviceName }),
39
+ });
40
+ provider.addSpanProcessor(new BatchSpanProcessor(exporter));
41
+ provider.register();
42
+ return { provider, exporter };
43
+ } catch {
44
+ if (!optionalDependencyWarningLogged) {
45
+ optionalDependencyWarningLogged = true;
46
+ console.warn('🌸 AgentsBloom: OTel SDK packages unavailable, continuing without export.');
47
+ }
48
+ return null;
49
+ }
50
+ }