@mcpose/audit 2.0.2 → 3.0.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -28
  3. package/dist/chain.d.ts +55 -0
  4. package/dist/chain.d.ts.map +1 -0
  5. package/dist/chain.js +265 -0
  6. package/dist/chain.js.map +1 -0
  7. package/dist/index.d.ts +9 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +7 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware.d.ts +5 -0
  12. package/dist/middleware.d.ts.map +1 -0
  13. package/dist/middleware.js +488 -0
  14. package/dist/middleware.js.map +1 -0
  15. package/dist/sensitivity.d.ts +12 -0
  16. package/dist/sensitivity.d.ts.map +1 -0
  17. package/dist/sensitivity.js +24 -0
  18. package/dist/sensitivity.js.map +1 -0
  19. package/dist/signingKey.d.ts +11 -0
  20. package/dist/signingKey.d.ts.map +1 -0
  21. package/dist/signingKey.js +30 -0
  22. package/dist/signingKey.js.map +1 -0
  23. package/dist/subjectKeyStore.d.ts +17 -0
  24. package/dist/subjectKeyStore.d.ts.map +1 -0
  25. package/dist/subjectKeyStore.js +37 -0
  26. package/dist/subjectKeyStore.js.map +1 -0
  27. package/dist/types.d.ts +244 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +2 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/verify.d.ts +24 -0
  32. package/dist/verify.d.ts.map +1 -0
  33. package/dist/verify.js +50 -0
  34. package/dist/verify.js.map +1 -0
  35. package/package.json +43 -14
  36. package/src/__tests__/chain.batch.test.ts +30 -0
  37. package/src/__tests__/chain.test.ts +399 -0
  38. package/src/__tests__/erasable.test.ts +499 -0
  39. package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
  40. package/src/__tests__/middleware.http.test.ts +172 -0
  41. package/src/__tests__/middleware.robustness.test.ts +737 -0
  42. package/src/__tests__/middleware.test.ts +1121 -0
  43. package/src/__tests__/properties.test.ts +737 -0
  44. package/src/__tests__/sensitivity.test.ts +72 -0
  45. package/src/__tests__/signingKey.test.ts +76 -0
  46. package/src/chain.ts +317 -0
  47. package/src/index.ts +31 -0
  48. package/src/middleware.ts +690 -0
  49. package/src/sensitivity.ts +33 -0
  50. package/src/signingKey.ts +35 -0
  51. package/src/subjectKeyStore.ts +38 -0
  52. package/src/types.ts +281 -0
  53. package/src/verify.ts +76 -0
@@ -0,0 +1,737 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createAuditMiddleware } from '../middleware.js';
3
+ import { createDefaultSigningKeyProvider } from '../signingKey.js';
4
+ import { createSensitivityResolver } from '../sensitivity.js';
5
+ import { verifyAuditChain, verifyManifestSignature } from '../verify.js';
6
+ import type { AuditEvent, AuditOptions } from '../types.js';
7
+ import { createProxyContext } from 'mcpose';
8
+ import type { Identity } from 'mcpose';
9
+
10
+ /**
11
+ * Shaped like an McpError from rejectionMcpError() without importing the
12
+ * SDK: the middleware duck-types `err.data.rejectionReason`.
13
+ */
14
+ function rejectionError(reason: string, message: string): Error {
15
+ return Object.assign(new Error(message), {
16
+ code: -32601,
17
+ data: { rejectionReason: reason },
18
+ });
19
+ }
20
+
21
+ const identity: Identity = {
22
+ sub: 'user-1',
23
+ type: 'human',
24
+ roles: ['analyst'],
25
+ claims: {},
26
+ resolvedAt: '2026-06-01T00:00:00.000Z',
27
+ source: 'jwt',
28
+ };
29
+
30
+ const signingKey = createDefaultSigningKeyProvider('test-secret');
31
+
32
+ function makeOptions(overrides: Partial<AuditOptions> = {}): AuditOptions {
33
+ return {
34
+ signingKey,
35
+ sensitivityResolver: createSensitivityResolver({ search: 'low' }),
36
+ onEvent: vi.fn(),
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ // Every context carries a proxy identity: it is a required covered field, and
42
+ // the middleware rejects a context without one at the pre-call stage (ADR-0019).
43
+ const defaultProxy = { name: 'test-proxy', version: '0.0.0' };
44
+
45
+ function makeCtx(sessionId?: string) {
46
+ return createProxyContext({
47
+ transport: 'http',
48
+ identity,
49
+ sessionId,
50
+ proxy: defaultProxy,
51
+ });
52
+ }
53
+
54
+ function makeReq(tool: string, args: Record<string, unknown> = {}) {
55
+ return {
56
+ method: 'tools/call' as const,
57
+ params: { name: tool, arguments: args },
58
+ };
59
+ }
60
+
61
+ describe('createAuditMiddleware — concurrency', () => {
62
+ it('allocates unique sequential positions under 20 concurrent calls', async () => {
63
+ const events: AuditEvent[] = [];
64
+ const { middleware } = createAuditMiddleware(
65
+ makeOptions({
66
+ onEvent: (e) => {
67
+ events.push(e);
68
+ },
69
+ }),
70
+ );
71
+
72
+ await Promise.all(
73
+ Array.from({ length: 20 }, (_, i) =>
74
+ middleware(
75
+ makeReq('search', { i }),
76
+ async () => {
77
+ // Random completion order so appends interleave.
78
+ await new Promise((r) => setTimeout(r, Math.floor((i * 7) % 13)));
79
+ return { content: [] };
80
+ },
81
+ makeCtx('concurrent-session'),
82
+ ),
83
+ ),
84
+ );
85
+
86
+ const positions = events
87
+ .map((e) => e.replayManifestPosition)
88
+ .sort((a, b) => a - b);
89
+ expect(positions).toEqual(Array.from({ length: 20 }, (_, i) => i));
90
+
91
+ // The chain must ALSO recompute — positions being unique is necessary
92
+ // but not sufficient (prevChainHash links must be consistent too).
93
+ const ordered = [...events].sort(
94
+ (a, b) => a.replayManifestPosition - b.replayManifestPosition,
95
+ );
96
+ expect(await verifyAuditChain(ordered, signingKey)).toEqual({
97
+ valid: true,
98
+ });
99
+ });
100
+ });
101
+
102
+ describe('createAuditMiddleware — never blocks the call path', () => {
103
+ it('a throwing onEvent sink does not fail a successful call', async () => {
104
+ const audiErrors: unknown[] = [];
105
+ const { middleware } = createAuditMiddleware(
106
+ makeOptions({
107
+ onEvent: () => {
108
+ throw new Error('sink down');
109
+ },
110
+ onAuditError: (err) => {
111
+ audiErrors.push(err);
112
+ },
113
+ }),
114
+ );
115
+
116
+ const result = await middleware(
117
+ makeReq('search'),
118
+ async () => ({ content: [{ type: 'text', text: 'ok' }] }),
119
+ makeCtx('s1'),
120
+ );
121
+ expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }] });
122
+ expect(audiErrors).toHaveLength(1);
123
+ });
124
+
125
+ it('a throwing onEvent sink does not mask the upstream error', async () => {
126
+ const { middleware } = createAuditMiddleware(
127
+ makeOptions({
128
+ onEvent: () => {
129
+ throw new Error('sink down');
130
+ },
131
+ onAuditError: () => {},
132
+ }),
133
+ );
134
+
135
+ await expect(
136
+ middleware(
137
+ makeReq('search'),
138
+ async () => {
139
+ throw new Error('upstream down');
140
+ },
141
+ makeCtx('s1'),
142
+ ),
143
+ ).rejects.toThrow('upstream down');
144
+ });
145
+
146
+ it('a throwing onAuditError reporter does not fail a successful call', async () => {
147
+ const onAuditError = vi.fn(() => {
148
+ throw new Error('reporter down');
149
+ });
150
+ const { middleware } = createAuditMiddleware(
151
+ makeOptions({
152
+ onEvent: () => {
153
+ throw new Error('sink down');
154
+ },
155
+ onAuditError,
156
+ }),
157
+ );
158
+
159
+ await expect(
160
+ middleware(
161
+ makeReq('search'),
162
+ async () => ({ content: [{ type: 'text', text: 'ok' }] }),
163
+ makeCtx('s1'),
164
+ ),
165
+ ).resolves.toEqual({ content: [{ type: 'text', text: 'ok' }] });
166
+ expect(onAuditError).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it('a throwing onAuditError reporter does not mask the upstream error', async () => {
170
+ const upstreamError = new Error('upstream down');
171
+ const onAuditError = vi.fn(() => {
172
+ throw new Error('reporter down');
173
+ });
174
+ const { middleware } = createAuditMiddleware(
175
+ makeOptions({
176
+ onEvent: () => {
177
+ throw new Error('sink down');
178
+ },
179
+ onAuditError,
180
+ }),
181
+ );
182
+
183
+ await expect(
184
+ middleware(
185
+ makeReq('search'),
186
+ async () => {
187
+ throw upstreamError;
188
+ },
189
+ makeCtx('s1'),
190
+ ),
191
+ ).rejects.toBe(upstreamError);
192
+ expect(onAuditError).toHaveBeenCalledTimes(1);
193
+ });
194
+
195
+ it('a throwing onAuditError reporter keeps the sensitivity fallback and event', async () => {
196
+ const events: AuditEvent[] = [];
197
+ const onAuditError = vi.fn(() => {
198
+ throw new Error('reporter down');
199
+ });
200
+ const { middleware } = createAuditMiddleware(
201
+ makeOptions({
202
+ sensitivityResolver: () => {
203
+ throw new Error('resolver bug');
204
+ },
205
+ onEvent: (event) => {
206
+ events.push(event);
207
+ },
208
+ onAuditError,
209
+ }),
210
+ );
211
+
212
+ await expect(
213
+ middleware(
214
+ makeReq('search', { ssn: '123-45-6789' }),
215
+ async () => ({ content: [] }),
216
+ makeCtx('s1'),
217
+ ),
218
+ ).resolves.toEqual({ content: [] });
219
+ expect(onAuditError).toHaveBeenCalledTimes(1);
220
+ expect(events).toHaveLength(1);
221
+ expect(events[0]!.sensitivityTier).toBe('high');
222
+ });
223
+
224
+ it('circular and BigInt arguments still produce an event', async () => {
225
+ const events: AuditEvent[] = [];
226
+ const { middleware } = createAuditMiddleware(
227
+ makeOptions({
228
+ onEvent: (e) => {
229
+ events.push(e);
230
+ },
231
+ }),
232
+ );
233
+ const circular: Record<string, unknown> = { amount: 10n };
234
+ circular.self = circular;
235
+
236
+ const result = await middleware(
237
+ makeReq('search', circular),
238
+ async () => ({ content: [] }),
239
+ makeCtx('s2'),
240
+ );
241
+ expect(result).toEqual({ content: [] });
242
+ expect(events).toHaveLength(1);
243
+ expect(events[0]!.inputHash).toMatch(/^[0-9a-f]{64}$/);
244
+ });
245
+
246
+ it('a throwing sensitivityResolver degrades to high tier, not a failed call', async () => {
247
+ const events: AuditEvent[] = [];
248
+ const auditErrors: unknown[] = [];
249
+ const { middleware } = createAuditMiddleware(
250
+ makeOptions({
251
+ sensitivityResolver: () => {
252
+ throw new Error('resolver bug');
253
+ },
254
+ onEvent: (e) => {
255
+ events.push(e);
256
+ },
257
+ onAuditError: (err) => {
258
+ auditErrors.push(err);
259
+ },
260
+ }),
261
+ );
262
+
263
+ const result = await middleware(
264
+ makeReq('search', { ssn: '123-45-6789' }),
265
+ async () => ({ content: [] }),
266
+ makeCtx('s3'),
267
+ );
268
+ expect(result).toEqual({ content: [] });
269
+ expect(events[0]!.sensitivityTier).toBe('high');
270
+ expect(auditErrors).toHaveLength(1);
271
+ });
272
+ });
273
+
274
+ describe('createAuditMiddleware — error and rejection events', () => {
275
+ it('records structured error details, distinct outputHash per error', async () => {
276
+ const events: AuditEvent[] = [];
277
+ const { middleware } = createAuditMiddleware(
278
+ makeOptions({
279
+ onEvent: (e) => {
280
+ events.push(e);
281
+ },
282
+ }),
283
+ );
284
+
285
+ await expect(
286
+ middleware(
287
+ makeReq('search'),
288
+ async () => {
289
+ throw new TypeError('bad input');
290
+ },
291
+ makeCtx('e1'),
292
+ ),
293
+ ).rejects.toThrow('bad input');
294
+
295
+ expect(events[0]!.outcome).toBe('error');
296
+ expect(events[0]!.error).toEqual({
297
+ name: 'TypeError',
298
+ message: 'bad input',
299
+ });
300
+ });
301
+
302
+ it('records outcome rejected with rejectionReason for MCP rejections', async () => {
303
+ const events: AuditEvent[] = [];
304
+ const { middleware } = createAuditMiddleware(
305
+ makeOptions({
306
+ onEvent: (e) => {
307
+ events.push(e);
308
+ },
309
+ }),
310
+ );
311
+
312
+ await expect(
313
+ middleware(
314
+ makeReq('hidden_tool'),
315
+ async () => {
316
+ throw rejectionError('TOOL_HIDDEN', 'Tool not found: hidden_tool');
317
+ },
318
+ makeCtx('r1'),
319
+ ),
320
+ ).rejects.toThrow('Tool not found: hidden_tool');
321
+
322
+ expect(events).toHaveLength(1);
323
+ expect(events[0]!.outcome).toBe('rejected');
324
+ expect(events[0]!.rejectionReason).toBe('TOOL_HIDDEN');
325
+ });
326
+
327
+ it('includeRejections: false skips rejection events and keeps positions continuous', async () => {
328
+ const events: AuditEvent[] = [];
329
+ const { middleware } = createAuditMiddleware(
330
+ makeOptions({
331
+ includeRejections: false,
332
+ onEvent: (e) => {
333
+ events.push(e);
334
+ },
335
+ }),
336
+ );
337
+ const ctx = () => makeCtx('r2');
338
+
339
+ await middleware(makeReq('search'), async () => ({ content: [] }), ctx());
340
+ await expect(
341
+ middleware(
342
+ makeReq('hidden_tool'),
343
+ async () => {
344
+ throw rejectionError('TOOL_HIDDEN', 'nope');
345
+ },
346
+ ctx(),
347
+ ),
348
+ ).rejects.toThrow();
349
+ await middleware(makeReq('search'), async () => ({ content: [] }), ctx());
350
+
351
+ expect(events.map((e) => e.outcome)).toEqual(['success', 'success']);
352
+ expect(events.map((e) => e.replayManifestPosition)).toEqual([0, 1]);
353
+ expect(await verifyAuditChain(events, signingKey)).toEqual({ valid: true });
354
+ });
355
+ });
356
+
357
+ describe('createAuditMiddleware — close during an in-flight call (#168)', () => {
358
+ function deferred<T = void>() {
359
+ let resolve!: (v: T) => void;
360
+ const promise = new Promise<T>((r) => (resolve = r));
361
+ return { promise, resolve };
362
+ }
363
+
364
+ it('closeSession waits for admitted calls, so the manifest covers them', async () => {
365
+ const events: AuditEvent[] = [];
366
+ const { middleware, closeSession } = createAuditMiddleware(
367
+ makeOptions({ onEvent: (e) => void events.push(e) }),
368
+ );
369
+
370
+ // An earlier completed event, then a call blocked inside the upstream.
371
+ await middleware(
372
+ makeReq('search'),
373
+ async () => ({ content: [] }),
374
+ makeCtx('s'),
375
+ );
376
+ const entered = deferred();
377
+ const release = deferred();
378
+ const blocked = middleware(
379
+ makeReq('search'),
380
+ async () => {
381
+ entered.resolve();
382
+ await release.promise;
383
+ return { content: [] };
384
+ },
385
+ makeCtx('s'),
386
+ );
387
+ await entered.promise;
388
+
389
+ const closing = closeSession('s');
390
+ // The close must not settle while the call is still in flight.
391
+ let settled = false;
392
+ void closing.then(() => (settled = true));
393
+ await new Promise((r) => setTimeout(r, 10));
394
+ expect(settled).toBe(false);
395
+
396
+ release.resolve();
397
+ await blocked;
398
+ const manifest = await closing;
399
+
400
+ expect(events.map((e) => e.replayManifestPosition)).toEqual([0, 1]);
401
+ expect(manifest?.eventCount).toBe(2);
402
+ expect(manifest?.merkleProofs).toHaveLength(2);
403
+ expect(await verifyAuditChain(events, signingKey)).toEqual({ valid: true });
404
+ // Sealed once: the session is gone afterwards.
405
+ expect(await closeSession('s')).toBeUndefined();
406
+ });
407
+
408
+ it('waits for a pending onEvent sink before sealing', async () => {
409
+ const persisted = deferred();
410
+ const { middleware, closeSession } = createAuditMiddleware(
411
+ makeOptions({ onEvent: () => persisted.promise }),
412
+ );
413
+ const call = middleware(
414
+ makeReq('search'),
415
+ async () => ({ content: [] }),
416
+ makeCtx('s'),
417
+ );
418
+ await new Promise((r) => setTimeout(r, 0));
419
+ const closing = closeSession('s');
420
+ let settled = false;
421
+ void closing.then(() => (settled = true));
422
+ await new Promise((r) => setTimeout(r, 10));
423
+ expect(settled).toBe(false);
424
+
425
+ persisted.resolve();
426
+ await call;
427
+ expect((await closing)?.eventCount).toBe(1);
428
+ });
429
+
430
+ it('counts a call as in flight from before subkey derivation, not after', async () => {
431
+ // The first sign() call (chain subkey derivation) blocks, so the call is
432
+ // suspended in the pre-call await when close arrives. It must still be
433
+ // sealed into the manifest rather than resurrect the session afterwards.
434
+ const release = deferred();
435
+ const inner = createDefaultSigningKeyProvider('test-secret');
436
+ let first = true;
437
+ const slowKey: typeof inner = {
438
+ keyId: inner.keyId,
439
+ algorithm: inner.algorithm,
440
+ sign: async (payload: Buffer) => {
441
+ if (first) {
442
+ first = false;
443
+ await release.promise;
444
+ }
445
+ return inner.sign(payload);
446
+ },
447
+ };
448
+ const events: AuditEvent[] = [];
449
+ const { middleware, closeSession } = createAuditMiddleware(
450
+ makeOptions({ signingKey: slowKey, onEvent: (e) => void events.push(e) }),
451
+ );
452
+ const call = middleware(
453
+ makeReq('search'),
454
+ async () => ({ content: [] }),
455
+ makeCtx('s'),
456
+ );
457
+ const closing = closeSession('s');
458
+ let settled = false;
459
+ void closing.then(() => (settled = true));
460
+ await new Promise((r) => setTimeout(r, 10));
461
+ expect(settled).toBe(false);
462
+
463
+ release.resolve();
464
+ await call;
465
+ expect((await closing)?.eventCount).toBe(1);
466
+ expect(events[0]!.replayManifestPosition).toBe(0);
467
+ expect(await closeSession('s')).toBeUndefined();
468
+ });
469
+
470
+ it('a rejected pre-call key fetch does not leave the session looking busy', async () => {
471
+ const onAuditError = vi.fn();
472
+ const { middleware, closeSession } = createAuditMiddleware(
473
+ makeOptions({
474
+ onAuditError,
475
+ keyStore: {
476
+ getOrCreate: () => Promise.reject(new Error('kms down')),
477
+ destroy: async () => ({ destroyedAt: new Date().toISOString() }),
478
+ },
479
+ }),
480
+ );
481
+ await expect(
482
+ middleware(
483
+ makeReq('search'),
484
+ async () => ({ content: [] }),
485
+ makeCtx('s'),
486
+ ),
487
+ ).rejects.toThrow('kms down');
488
+ // No events, so no manifest, but the close must resolve promptly rather
489
+ // than wait on a call that already failed.
490
+ expect(await closeSession('s')).toBeUndefined();
491
+ expect(onAuditError).not.toHaveBeenCalled();
492
+ });
493
+
494
+ it('seals after closeDrainTimeoutMs and reports each stuck call', async () => {
495
+ const events: AuditEvent[] = [];
496
+ const onAuditError = vi.fn();
497
+ const { middleware, closeSession } = createAuditMiddleware(
498
+ makeOptions({
499
+ closeDrainTimeoutMs: 20,
500
+ onAuditError,
501
+ onEvent: (e) => void events.push(e),
502
+ }),
503
+ );
504
+ await middleware(
505
+ makeReq('search'),
506
+ async () => ({ content: [] }),
507
+ makeCtx('s'),
508
+ );
509
+ const ctx = makeCtx('s');
510
+ void middleware(makeReq('search'), () => new Promise(() => {}), ctx);
511
+
512
+ const manifest = await closeSession('s');
513
+ expect(manifest?.eventCount).toBe(1);
514
+ expect(events).toHaveLength(1);
515
+ expect(onAuditError).toHaveBeenCalledTimes(1);
516
+ expect(onAuditError.mock.calls[0]![0]).toBeInstanceOf(Error);
517
+ expect(onAuditError.mock.calls[0]![1]).toEqual({
518
+ tool: 'search',
519
+ requestId: ctx.requestId,
520
+ sessionId: 's',
521
+ });
522
+ expect(await closeSession('s')).toBeUndefined();
523
+ });
524
+
525
+ it('concurrent closeSession calls share one manifest', async () => {
526
+ const { middleware, closeSession } = createAuditMiddleware(makeOptions());
527
+ const release = deferred();
528
+ const blocked = middleware(
529
+ makeReq('search'),
530
+ async () => {
531
+ await release.promise;
532
+ return { content: [] };
533
+ },
534
+ makeCtx('s'),
535
+ );
536
+ await new Promise((r) => setTimeout(r, 0));
537
+ const [a, b] = [closeSession('s'), closeSession('s')];
538
+ release.resolve();
539
+ await blocked;
540
+ const [ma, mb] = await Promise.all([a, b]);
541
+ expect(ma).toBeDefined();
542
+ expect(mb).toBe(ma);
543
+ });
544
+ });
545
+
546
+ describe('createAuditMiddleware — session hygiene', () => {
547
+ it('closeSession is idempotent: second call returns undefined', async () => {
548
+ const { middleware, closeSession } = createAuditMiddleware(makeOptions());
549
+ await middleware(
550
+ makeReq('search'),
551
+ async () => ({ content: [] }),
552
+ makeCtx('h1'),
553
+ );
554
+
555
+ expect(await closeSession('h1')).toBeDefined();
556
+ expect(await closeSession('h1')).toBeUndefined();
557
+ });
558
+
559
+ it('an empty session is removed from memory on closeSession', async () => {
560
+ const { middleware, closeSession } = createAuditMiddleware(
561
+ makeOptions({ includeRejections: false }),
562
+ );
563
+ // Session state is created, but the only call is a skipped rejection.
564
+ await expect(
565
+ middleware(
566
+ makeReq('hidden_tool'),
567
+ async () => {
568
+ throw rejectionError('TOOL_HIDDEN', 'nope');
569
+ },
570
+ makeCtx('h2'),
571
+ ),
572
+ ).rejects.toThrow();
573
+
574
+ expect(await closeSession('h2')).toBeUndefined();
575
+ // Second close: the session must have been deleted, not retained.
576
+ expect(await closeSession('h2')).toBeUndefined();
577
+ });
578
+
579
+ it('a failed subkey derivation is retryable (transient provider error)', async () => {
580
+ let calls = 0;
581
+ const flaky = {
582
+ ...signingKey,
583
+ sign: async (data: Buffer) => {
584
+ calls += 1;
585
+ if (calls <= 2) throw new Error('KMS unavailable');
586
+ return signingKey.sign(data);
587
+ },
588
+ };
589
+ const events: AuditEvent[] = [];
590
+ const { middleware } = createAuditMiddleware(
591
+ makeOptions({
592
+ signingKey: flaky,
593
+ onEvent: (e) => {
594
+ events.push(e);
595
+ },
596
+ }),
597
+ );
598
+
599
+ await expect(
600
+ middleware(
601
+ makeReq('search'),
602
+ async () => ({ content: [] }),
603
+ makeCtx('k1'),
604
+ ),
605
+ ).rejects.toThrow('KMS unavailable');
606
+
607
+ // Second attempt succeeds — the rejected derivation was not cached.
608
+ await middleware(
609
+ makeReq('search'),
610
+ async () => ({ content: [] }),
611
+ makeCtx('k1'),
612
+ );
613
+ expect(events).toHaveLength(1);
614
+ });
615
+ });
616
+
617
+ describe('createAuditMiddleware — sealed state survives a failed close (#169)', () => {
618
+ const MANIFEST_DOMAIN = 'mcpose/v2/manifest';
619
+
620
+ /** Delegates to the real provider but rejects the first N manifest payloads. */
621
+ function flakySigner(failures: number) {
622
+ let remaining = failures;
623
+ const manifestSigns = vi.fn();
624
+ const provider: AuditOptions['signingKey'] = {
625
+ keyId: signingKey.keyId,
626
+ algorithm: signingKey.algorithm,
627
+ sign: async (data) => {
628
+ if (data.toString().includes(MANIFEST_DOMAIN)) {
629
+ manifestSigns();
630
+ if (remaining > 0) {
631
+ remaining -= 1;
632
+ throw new Error('kms unavailable');
633
+ }
634
+ }
635
+ return signingKey.sign(data);
636
+ },
637
+ };
638
+ return { provider, manifestSigns };
639
+ }
640
+
641
+ async function recordOne(
642
+ middleware: ReturnType<typeof createAuditMiddleware>['middleware'],
643
+ sessionId: string,
644
+ ) {
645
+ await middleware(
646
+ makeReq('search'),
647
+ async () => ({ content: [] }),
648
+ makeCtx(sessionId),
649
+ );
650
+ }
651
+
652
+ it('a one-shot signing failure is retryable and the retry delivers a valid manifest', async () => {
653
+ const { provider } = flakySigner(1);
654
+ const onManifest = vi.fn();
655
+ const { middleware, closeSession } = createAuditMiddleware(
656
+ makeOptions({ signingKey: provider, onManifest }),
657
+ );
658
+ await recordOne(middleware, 's');
659
+
660
+ await expect(closeSession('s')).rejects.toThrow('kms unavailable');
661
+ expect(onManifest).not.toHaveBeenCalled();
662
+
663
+ const manifest = await closeSession('s');
664
+ expect(manifest).toBeDefined();
665
+ expect(await verifyManifestSignature(manifest!, signingKey)).toBe(true);
666
+ expect(manifest!.eventCount).toBe(1);
667
+ expect(onManifest).toHaveBeenCalledTimes(1);
668
+ expect(onManifest).toHaveBeenCalledWith(manifest);
669
+
670
+ // Delivered: the sealed state is released and the id is unknown again.
671
+ expect(await closeSession('s')).toBeUndefined();
672
+ });
673
+
674
+ it('a one-shot onManifest failure re-delivers the identical manifest without signing again', async () => {
675
+ const { provider, manifestSigns } = flakySigner(0);
676
+ const delivered: unknown[] = [];
677
+ const onManifest = vi
678
+ .fn()
679
+ .mockImplementationOnce(async (manifest: unknown) => {
680
+ delivered.push(manifest);
681
+ throw new Error('manifest store down');
682
+ })
683
+ .mockImplementation(async (manifest: unknown) => {
684
+ delivered.push(manifest);
685
+ });
686
+ const { middleware, closeSession } = createAuditMiddleware(
687
+ makeOptions({ signingKey: provider, onManifest }),
688
+ );
689
+ await recordOne(middleware, 's');
690
+
691
+ await expect(closeSession('s')).rejects.toThrow('manifest store down');
692
+ const manifest = await closeSession('s');
693
+
694
+ expect(delivered).toHaveLength(2);
695
+ // Same bytes both times: same closedAt, same signature, no second artifact.
696
+ expect(JSON.stringify(delivered[1])).toBe(JSON.stringify(delivered[0]));
697
+ expect(delivered[1]).toEqual(manifest);
698
+ expect(manifestSigns).toHaveBeenCalledTimes(1);
699
+ expect(await verifyManifestSignature(manifest!, signingKey)).toBe(true);
700
+ expect(await closeSession('s')).toBeUndefined();
701
+ });
702
+
703
+ it('concurrent retries share one attempt', async () => {
704
+ const { provider, manifestSigns } = flakySigner(1);
705
+ const onManifest = vi.fn();
706
+ const { middleware, closeSession } = createAuditMiddleware(
707
+ makeOptions({ signingKey: provider, onManifest }),
708
+ );
709
+ await recordOne(middleware, 's');
710
+ await expect(closeSession('s')).rejects.toThrow('kms unavailable');
711
+
712
+ const [a, b] = await Promise.all([closeSession('s'), closeSession('s')]);
713
+ expect(a).toBeDefined();
714
+ expect(b).toBe(a);
715
+ expect(manifestSigns).toHaveBeenCalledTimes(2);
716
+ expect(onManifest).toHaveBeenCalledTimes(1);
717
+ });
718
+
719
+ it('a sealed session does not admit new calls: they start a fresh session under the id', async () => {
720
+ const { provider } = flakySigner(1);
721
+ const onAuditError = vi.fn();
722
+ const { middleware, closeSession } = createAuditMiddleware(
723
+ makeOptions({ signingKey: provider, onAuditError }),
724
+ );
725
+ await recordOne(middleware, 's');
726
+ await expect(closeSession('s')).rejects.toThrow('kms unavailable');
727
+
728
+ // The id is reused while the first manifest is still undelivered.
729
+ await recordOne(middleware, 's');
730
+ const second = await closeSession('s');
731
+ expect(second?.eventCount).toBe(1);
732
+ expect(onAuditError).toHaveBeenCalledTimes(1);
733
+ expect(String(onAuditError.mock.calls[0]![0])).toMatch(
734
+ /replaced an undelivered manifest/,
735
+ );
736
+ });
737
+ });