@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 +30 -0
- package/package.json +5 -7
- package/src/ap2/mandates.js +20 -15
- package/src/client.js +11 -3
- package/src/index.js +19 -3
- package/src/key-manager.js +11 -5
- package/src/key-manager.test.js +33 -8
- package/src/session.js +34 -23
- package/src/session.test.js +178 -0
- package/src/tap/visa.js +15 -9
- package/src/tests/ap2-mandates.test.js +23 -4
- package/src/tests/client.test.js +82 -0
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.
|
|
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.
|
|
41
|
-
"@aura-labs-ai/sdk-common": "^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"
|
package/src/ap2/mandates.js
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* @see https://ap2-protocol.org/specification/
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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,
|
package/src/key-manager.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
|
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
|
/**
|
package/src/key-manager.test.js
CHANGED
|
@@ -161,13 +161,17 @@ describe('KeyManager signRequest', () => {
|
|
|
161
161
|
await km.init();
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
it('should return signature and
|
|
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
|
|
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
|
-
|
|
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.
|
|
346
|
-
violations.push(`price_exceeds_budget:${offer.
|
|
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.
|
|
353
|
-
violations.push(`delivery_too_late:${offer.
|
|
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
|
-
|
|
375
|
-
|
|
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.
|
|
381
|
-
const deliveryDate = new Date(offer.
|
|
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.
|
|
445
|
-
get beaconName() { return this.#data.
|
|
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.
|
|
455
|
+
get unitPrice() { return this.#data.unit_price; }
|
|
448
456
|
get quantity() { return this.#data.quantity; }
|
|
449
|
-
get totalPrice() { return this.#data.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
505
|
-
get paymentStatus() { return this.#data.
|
|
506
|
-
get fulfillmentStatus() { return this.#data.
|
|
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.
|
|
589
|
-
name: this.#data.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|