@aura-labs-ai/scout 0.2.0 → 0.3.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/README.md CHANGED
@@ -103,12 +103,21 @@ const scout = createScout({
103
103
  timeout: 30000, // optional, ms
104
104
  storage: customStorageAdapter, // optional, defaults to in-memory
105
105
  constraints: {}, // optional, default constraints
106
+ principalId: 'prn_01HXYZ...', // required — registered principal ID
107
+ capacity: 'agent_for_principal', // optional, defaults to 'agent_for_principal'
106
108
  });
107
109
 
108
110
  // Initialize and register with AURA Core (idempotent)
109
111
  await scout.ready();
110
112
  ```
111
113
 
114
+ **Principal registration:** Before creating a Scout, register the principal (legal entity) via `POST /v1/principals`. The `principalId` links this agent to the accountable entity. See [Protocol Spec Section 3.1](../docs/protocol/PROTOCOL_SPECIFICATION.md) for details.
115
+
116
+ **Capacity options:**
117
+ - `agent_for_principal` — Agent acts on behalf of the declared principal (default)
118
+ - `principal_direct` — The principal is operating directly (no separate agent entity)
119
+ - `platform_delegated` — A platform operates the agent on behalf of multiple principals
120
+
112
121
  ### `scout.ping()`
113
122
 
114
123
  Read-only connectivity check. Verifies Core is reachable and its dependencies are healthy. Does not require `ready()` — works on a freshly created instance. No auth headers sent.
@@ -232,6 +241,27 @@ Scout uses **Ed25519 public key cryptography** for identity. When you call `scou
232
241
 
233
242
  No API keys or other credentials are required.
234
243
 
244
+ ## Protocol Modules (Phase B)
245
+
246
+ Scout SDK ships modules for the external commerce protocols AURA composes
247
+ with. All sign with Ed25519 via Node's `crypto.sign(null, data, key)` API
248
+ (NOT `createSign('sha256')` — Ed25519 does its own internal hashing and
249
+ does not take a hash algorithm parameter).
250
+
251
+ | Module | Path | Purpose |
252
+ |--------|------|---------|
253
+ | AP2 (Google Agent Payments Protocol) | `src/ap2/mandates.js` | Intent → Cart → Payment mandate creation, signature verification, intent-coverage validation. |
254
+ | TAP (Visa Trusted Agent Protocol) | `src/tap/visa.js` | Agent registration, request signing per HTTP Message Signatures, key rotation. |
255
+ | MCP (Model Context Protocol) | `src/mcp/client.js` | Beacon discovery and protocol query. |
256
+
257
+ Test coverage:
258
+
259
+ ```bash
260
+ node --test src/tests/ap2-mandates.test.js # AP2 mandates: 20 tests
261
+ node --test src/tests/visa-tap.test.js # Visa TAP: 21 tests
262
+ node --test src/tests/mcp-client.test.js # MCP client: 18 tests
263
+ ```
264
+
235
265
  ## License
236
266
 
237
267
  Business Source License 1.1 — See [LICENSE](LICENSE) for details.
package/package.json CHANGED
@@ -1,18 +1,16 @@
1
1
  {
2
2
  "name": "@aura-labs-ai/scout",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scout SDK for AURA - Build buying agents that participate in agentic commerce",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
- "types": "src/index.d.ts",
8
7
  "bin": {
9
8
  "scout-cli": "./bin/scout-cli.js",
10
9
  "aura-scout": "./bin/scout-cli.js"
11
10
  },
12
11
  "exports": {
13
12
  ".": {
14
- "import": "./src/index.js",
15
- "types": "./src/index.d.ts"
13
+ "import": "./src/index.js"
16
14
  },
17
15
  "./mcp": {
18
16
  "import": "./src/mcp/client.js"
@@ -25,7 +23,7 @@
25
23
  }
26
24
  },
27
25
  "scripts": {
28
- "test": "node --test src/**/*.test.js",
26
+ "test": "node --test src/*.test.js src/**/*.test.js src/**/**/*.test.js",
29
27
  "test:protocols": "node src/tests/run-protocol-tests.js",
30
28
  "test:scenarios": "node src/tests/scenarios/index.js",
31
29
  "test:ap2": "node --test src/tests/scenarios/ap2-scenarios.test.js",
@@ -37,8 +35,8 @@
37
35
  "example:protocols": "node examples/protocol-integration.js"
38
36
  },
39
37
  "dependencies": {
40
- "@aura-labs-ai/nlp": "^0.1.0",
41
- "@aura-labs-ai/sdk-common": "^0.1.0"
38
+ "@aura-labs-ai/nlp": "^0.2.0",
39
+ "@aura-labs-ai/sdk-common": "^0.2.0"
42
40
  },
43
41
  "devDependencies": {
44
42
  "@types/node": "^20.11.0"
@@ -12,7 +12,12 @@
12
12
  * @see https://ap2-protocol.org/specification/
13
13
  */
14
14
 
15
- import { createHash, createSign, createVerify, generateKeyPairSync } from 'crypto';
15
+ import {
16
+ createHash,
17
+ generateKeyPairSync,
18
+ sign as cryptoSign,
19
+ verify as cryptoVerify,
20
+ } from 'crypto';
16
21
 
17
22
  /**
18
23
  * AP2 Mandates - Create and validate payment mandates
@@ -310,25 +315,28 @@ function canonicalize(obj) {
310
315
  }
311
316
 
312
317
  /**
313
- * Sign data with private key
318
+ * Sign data with private key.
319
+ *
320
+ * Ed25519 signs the message directly — no separate hash algorithm is
321
+ * involved. The Node 'crypto' API for Ed25519 is crypto.sign(null, data, key);
322
+ * the older createSign('sha256') API throws "Unsupported crypto operation"
323
+ * on Ed25519 keys because Ed25519 does not take a hash specifier.
314
324
  */
315
325
  async function sign(data, privateKey) {
316
- // In production, use proper Ed25519 signing
317
- // For now, use Node.js crypto
318
- if (typeof privateKey === 'string') {
319
- // Assume PEM format
320
- const signer = createSign('sha256');
321
- signer.update(data);
322
- return signer.sign(privateKey, 'base64');
326
+ if (typeof privateKey === 'string' || (privateKey && privateKey.type === 'private')) {
327
+ // PEM-string or KeyObject. Ed25519 signs the buffer directly.
328
+ return cryptoSign(null, Buffer.from(data), privateKey).toString('base64');
323
329
  }
324
330
 
325
- // Mock signing for development
331
+ // No private key provided — mock signature for development paths.
326
332
  const hash = createHash('sha256').update(data).digest('base64');
327
333
  return `mock_signature_${hash.substring(0, 20)}`;
328
334
  }
329
335
 
330
336
  /**
331
- * Verify signature
337
+ * Verify signature against public key.
338
+ *
339
+ * Mirrors sign(): crypto.verify(null, data, key, signature) for Ed25519.
332
340
  */
333
341
  async function verifySignature(data, signature, publicKey) {
334
342
  if (signature.startsWith('mock_signature_')) {
@@ -337,10 +345,7 @@ async function verifySignature(data, signature, publicKey) {
337
345
  return signature === `mock_signature_${hash.substring(0, 20)}`;
338
346
  }
339
347
 
340
- // Real verification
341
- const verifier = createVerify('sha256');
342
- verifier.update(data);
343
- return verifier.verify(publicKey, signature, 'base64');
348
+ return cryptoVerify(null, Buffer.from(data), publicKey, Buffer.from(signature, 'base64'));
344
349
  }
345
350
 
346
351
  /**
package/src/client.js CHANGED
@@ -91,7 +91,11 @@ export class ScoutClient {
91
91
  * Falls back to Bearer token auth if apiKey is provided.
92
92
  */
93
93
  async #request(method, path, body = null, extraHeaders = {}) {
94
- const url = `${this.#config.coreUrl}${API_VERSION}${path}`;
94
+ // Sign the exact path we send. Core mounts routes under API_VERSION and verifies
95
+ // request.url, so the SDK must sign the versioned path, not the bare one (SDK-01,
96
+ // DEC-148).
97
+ const versionedPath = `${API_VERSION}${path}`;
98
+ const url = `${this.#config.coreUrl}${versionedPath}`;
95
99
  const requestId = randomUUID();
96
100
  const bodyString = body ? JSON.stringify(body) : null;
97
101
 
@@ -104,14 +108,18 @@ export class ScoutClient {
104
108
 
105
109
  // Auth: prefer Ed25519 signing, fall back to Bearer token
106
110
  if (this.#keyManager && this.#agentId) {
107
- const { signature, timestamp } = this.#keyManager.signRequest({
111
+ const host = new URL(this.#config.coreUrl).host;
112
+ const { signature, timestamp, nonce } = this.#keyManager.signRequest({
108
113
  method,
109
- path,
114
+ path: versionedPath,
110
115
  body: bodyString,
116
+ host,
111
117
  });
112
118
  headers['X-Agent-Id'] = this.#agentId;
113
119
  headers['X-Agent-Signature'] = signature;
114
120
  headers['X-Agent-Timestamp'] = timestamp;
121
+ headers['X-Agent-Nonce'] = nonce;
122
+ headers['X-Agent-Host'] = host;
115
123
  } else if (this.#config.apiKey) {
116
124
  headers['Authorization'] = `Bearer ${this.#config.apiKey}`;
117
125
  }
package/src/index.js CHANGED
@@ -90,6 +90,8 @@ export class Scout {
90
90
  * @param {object} [config.storage] - Storage adapter with get/set/remove methods
91
91
  * @param {object} [config.constraints] - Default constraints for all sessions
92
92
  * @param {object} [config.logger] - External logger instance (optional)
93
+ * @param {string} [config.principalId] - Principal ID (registered via POST /v1/principals). Required for v1.5+ protocol compliance.
94
+ * @param {string} [config.capacity] - Agent capacity: 'agent_for_principal' (default), 'principal_direct', or 'platform_delegated'
93
95
  */
94
96
  constructor(config = {}) {
95
97
  this.#config = {
@@ -247,9 +249,15 @@ export class Scout {
247
249
  const publicKey = this.#keyManager.publicKey;
248
250
 
249
251
  // Build registration body
252
+ // Protocol v1.5: principal_id and capacity are required by Core.
253
+ // If not provided in config, Core will reject with 400.
254
+ // Wire contract: the Ed25519 key is sent as `public_key`. The local
255
+ // variable stays camelCase; only the JSON key on the wire is snake_case.
250
256
  const body = {
251
- publicKey,
257
+ public_key: publicKey,
252
258
  type: 'scout',
259
+ principal_id: this.#config.principalId || metadata.principalId || undefined,
260
+ capacity: this.#config.capacity || metadata.capacity || 'agent_for_principal',
253
261
  manifest: {
254
262
  name: metadata.name || 'AURA Scout',
255
263
  version: '0.1.0',
@@ -269,7 +277,7 @@ export class Scout {
269
277
  'X-Agent-Signature': signature,
270
278
  });
271
279
 
272
- this.#agentId = result.agentId;
280
+ this.#agentId = result.agentId || result.agent_id;
273
281
  this.#registered = true;
274
282
 
275
283
  // Persist agentId for next launch
@@ -281,16 +289,24 @@ export class Scout {
281
289
  // Update activity context with assigned agentId
282
290
  this.#activity.setScoutContext({
283
291
  agentId: this.#agentId,
292
+ principalId: result.principal_id || null,
293
+ capacity: result.capacity || null,
284
294
  });
285
295
 
286
296
  finish({
287
297
  success: true,
288
298
  metadata: {
289
299
  agentId: this.#agentId,
300
+ principalId: result.principal_id,
301
+ capacity: result.capacity,
290
302
  },
291
303
  });
292
304
 
293
- return { agentId: this.#agentId };
305
+ return {
306
+ agentId: this.#agentId,
307
+ principalId: result.principal_id,
308
+ capacity: result.capacity,
309
+ };
294
310
  } catch (error) {
295
311
  this.#activity.record(ScoutActivityEventTypes.SCOUT_REGISTRATION_FAILED, {
296
312
  success: false,
@@ -107,25 +107,31 @@ export class KeyManager {
107
107
  /**
108
108
  * Build and sign the canonical request string for HTTP request authentication
109
109
  *
110
- * Format: "${method}\n${path}\n${timestamp}\n${bodyDigest}"
110
+ * Format: "${method}\n${path}\n${timestamp}\n${nonce}\n${bodyDigest}"
111
+ * The nonce (GAP-02) makes each signed request single-use; it is returned so the
112
+ * caller can emit the matching X-Agent-Nonce header. This string MUST byte-match
113
+ * Core's buildSigningString (aura-core .../lib/agent-auth.js).
111
114
  *
112
115
  * @param {object} params
113
116
  * @param {string} params.method - HTTP method (uppercase)
114
117
  * @param {string} params.path - Request path (e.g., /sessions)
115
118
  * @param {string|null} params.body - JSON body string or null
116
- * @returns {{ signature: string, timestamp: string }} Signature and timestamp to include in headers
119
+ * @param {string} [params.host] - Optional target host (@authority, GAP-05); when set it is folded into the signed bytes and must be sent as X-Agent-Host
120
+ * @returns {{ signature: string, timestamp: string, nonce: string }} Signature, timestamp, and nonce for the request headers
117
121
  */
118
- signRequest({ method, path, body }) {
122
+ signRequest({ method, path, body, host }) {
119
123
  this.#ensureInitialized();
120
124
 
121
125
  const timestamp = Date.now().toString();
126
+ const nonce = crypto.randomBytes(16).toString('hex');
122
127
  const bodyDigest = body
123
128
  ? crypto.createHash('sha256').update(body).digest('base64')
124
129
  : '';
125
- const signingString = `${method}\n${path}\n${timestamp}\n${bodyDigest}`;
130
+ const base = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyDigest}`;
131
+ const signingString = host ? `${base}\n${host}` : base;
126
132
  const signature = this.sign(signingString);
127
133
 
128
- return { signature, timestamp };
134
+ return { signature, timestamp, nonce };
129
135
  }
130
136
 
131
137
  /**
@@ -161,13 +161,17 @@ describe('KeyManager signRequest', () => {
161
161
  await km.init();
162
162
  });
163
163
 
164
- it('should return signature and timestamp for GET request', () => {
164
+ it('should return signature, timestamp, and nonce for GET request', () => {
165
165
  const result = km.signRequest({ method: 'GET', path: '/health', body: null });
166
166
 
167
167
  assert.equal(typeof result.signature, 'string');
168
168
  assert.equal(typeof result.timestamp, 'string');
169
169
  assert.equal(Buffer.from(result.signature, 'base64').length, 64);
170
170
 
171
+ // Nonce (GAP-02) should be a non-empty string
172
+ assert.equal(typeof result.nonce, 'string');
173
+ assert.ok(result.nonce.length > 0);
174
+
171
175
  // Timestamp should be a recent unix ms
172
176
  const ts = parseInt(result.timestamp, 10);
173
177
  assert.ok(Math.abs(Date.now() - ts) < 1000);
@@ -175,7 +179,7 @@ describe('KeyManager signRequest', () => {
175
179
 
176
180
  it('should produce verifiable signature for POST request', () => {
177
181
  const body = JSON.stringify({ intent: 'buy widgets' });
178
- const { signature, timestamp } = km.signRequest({
182
+ const { signature, timestamp, nonce } = km.signRequest({
179
183
  method: 'POST',
180
184
  path: '/sessions',
181
185
  body,
@@ -183,20 +187,20 @@ describe('KeyManager signRequest', () => {
183
187
 
184
188
  // Reconstruct the signing string server-side would build
185
189
  const bodyDigest = crypto.createHash('sha256').update(body).digest('base64');
186
- const signingString = `POST\n/sessions\n${timestamp}\n${bodyDigest}`;
190
+ const signingString = `POST\n/sessions\n${timestamp}\n${nonce}\n${bodyDigest}`;
187
191
 
188
192
  const valid = verifySignature(km.publicKey, signingString, signature);
189
193
  assert.equal(valid, true);
190
194
  });
191
195
 
192
196
  it('should produce verifiable signature for GET (no body)', () => {
193
- const { signature, timestamp } = km.signRequest({
197
+ const { signature, timestamp, nonce } = km.signRequest({
194
198
  method: 'GET',
195
199
  path: '/sessions/123',
196
200
  body: null,
197
201
  });
198
202
 
199
- const signingString = `GET\n/sessions/123\n${timestamp}\n`;
203
+ const signingString = `GET\n/sessions/123\n${timestamp}\n${nonce}\n`;
200
204
  const valid = verifySignature(km.publicKey, signingString, signature);
201
205
  assert.equal(valid, true);
202
206
  });
@@ -277,15 +281,36 @@ describe('KeyManager cross-compat with agent-auth.js signing scheme', () => {
277
281
  await km.init();
278
282
 
279
283
  const body = JSON.stringify({ intent: 'test intent' });
280
- const { signature, timestamp } = km.signRequest({
284
+ const { signature, timestamp, nonce } = km.signRequest({
285
+ method: 'POST',
286
+ path: '/sessions',
287
+ body,
288
+ });
289
+
290
+ // Server reconstructs: buildSigningString(method, path, timestamp, nonce, body)
291
+ const bodyDigest = crypto.createHash('sha256').update(body).digest('base64');
292
+ const signingString = `POST\n/sessions\n${timestamp}\n${nonce}\n${bodyDigest}`;
293
+
294
+ const valid = verifySignature(km.publicKey, signingString, signature);
295
+ assert.equal(valid, true);
296
+ });
297
+
298
+ it('should bind the host into the signature when host is provided (GAP-05)', async () => {
299
+ const km = new KeyManager();
300
+ await km.init();
301
+
302
+ const body = JSON.stringify({ intent: 'test intent' });
303
+ const { signature, timestamp, nonce } = km.signRequest({
281
304
  method: 'POST',
282
305
  path: '/sessions',
283
306
  body,
307
+ host: 'core.example',
284
308
  });
285
309
 
286
- // Server reconstructs: buildSigningString(method, path, timestamp, body)
310
+ // Server reconstructs with the 6th host field appended:
311
+ // buildSigningString(method, path, timestamp, nonce, body, host)
287
312
  const bodyDigest = crypto.createHash('sha256').update(body).digest('base64');
288
- const signingString = `POST\n/sessions\n${timestamp}\n${bodyDigest}`;
313
+ const signingString = `POST\n/sessions\n${timestamp}\n${nonce}\n${bodyDigest}\ncore.example`;
289
314
 
290
315
  const valid = verifySignature(km.publicKey, signingString, signature);
291
316
  assert.equal(valid, true);
package/src/session.js CHANGED
@@ -55,7 +55,8 @@ export class Session {
55
55
  * Session ID
56
56
  */
57
57
  get id() {
58
- return this.#data.sessionId;
58
+ // Wire contract: Core returns the session identifier as `session_id`.
59
+ return this.#data.session_id;
59
60
  }
60
61
 
61
62
  /**
@@ -228,8 +229,11 @@ export class Session {
228
229
  });
229
230
 
230
231
  try {
232
+ // Wire contract: the commit payload carries the selected offer as
233
+ // `offer_id`. The local variable stays camelCase; only the JSON key
234
+ // crossing the HTTP boundary is snake_case.
231
235
  const response = await this.#client.post(`/sessions/${this.id}/commit`, {
232
- offerId,
236
+ offer_id: offerId,
233
237
  });
234
238
 
235
239
  this.#data.status = SessionStatus.COMMITTED;
@@ -340,17 +344,18 @@ export class Constraints {
340
344
  getViolations(offer) {
341
345
  const violations = [];
342
346
 
343
- // Check budget
347
+ // Check budget. `offer` is the raw Core offer body, so the wire keys
348
+ // `total_price` and `delivery_date` are read directly.
344
349
  if (this.#constraints.maxBudget !== null) {
345
- if (offer.totalPrice > this.#constraints.maxBudget) {
346
- violations.push(`price_exceeds_budget:${offer.totalPrice}>${this.#constraints.maxBudget}`);
350
+ if (offer.total_price > this.#constraints.maxBudget) {
351
+ violations.push(`price_exceeds_budget:${offer.total_price}>${this.#constraints.maxBudget}`);
347
352
  }
348
353
  }
349
354
 
350
355
  // Check delivery date
351
356
  if (this.#constraints.deliveryBy !== null) {
352
- if (offer.deliveryDate && new Date(offer.deliveryDate) > this.#constraints.deliveryBy) {
353
- violations.push(`delivery_too_late:${offer.deliveryDate}`);
357
+ if (offer.delivery_date && new Date(offer.delivery_date) > this.#constraints.deliveryBy) {
358
+ violations.push(`delivery_too_late:${offer.delivery_date}`);
354
359
  }
355
360
  }
356
361
 
@@ -370,15 +375,16 @@ export class Constraints {
370
375
  score(offer) {
371
376
  let score = 50; // Base score
372
377
 
373
- // Budget utilization (prefer lower prices)
374
- if (this.#constraints.maxBudget && offer.totalPrice) {
375
- const utilizationRatio = offer.totalPrice / this.#constraints.maxBudget;
378
+ // Budget utilization (prefer lower prices). `offer` is the raw Core
379
+ // offer body, so the wire key `total_price` is read directly.
380
+ if (this.#constraints.maxBudget && offer.total_price) {
381
+ const utilizationRatio = offer.total_price / this.#constraints.maxBudget;
376
382
  score += (1 - utilizationRatio) * 20; // Up to +20 for lower prices
377
383
  }
378
384
 
379
385
  // Delivery speed (prefer earlier delivery)
380
- if (this.#constraints.deliveryBy && offer.deliveryDate) {
381
- const deliveryDate = new Date(offer.deliveryDate);
386
+ if (this.#constraints.deliveryBy && offer.delivery_date) {
387
+ const deliveryDate = new Date(offer.delivery_date);
382
388
  const deadline = this.#constraints.deliveryBy;
383
389
  const now = new Date();
384
390
  const totalWindow = deadline - now;
@@ -440,15 +446,17 @@ export class Offer {
440
446
  this.#constraints = constraints;
441
447
  }
442
448
 
449
+ // Public getters keep idiomatic camelCase names; the values are read from
450
+ // the raw Core offer body, whose protocol fields are snake_case on the wire.
443
451
  get id() { return this.#data.id; }
444
- get beaconId() { return this.#data.beaconId; }
445
- get beaconName() { return this.#data.beaconName; }
452
+ get beaconId() { return this.#data.beacon_id; }
453
+ get beaconName() { return this.#data.beacon_name; }
446
454
  get product() { return this.#data.product; }
447
- get unitPrice() { return this.#data.unitPrice; }
455
+ get unitPrice() { return this.#data.unit_price; }
448
456
  get quantity() { return this.#data.quantity; }
449
- get totalPrice() { return this.#data.totalPrice || (this.unitPrice * this.quantity); }
457
+ get totalPrice() { return this.#data.total_price || (this.unitPrice * this.quantity); }
450
458
  get currency() { return this.#data.currency || 'USD'; }
451
- get deliveryDate() { return this.#data.deliveryDate; }
459
+ get deliveryDate() { return this.#data.delivery_date; }
452
460
  get terms() { return this.#data.terms || {}; }
453
461
  get metadata() { return this.#data.metadata || {}; }
454
462
 
@@ -499,11 +507,13 @@ export class Transaction {
499
507
  }
500
508
  }
501
509
 
502
- get id() { return this.#data.transactionId; }
510
+ // Public getters keep camelCase names; values are read from the raw Core
511
+ // transaction body, whose protocol fields are snake_case on the wire.
512
+ get id() { return this.#data.transaction_id; }
503
513
  get status() { return this.#data.status; }
504
- get offerId() { return this.#data.offerId; }
505
- get paymentStatus() { return this.#data.paymentStatus; }
506
- get fulfillmentStatus() { return this.#data.fulfillmentStatus; }
514
+ get offerId() { return this.#data.offer_id; }
515
+ get paymentStatus() { return this.#data.payment_status; }
516
+ get fulfillmentStatus() { return this.#data.fulfillment_status; }
507
517
 
508
518
  /**
509
519
  * Refresh transaction state from Core
@@ -584,9 +594,10 @@ export class Transaction {
584
594
  * Get beacon information
585
595
  */
586
596
  get beacon() {
597
+ // `beacon_id` and `beacon_name` are the snake_case wire keys (v3.0).
587
598
  return {
588
- id: this.#data.beaconId,
589
- name: this.#data.beaconName,
599
+ id: this.#data.beacon_id,
600
+ name: this.#data.beacon_name,
590
601
  };
591
602
  }
592
603
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Session / Offer / Transaction wire-contract unit tests
3
+ *
4
+ * Locks in the snake_case HTTP/JSON wire contract (openapi.json) for the
5
+ * Session, Offer, Transaction, and Constraints classes:
6
+ *
7
+ * - Response bodies received from Core use snake_case protocol keys
8
+ * (session_id, beacon_id, unit_price, total_price, delivery_date,
9
+ * transaction_id, offer_id, payment_status, fulfillment_status).
10
+ * - The commit() request payload sends `offer_id` on the wire.
11
+ *
12
+ * Public JS getter names remain idiomatic camelCase; only the keys crossing
13
+ * the HTTP boundary are snake_case. These tests assert both: the camelCase
14
+ * public API reads correctly from snake_case wire data, and the commit
15
+ * payload is built with the snake_case key.
16
+ *
17
+ * Imports only ./session.js, which has no workspace-package dependencies,
18
+ * so it runs without the monorepo's linked node_modules.
19
+ */
20
+
21
+ import { describe, it } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { Session, Offer, Transaction, Constraints } from './session.js';
24
+
25
+ // A stub HTTP client that records the last POST and returns a canned body.
26
+ function makeClient({ getBody = {}, postBody = {} } = {}) {
27
+ const calls = { get: [], post: [] };
28
+ return {
29
+ calls,
30
+ async get(path) {
31
+ calls.get.push({ path });
32
+ return getBody;
33
+ },
34
+ async post(path, body) {
35
+ calls.post.push({ path, body });
36
+ return postBody;
37
+ },
38
+ };
39
+ }
40
+
41
+ describe('Session — reads snake_case wire fields', () => {
42
+ it('exposes session_id from the response body as id', () => {
43
+ const session = new Session({ session_id: 'sess_123', status: 'created' }, makeClient(), {});
44
+ assert.equal(session.id, 'sess_123');
45
+ });
46
+
47
+ it('does NOT read a legacy camelCase sessionId key', () => {
48
+ const session = new Session({ sessionId: 'sess_legacy', status: 'created' }, makeClient(), {});
49
+ assert.equal(session.id, undefined);
50
+ });
51
+
52
+ it('refresh() re-reads session_id from the refreshed body', async () => {
53
+ const client = makeClient({ getBody: { session_id: 'sess_refreshed', status: 'offers_available' } });
54
+ const session = new Session({ session_id: 'sess_123', status: 'created' }, client, {});
55
+ await session.refresh();
56
+ assert.equal(session.id, 'sess_refreshed');
57
+ assert.equal(session.status, 'offers_available');
58
+ assert.equal(client.calls.get[0].path, '/sessions/sess_123');
59
+ });
60
+ });
61
+
62
+ describe('Session.commit() — sends offer_id on the wire', () => {
63
+ it('builds the commit payload with the snake_case offer_id key', async () => {
64
+ const offer = new Offer(
65
+ { id: 'offer_1', total_price: 100, unit_price: 50, quantity: 2 },
66
+ new Constraints({}),
67
+ );
68
+ const client = makeClient({ postBody: { transaction_id: 'txn_1', status: 'committed' } });
69
+ const session = new Session({ session_id: 'sess_1', status: 'offers_available' }, client, {});
70
+ // Inject the offer into the session's private offer list via waitForOffers path:
71
+ // commit() looks up this.#offers, so we exercise it through the public surface.
72
+ session.offers.push(offer);
73
+
74
+ const txn = await session.commit('offer_1');
75
+
76
+ const post = client.calls.post[0];
77
+ assert.equal(post.path, '/sessions/sess_1/commit');
78
+ assert.deepEqual(post.body, { offer_id: 'offer_1' });
79
+ assert.ok(!('offerId' in post.body), 'payload must not carry camelCase offerId');
80
+ assert.equal(txn.id, 'txn_1');
81
+ });
82
+ });
83
+
84
+ describe('Offer — getters read snake_case wire fields', () => {
85
+ const raw = {
86
+ id: 'offer_42',
87
+ beacon_id: 'beacon_7',
88
+ beacon_name: 'Acme',
89
+ product: { name: 'Widget' },
90
+ unit_price: 25,
91
+ quantity: 4,
92
+ total_price: 100,
93
+ currency: 'USD',
94
+ delivery_date: '2026-07-01',
95
+ };
96
+
97
+ it('maps beacon_id -> beaconId', () => {
98
+ assert.equal(new Offer(raw, new Constraints({})).beaconId, 'beacon_7');
99
+ });
100
+
101
+ it('maps unit_price -> unitPrice and total_price -> totalPrice', () => {
102
+ const offer = new Offer(raw, new Constraints({}));
103
+ assert.equal(offer.unitPrice, 25);
104
+ assert.equal(offer.totalPrice, 100);
105
+ });
106
+
107
+ it('maps delivery_date -> deliveryDate', () => {
108
+ assert.equal(new Offer(raw, new Constraints({})).deliveryDate, '2026-07-01');
109
+ });
110
+
111
+ it('derives totalPrice from unit_price * quantity when total_price is absent', () => {
112
+ const offer = new Offer({ id: 'o', unit_price: 10, quantity: 3 }, new Constraints({}));
113
+ assert.equal(offer.totalPrice, 30);
114
+ });
115
+ });
116
+
117
+ describe('Constraints — evaluate raw snake_case offer bodies', () => {
118
+ it('flags a budget violation using total_price', () => {
119
+ const constraints = new Constraints({ maxBudget: 50 });
120
+ const violations = constraints.getViolations({ total_price: 120 });
121
+ assert.equal(violations.length, 1);
122
+ assert.match(violations[0], /price_exceeds_budget:120>50/);
123
+ });
124
+
125
+ it('flags a late delivery using delivery_date', () => {
126
+ const constraints = new Constraints({ deliveryBy: new Date('2026-07-01') });
127
+ const violations = constraints.getViolations({ delivery_date: '2026-08-01' });
128
+ assert.equal(violations.length, 1);
129
+ assert.match(violations[0], /delivery_too_late:2026-08-01/);
130
+ });
131
+
132
+ it('an Offer that meets constraints reports no violations', () => {
133
+ const constraints = new Constraints({ maxBudget: 200, deliveryBy: new Date('2026-09-01') });
134
+ const offer = new Offer(
135
+ { id: 'o', total_price: 100, delivery_date: '2026-07-01' },
136
+ constraints,
137
+ );
138
+ assert.equal(offer.meetsConstraints, true);
139
+ assert.deepEqual(offer.constraintViolations, []);
140
+ });
141
+ });
142
+
143
+ describe('Transaction — getters read snake_case wire fields', () => {
144
+ const raw = {
145
+ transaction_id: 'txn_99',
146
+ status: 'committed',
147
+ offer_id: 'offer_5',
148
+ payment_status: 'pending',
149
+ fulfillment_status: 'processing',
150
+ beacon_id: 'beacon_3',
151
+ beacon_name: 'Globex',
152
+ };
153
+
154
+ it('maps transaction_id -> id and offer_id -> offerId', () => {
155
+ const txn = new Transaction(raw, makeClient());
156
+ assert.equal(txn.id, 'txn_99');
157
+ assert.equal(txn.offerId, 'offer_5');
158
+ });
159
+
160
+ it('maps payment_status -> paymentStatus and fulfillment_status -> fulfillmentStatus', () => {
161
+ const txn = new Transaction(raw, makeClient());
162
+ assert.equal(txn.paymentStatus, 'pending');
163
+ assert.equal(txn.fulfillmentStatus, 'processing');
164
+ });
165
+
166
+ it('beacon getter reads beacon_id from the wire body', () => {
167
+ const txn = new Transaction(raw, makeClient());
168
+ assert.deepEqual(txn.beacon, { id: 'beacon_3', name: 'Globex' });
169
+ });
170
+
171
+ it('refresh() re-reads transaction_id from the refreshed body', async () => {
172
+ const client = makeClient({ getBody: { transaction_id: 'txn_after', status: 'completed' } });
173
+ const txn = new Transaction(raw, client);
174
+ await txn.refresh();
175
+ assert.equal(txn.id, 'txn_after');
176
+ assert.equal(client.calls.get[0].path, '/transactions/txn_99');
177
+ });
178
+ });
package/src/tap/visa.js CHANGED
@@ -12,7 +12,13 @@
12
12
  * @see https://usa.visa.com/about-visa/newsroom/press-releases.releaseId.21716.html
13
13
  */
14
14
 
15
- import { createHash, createSign, createVerify, generateKeyPairSync, randomBytes } from 'crypto';
15
+ import {
16
+ createHash,
17
+ generateKeyPairSync,
18
+ randomBytes,
19
+ sign as cryptoSign,
20
+ verify as cryptoVerify,
21
+ } from 'crypto';
16
22
 
17
23
  // TAP Registry URL (would be Visa's in production)
18
24
  const TAP_REGISTRY_URL = process.env.TAP_REGISTRY_URL || 'https://tap.visa.com/v1';
@@ -313,21 +319,21 @@ export class VisaTAP {
313
319
  }
314
320
 
315
321
  /**
316
- * Sign data with private key
322
+ * Sign data with private key (Ed25519).
323
+ *
324
+ * Ed25519 signs the message directly — no separate hash algorithm. The
325
+ * older createSign('sha256') API throws "Unsupported crypto operation"
326
+ * on Ed25519 keys.
317
327
  */
318
328
  static #sign(data, privateKey) {
319
- const signer = createSign('sha256');
320
- signer.update(data);
321
- return signer.sign(privateKey, 'base64');
329
+ return cryptoSign(null, Buffer.from(data), privateKey).toString('base64');
322
330
  }
323
331
 
324
332
  /**
325
- * Verify signature with public key
333
+ * Verify signature with public key (Ed25519).
326
334
  */
327
335
  static #verify(data, signature, publicKey) {
328
- const verifier = createVerify('sha256');
329
- verifier.update(data);
330
- return verifier.verify(publicKey, signature, 'base64');
336
+ return cryptoVerify(null, Buffer.from(data), publicKey, Buffer.from(signature, 'base64'));
331
337
  }
332
338
  }
333
339
 
@@ -21,13 +21,19 @@ const createTestIntent = (overrides = {}) => ({
21
21
  agentId: 'test-agent-001',
22
22
  userId: 'test-user-001',
23
23
  userKey: testKeys.privateKey,
24
+ ...overrides,
25
+ // Deep-merge constraints so a test that overrides one field (e.g.
26
+ // merchantAllowlist) still inherits the defaults (currency, categories,
27
+ // validUntil). Shallow-spread above clobbered the whole constraints object,
28
+ // which made currency undefined and surfaced the wrong validation error
29
+ // first ('Currency undefined not allowed' instead of the asserted error).
24
30
  constraints: {
25
31
  maxAmount: 5000,
26
32
  currency: 'USD',
27
33
  categories: ['electronics'],
28
34
  validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
35
+ ...(overrides.constraints || {}),
29
36
  },
30
- ...overrides,
31
37
  });
32
38
 
33
39
  const createTestOffer = (overrides = {}) => ({
@@ -333,18 +339,25 @@ describe('AP2 Intent Coverage Validation', () => {
333
339
  },
334
340
  }));
335
341
 
342
+ // proposedPurchase must conform on every other dimension that the
343
+ // mandate constrains (currency, category) so the assertion isolates
344
+ // the merchant-allowlist behaviour.
336
345
  const validResult = AP2Mandates.validateIntentCoverage(intentMandate, {
337
346
  totalAmount: 500,
347
+ currency: 'USD',
348
+ category: 'electronics',
338
349
  merchantId: 'merchant-a',
339
350
  });
340
351
  assert.strictEqual(validResult.valid, true);
341
352
 
342
353
  const invalidResult = AP2Mandates.validateIntentCoverage(intentMandate, {
343
354
  totalAmount: 500,
355
+ currency: 'USD',
356
+ category: 'electronics',
344
357
  merchantId: 'merchant-c',
345
358
  });
346
359
  assert.strictEqual(invalidResult.valid, false);
347
- assert.ok(invalidResult.errors[0].includes('allowlist'));
360
+ assert.ok(invalidResult.errors.some(e => e.includes('allowlist')), `Expected an 'allowlist' error, got: ${JSON.stringify(invalidResult.errors)}`);
348
361
  });
349
362
 
350
363
  test('validates merchant blocklist', async () => {
@@ -357,16 +370,20 @@ describe('AP2 Intent Coverage Validation', () => {
357
370
 
358
371
  const validResult = AP2Mandates.validateIntentCoverage(intentMandate, {
359
372
  totalAmount: 500,
373
+ currency: 'USD',
374
+ category: 'electronics',
360
375
  merchantId: 'good-merchant',
361
376
  });
362
377
  assert.strictEqual(validResult.valid, true);
363
378
 
364
379
  const invalidResult = AP2Mandates.validateIntentCoverage(intentMandate, {
365
380
  totalAmount: 500,
381
+ currency: 'USD',
382
+ category: 'electronics',
366
383
  merchantId: 'bad-merchant',
367
384
  });
368
385
  assert.strictEqual(invalidResult.valid, false);
369
- assert.ok(invalidResult.errors[0].includes('blocklisted'));
386
+ assert.ok(invalidResult.errors.some(e => e.includes('blocklisted')), `Expected a 'blocklisted' error, got: ${JSON.stringify(invalidResult.errors)}`);
370
387
  });
371
388
 
372
389
  test('validates expiration', async () => {
@@ -379,10 +396,12 @@ describe('AP2 Intent Coverage Validation', () => {
379
396
 
380
397
  const result = AP2Mandates.validateIntentCoverage(expiredMandate, {
381
398
  totalAmount: 500,
399
+ currency: 'USD',
400
+ category: 'electronics',
382
401
  });
383
402
 
384
403
  assert.strictEqual(result.valid, false);
385
- assert.ok(result.errors[0].includes('expired'));
404
+ assert.ok(result.errors.some(e => e.includes('expired')), `Expected an 'expired' error, got: ${JSON.stringify(result.errors)}`);
386
405
  });
387
406
  });
388
407
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ScoutClient signed-request path tests (SDK-01, DEC-148)
3
+ *
4
+ * Regression guard for the path-versioning bug: the client builds the URL as
5
+ * `${coreUrl}/v1${path}` but must SIGN the same versioned path, because Core mounts
6
+ * routes under /v1 and verifies request.url. These tests drive the REAL client with a
7
+ * mocked fetch and verify the emitted X-Agent-Signature against a reconstruction that
8
+ * uses the URL's pathname — so a client that signs the bare (unversioned) path fails.
9
+ */
10
+
11
+ import { describe, it, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import crypto from 'node:crypto';
14
+ import { ScoutClient } from '../client.js';
15
+ import { KeyManager, MemoryStorage } from '../key-manager.js';
16
+
17
+ // Rebuild a Node KeyObject from a raw base64 Ed25519 public key (mirrors Core).
18
+ function rawPublicKeyToKeyObject(base64Key) {
19
+ const raw = Buffer.from(base64Key, 'base64');
20
+ const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]);
21
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
22
+ }
23
+
24
+ // Core's canonical authenticated_request signing string (buildSigningString).
25
+ function coreSigningString(method, path, timestamp, bodyString, nonce, host) {
26
+ const bodyDigest = bodyString ? crypto.createHash('sha256').update(bodyString).digest('base64') : '';
27
+ const base = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyDigest}`;
28
+ return host ? `${base}\n${host}` : base;
29
+ }
30
+
31
+ async function signedClient(coreUrl) {
32
+ const km = new KeyManager({ storage: new MemoryStorage() });
33
+ await km.init();
34
+ const client = new ScoutClient({ coreUrl, timeout: 5000 });
35
+ client.setKeyManager(km, '11111111-2222-4333-8444-555555555555');
36
+ return { client, publicKey: km.publicKey };
37
+ }
38
+
39
+ function captureFetch() {
40
+ const state = {};
41
+ globalThis.fetch = async (url, opts) => {
42
+ state.url = url;
43
+ state.opts = opts;
44
+ return { ok: true, status: 200, json: async () => ({}) };
45
+ };
46
+ return state;
47
+ }
48
+
49
+ describe('ScoutClient signs the versioned path it sends (SDK-01, DEC-148)', () => {
50
+ let originalFetch;
51
+ beforeEach(() => { originalFetch = globalThis.fetch; });
52
+ afterEach(() => { globalThis.fetch = originalFetch; });
53
+
54
+ it('a signed GET verifies against the VERSIONED path the client actually sent', async () => {
55
+ const captured = captureFetch();
56
+ const { client, publicKey } = await signedClient('https://core-a.example');
57
+
58
+ await client.get('/sessions/abc');
59
+
60
+ const sentPath = new URL(captured.url).pathname;
61
+ assert.equal(sentPath, '/v1/sessions/abc', 'the client sends the versioned path');
62
+ const h = captured.opts.headers;
63
+ const signingString = coreSigningString('GET', sentPath, h['X-Agent-Timestamp'], null, h['X-Agent-Nonce'], h['X-Agent-Host']);
64
+ const ok = crypto.verify(null, Buffer.from(signingString), rawPublicKeyToKeyObject(publicKey), Buffer.from(h['X-Agent-Signature'], 'base64'));
65
+ assert.equal(ok, true, 'X-Agent-Signature must verify against the versioned path (fails if the client signed the bare path)');
66
+ });
67
+
68
+ it('a signed POST with body verifies against the versioned path and the sent bytes', async () => {
69
+ const captured = captureFetch();
70
+ const { client, publicKey } = await signedClient('https://core-a.example');
71
+
72
+ await client.post('/sessions', { intent: 'buy widgets' });
73
+
74
+ const sentPath = new URL(captured.url).pathname;
75
+ assert.equal(sentPath, '/v1/sessions');
76
+ const h = captured.opts.headers;
77
+ const signingString = coreSigningString('POST', sentPath, h['X-Agent-Timestamp'], captured.opts.body, h['X-Agent-Nonce'], h['X-Agent-Host']);
78
+ const ok = crypto.verify(null, Buffer.from(signingString), rawPublicKeyToKeyObject(publicKey), Buffer.from(h['X-Agent-Signature'], 'base64'));
79
+ assert.equal(ok, true);
80
+ assert.equal(h['X-Agent-Host'], 'core-a.example', 'the bound host is the target Core host');
81
+ });
82
+ });