@mcp-i/core 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/auth/handshake.d.ts +19 -4
  2. package/dist/auth/handshake.d.ts.map +1 -1
  3. package/dist/auth/handshake.js +52 -15
  4. package/dist/auth/handshake.js.map +1 -1
  5. package/dist/auth/index.d.ts +1 -1
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/delegation/cascading-revocation.d.ts.map +1 -1
  9. package/dist/delegation/cascading-revocation.js +3 -1
  10. package/dist/delegation/cascading-revocation.js.map +1 -1
  11. package/dist/delegation/did-key-resolver.d.ts.map +1 -1
  12. package/dist/delegation/did-key-resolver.js +9 -6
  13. package/dist/delegation/did-key-resolver.js.map +1 -1
  14. package/dist/delegation/outbound-headers.d.ts +14 -16
  15. package/dist/delegation/outbound-headers.d.ts.map +1 -1
  16. package/dist/delegation/outbound-headers.js +14 -15
  17. package/dist/delegation/outbound-headers.js.map +1 -1
  18. package/dist/delegation/outbound-proof.d.ts +1 -1
  19. package/dist/delegation/outbound-proof.js +1 -1
  20. package/dist/delegation/statuslist-manager.d.ts +3 -0
  21. package/dist/delegation/statuslist-manager.d.ts.map +1 -1
  22. package/dist/delegation/statuslist-manager.js +14 -1
  23. package/dist/delegation/statuslist-manager.js.map +1 -1
  24. package/dist/delegation/vc-verifier.d.ts.map +1 -1
  25. package/dist/delegation/vc-verifier.js +2 -2
  26. package/dist/delegation/vc-verifier.js.map +1 -1
  27. package/dist/errors.d.ts +42 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +45 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/index.d.ts +3 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/middleware/index.d.ts +1 -0
  36. package/dist/middleware/index.d.ts.map +1 -1
  37. package/dist/middleware/index.js +1 -0
  38. package/dist/middleware/index.js.map +1 -1
  39. package/dist/middleware/mcpi-transport.d.ts +39 -0
  40. package/dist/middleware/mcpi-transport.d.ts.map +1 -0
  41. package/dist/middleware/mcpi-transport.js +121 -0
  42. package/dist/middleware/mcpi-transport.js.map +1 -0
  43. package/dist/middleware/with-mcpi-server.d.ts +25 -9
  44. package/dist/middleware/with-mcpi-server.d.ts.map +1 -1
  45. package/dist/middleware/with-mcpi-server.js +62 -47
  46. package/dist/middleware/with-mcpi-server.js.map +1 -1
  47. package/dist/middleware/with-mcpi.d.ts +40 -5
  48. package/dist/middleware/with-mcpi.d.ts.map +1 -1
  49. package/dist/middleware/with-mcpi.js +120 -10
  50. package/dist/middleware/with-mcpi.js.map +1 -1
  51. package/dist/providers/memory.js +2 -2
  52. package/dist/providers/memory.js.map +1 -1
  53. package/dist/session/manager.d.ts +7 -1
  54. package/dist/session/manager.d.ts.map +1 -1
  55. package/dist/session/manager.js +20 -4
  56. package/dist/session/manager.js.map +1 -1
  57. package/dist/utils/crypto-service.d.ts.map +1 -1
  58. package/dist/utils/crypto-service.js +11 -10
  59. package/dist/utils/crypto-service.js.map +1 -1
  60. package/dist/utils/did-helpers.d.ts +12 -0
  61. package/dist/utils/did-helpers.d.ts.map +1 -1
  62. package/dist/utils/did-helpers.js +18 -0
  63. package/dist/utils/did-helpers.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/__tests__/audit/canonicalization-integrity.test.ts +243 -0
  66. package/src/__tests__/audit/graph-revocation-roundtrip.test.ts +280 -0
  67. package/src/__tests__/audit/helpers/crypto-helpers.ts +245 -0
  68. package/src/__tests__/audit/proof-boundary.test.ts +269 -0
  69. package/src/__tests__/audit/statuslist-bitstring-roundtrip.test.ts +135 -0
  70. package/src/__tests__/audit/vc-roundtrip.test.ts +290 -0
  71. package/src/delegation/__tests__/outbound-headers.test.ts +16 -16
  72. package/src/delegation/__tests__/transitive-access.test.ts +1233 -0
  73. package/src/delegation/__tests__/vc-issuer.integration.test.ts +136 -0
  74. package/src/delegation/__tests__/vc-jwt.test.ts +318 -0
  75. package/src/delegation/__tests__/vc-verifier.integration.test.ts +199 -0
  76. package/src/delegation/cascading-revocation.ts +3 -1
  77. package/src/delegation/outbound-headers.ts +16 -16
  78. package/src/delegation/outbound-proof.ts +1 -1
  79. package/src/delegation/statuslist-manager.ts +17 -0
  80. package/src/middleware/with-mcpi.ts +29 -0
  81. package/src/proof/__tests__/verifier.integration.test.ts +181 -0
@@ -0,0 +1,1233 @@
1
+ /**
2
+ * Transitive Access — Scenario Tests
3
+ *
4
+ * These tests map directly to the transitive-access attack scenarios described
5
+ * in Alan Karp's use-case analysis (https://alanhkarp.com/UseCases.pdf),
6
+ * presented to the DIF MCP-I TaskForce on 2026-03-27.
7
+ *
8
+ * ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
9
+ * │ Aperture │ │ Bluth │ │ Cyberdyne │
10
+ * │ (Alice, X,Y)│ │ (Bob) │ │ (Carol) │
11
+ * └──────────────┘ └──────────────┘ └──────────────┘
12
+ *
13
+ * Scenario: Alice (Aperture) delegates [query:x, update:y] to Bob (Bluth).
14
+ * Bob re-delegates a subset to Carol (Cyberdyne). Aperture's server must
15
+ * verify the complete chain, enforce attenuation, and prevent confused-deputy
16
+ * attacks — all without Alice having heard of Carol or Cyberdyne.
17
+ *
18
+ * With ACLs, Aperture sees Carol's requests as coming from Bluth due to
19
+ * federated identity. Capability certificates solve this by carrying the
20
+ * full delegation provenance and enforcing attenuation at every hop.
21
+ *
22
+ * Every test uses real Ed25519 key pairs and cryptographic signatures.
23
+ * No signing operations are mocked.
24
+ *
25
+ * Related Spec: MCP-I §4.4 (Delegation Chains), §11.3 (Scope Escalation),
26
+ * §11.6 (Confused Deputy), §12.3 (Delegation Chain Disclosure)
27
+ */
28
+
29
+ import { describe, it, expect, beforeAll } from 'vitest';
30
+ import {
31
+ createMCPIMiddleware,
32
+ type MCPIDelegationConfig,
33
+ type MCPIMiddleware,
34
+ } from '../../middleware/with-mcpi.js';
35
+ import { NodeCryptoProvider } from '../../__tests__/utils/node-crypto-provider.js';
36
+ import { generateDidKeyFromBase64 } from '../../utils/did-helpers.js';
37
+ import { DelegationCredentialIssuer } from '../vc-issuer.js';
38
+ import type {
39
+ DelegationCredential,
40
+ Proof,
41
+ } from '../../types/protocol.js';
42
+ import { base64urlEncodeFromBytes } from '../../utils/base64.js';
43
+ import { DelegationGraphManager } from '../delegation-graph.js';
44
+ import { CascadingRevocationManager } from '../cascading-revocation.js';
45
+ import { StatusList2021Manager } from '../statuslist-manager.js';
46
+ import { MemoryDelegationGraphStorage } from '../storage/memory-graph-storage.js';
47
+ import { MemoryStatusListStorage } from '../storage/memory-statuslist-storage.js';
48
+ import {
49
+ buildOutboundDelegationHeaders,
50
+ } from '../outbound-headers.js';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Shared identity helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ interface AgentIdentity {
57
+ crypto: NodeCryptoProvider;
58
+ keyPair: { privateKey: string; publicKey: string };
59
+ did: string;
60
+ kid: string;
61
+ issuer: DelegationCredentialIssuer;
62
+ }
63
+
64
+ /**
65
+ * Create an agent identity with a real Ed25519 key pair.
66
+ * Returns a DID, key ID, and a DelegationCredentialIssuer that
67
+ * produces cryptographically signed VCs.
68
+ */
69
+ async function createAgentIdentity(): Promise<AgentIdentity> {
70
+ const crypto = new NodeCryptoProvider();
71
+ const keyPair = await crypto.generateKeyPair();
72
+ const did = generateDidKeyFromBase64(keyPair.publicKey);
73
+ const kid = `${did}#${did.replace('did:key:', '')}`;
74
+
75
+ const signingFn = async (
76
+ canonicalVC: string,
77
+ _issuerDid: string,
78
+ kidArg: string,
79
+ ): Promise<Proof> => {
80
+ const data = new TextEncoder().encode(canonicalVC);
81
+ const sigBytes = await crypto.sign(data, keyPair.privateKey);
82
+ const proofValue = base64urlEncodeFromBytes(sigBytes);
83
+ return {
84
+ type: 'Ed25519Signature2020',
85
+ created: new Date().toISOString(),
86
+ verificationMethod: kidArg,
87
+ proofPurpose: 'assertionMethod',
88
+ proofValue,
89
+ };
90
+ };
91
+
92
+ const issuer = new DelegationCredentialIssuer(
93
+ {
94
+ getDid: () => did,
95
+ getKeyId: () => kid,
96
+ getPrivateKey: () => keyPair.privateKey,
97
+ },
98
+ signingFn,
99
+ );
100
+
101
+ return { crypto, keyPair, did, kid, issuer };
102
+ }
103
+
104
+ /**
105
+ * Issue a signed DelegationCredential.
106
+ */
107
+ async function issueVC(opts: {
108
+ from: AgentIdentity;
109
+ to: AgentIdentity;
110
+ scopes: string[];
111
+ audience?: string | string[];
112
+ parentId?: string;
113
+ }): Promise<DelegationCredential> {
114
+ return opts.from.issuer.createAndIssueDelegation({
115
+ id: `del-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
116
+ issuerDid: opts.from.did,
117
+ subjectDid: opts.to.did,
118
+ parentId: opts.parentId,
119
+ constraints: {
120
+ scopes: opts.scopes,
121
+ ...(opts.audience !== undefined && { audience: opts.audience }),
122
+ notAfter: Math.floor(Date.now() / 1000) + 3600,
123
+ },
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Create a test middleware representing a verifying server (Aperture).
129
+ */
130
+ async function createServer(opts?: {
131
+ delegation?: MCPIDelegationConfig;
132
+ autoSession?: boolean;
133
+ }): Promise<{ middleware: MCPIMiddleware; did: string }> {
134
+ const crypto = new NodeCryptoProvider();
135
+ const keyPair = await crypto.generateKeyPair();
136
+ const did = generateDidKeyFromBase64(keyPair.publicKey);
137
+ const kid = `${did}#${did.replace('did:key:', '')}`;
138
+
139
+ const middleware = createMCPIMiddleware(
140
+ {
141
+ identity: {
142
+ did,
143
+ kid,
144
+ privateKey: keyPair.privateKey,
145
+ publicKey: keyPair.publicKey,
146
+ },
147
+ session: { sessionTtlMinutes: 60 },
148
+ delegation: opts?.delegation,
149
+ autoSession: opts?.autoSession,
150
+ },
151
+ crypto,
152
+ );
153
+
154
+ return { middleware, did };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Three-jurisdiction fixture
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** Shared state across all tests in this suite. */
162
+ let alice: AgentIdentity; // Aperture — original delegator
163
+ let bob: AgentIdentity; // Bluth — intermediary
164
+ let carol: AgentIdentity; // Cyberdyne — downstream
165
+
166
+ beforeAll(async () => {
167
+ alice = await createAgentIdentity();
168
+ bob = await createAgentIdentity();
169
+ carol = await createAgentIdentity();
170
+ });
171
+
172
+ // ===========================================================================
173
+ // 1. Valid transitive delegation
174
+ // ===========================================================================
175
+
176
+ describe('Transitive Access — Karp Use Cases', () => {
177
+ describe('1. Valid transitive delegation chain (Alice → Bob → Carol)', () => {
178
+ it('accepts a two-hop chain where Carol acts with attenuated scope', async () => {
179
+ // Alice delegates [query:x, update:y] to Bob
180
+ const aliceToBob = await issueVC({
181
+ from: alice,
182
+ to: bob,
183
+ scopes: ['query:x', 'update:y'],
184
+ });
185
+
186
+ // Bob re-delegates [query:x] to Carol (attenuated — drops update:y)
187
+ const bobToCarol = await issueVC({
188
+ from: bob,
189
+ to: carol,
190
+ scopes: ['query:x'],
191
+ parentId: aliceToBob.credentialSubject.delegation.id,
192
+ });
193
+
194
+ // Aperture's server is configured to resolve chains
195
+ const { middleware } = await createServer({
196
+ delegation: {
197
+ resolveDelegationChain: async () => [aliceToBob],
198
+ },
199
+ });
200
+
201
+ const handler = middleware.wrapWithDelegation(
202
+ 'query_x',
203
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
204
+ async () => ({
205
+ content: [{ type: 'text', text: 'query result for resource X' }],
206
+ }),
207
+ );
208
+
209
+ const result = await handler({ _mcpi_delegation: bobToCarol });
210
+
211
+ expect(result.isError).toBeUndefined();
212
+ expect(result.content[0].text).toBe('query result for resource X');
213
+ });
214
+ });
215
+
216
+ // =========================================================================
217
+ // 2. Provenance visibility
218
+ // =========================================================================
219
+
220
+ describe('2. Provenance visibility (unlike ACL federation)', () => {
221
+ it('the delegation chain identifies Carol by her own DID, not Bob\'s', async () => {
222
+ // In ACL systems, Carol's identity is hidden behind Bluth's federated
223
+ // identity — Aperture sees the request as coming from Bob.
224
+ // With capability certificates, the chain explicitly names each party.
225
+
226
+ const aliceToBob = await issueVC({
227
+ from: alice,
228
+ to: bob,
229
+ scopes: ['query:x'],
230
+ });
231
+
232
+ const bobToCarol = await issueVC({
233
+ from: bob,
234
+ to: carol,
235
+ scopes: ['query:x'],
236
+ parentId: aliceToBob.credentialSubject.delegation.id,
237
+ });
238
+
239
+ // Inspect the chain — the leaf credential names Carol, not Bob
240
+ const leafSubject = bobToCarol.credentialSubject.delegation.subjectDid;
241
+ const leafIssuer = bobToCarol.credentialSubject.delegation.issuerDid;
242
+
243
+ expect(leafSubject).toBe(carol.did);
244
+ expect(leafIssuer).toBe(bob.did);
245
+
246
+ // The root credential shows Alice→Bob
247
+ const rootSubject = aliceToBob.credentialSubject.delegation.subjectDid;
248
+ const rootIssuer = aliceToBob.credentialSubject.delegation.issuerDid;
249
+
250
+ expect(rootIssuer).toBe(alice.did);
251
+ expect(rootSubject).toBe(bob.did);
252
+
253
+ // The verifying server can reconstruct the full chain:
254
+ // Alice (issuer) → Bob (subject/issuer) → Carol (subject)
255
+ // No identity is hidden or federated.
256
+ expect(leafIssuer).toBe(rootSubject); // Bob links the two credentials
257
+ });
258
+ });
259
+
260
+ // =========================================================================
261
+ // 3. Scope attenuation enforcement
262
+ // =========================================================================
263
+
264
+ describe('3. Scope attenuation across hops', () => {
265
+ it('accepts a child that narrows the parent\'s scopes', async () => {
266
+ const aliceToBob = await issueVC({
267
+ from: alice,
268
+ to: bob,
269
+ scopes: ['query:x', 'update:y'],
270
+ });
271
+
272
+ // Bob narrows to just query:x — valid attenuation
273
+ const bobToCarol = await issueVC({
274
+ from: bob,
275
+ to: carol,
276
+ scopes: ['query:x'],
277
+ parentId: aliceToBob.credentialSubject.delegation.id,
278
+ });
279
+
280
+ const { middleware } = await createServer({
281
+ delegation: {
282
+ resolveDelegationChain: async () => [aliceToBob],
283
+ },
284
+ });
285
+
286
+ const handler = middleware.wrapWithDelegation(
287
+ 'query_x',
288
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
289
+ async () => ({ content: [{ type: 'text', text: 'ok' }] }),
290
+ );
291
+
292
+ const result = await handler({ _mcpi_delegation: bobToCarol });
293
+ expect(result.isError).toBeUndefined();
294
+ });
295
+
296
+ it('rejects a child that widens scopes beyond the parent\'s grant', async () => {
297
+ // Alice gave Bob [query:x, update:y]
298
+ const aliceToBob = await issueVC({
299
+ from: alice,
300
+ to: bob,
301
+ scopes: ['query:x', 'update:y'],
302
+ });
303
+
304
+ // Bob attempts to give Carol [query:x, update:y, admin:z]
305
+ // admin:z was never granted to Bob — this is scope escalation
306
+ const bobToCarol = await issueVC({
307
+ from: bob,
308
+ to: carol,
309
+ scopes: ['query:x', 'update:y', 'admin:z'],
310
+ parentId: aliceToBob.credentialSubject.delegation.id,
311
+ });
312
+
313
+ const { middleware } = await createServer({
314
+ delegation: {
315
+ resolveDelegationChain: async () => [aliceToBob],
316
+ },
317
+ });
318
+
319
+ const handler = middleware.wrapWithDelegation(
320
+ 'query_x',
321
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
322
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
323
+ );
324
+
325
+ const result = await handler({ _mcpi_delegation: bobToCarol });
326
+
327
+ expect(result.isError).toBe(true);
328
+ const parsed = JSON.parse(result.content[0].text);
329
+ expect(parsed.error).toBe('delegation_invalid');
330
+ expect(parsed.reason).toContain('widens scopes');
331
+ expect(parsed.reason).toContain('admin:z');
332
+ });
333
+
334
+ it('rejects a child that passes through equal scopes but adds new ones', async () => {
335
+ // Alice gave Bob [query:x]
336
+ const aliceToBob = await issueVC({
337
+ from: alice,
338
+ to: bob,
339
+ scopes: ['query:x'],
340
+ });
341
+
342
+ // Bob passes through query:x but adds update:y — escalation
343
+ const bobToCarol = await issueVC({
344
+ from: bob,
345
+ to: carol,
346
+ scopes: ['query:x', 'update:y'],
347
+ parentId: aliceToBob.credentialSubject.delegation.id,
348
+ });
349
+
350
+ const { middleware } = await createServer({
351
+ delegation: {
352
+ resolveDelegationChain: async () => [aliceToBob],
353
+ },
354
+ });
355
+
356
+ const handler = middleware.wrapWithDelegation(
357
+ 'query_x',
358
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
359
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
360
+ );
361
+
362
+ const result = await handler({ _mcpi_delegation: bobToCarol });
363
+
364
+ expect(result.isError).toBe(true);
365
+ const parsed = JSON.parse(result.content[0].text);
366
+ expect(parsed.reason).toContain('widens scopes');
367
+ });
368
+ });
369
+
370
+ // =========================================================================
371
+ // 4. Confused deputy prevention
372
+ // =========================================================================
373
+
374
+ describe('4. Confused deputy prevention', () => {
375
+ it('rejects a delegation presented to the wrong server (audience mismatch)', async () => {
376
+ // Karp scenario: Alice delegates to Bob for use at Aperture's server,
377
+ // but Carol presents it to Cyberdyne's server instead.
378
+ // The audience constraint binds the credential to a specific server DID.
379
+
380
+ const { did: apertureDid } = await createServer();
381
+ const { middleware: cyberdyneServer } = await createServer();
382
+
383
+ // Alice delegates to Bob, audience-bound to Aperture
384
+ const aliceToBob = await issueVC({
385
+ from: alice,
386
+ to: bob,
387
+ scopes: ['query:x'],
388
+ audience: apertureDid,
389
+ });
390
+
391
+ // Bob re-delegates to Carol, still audience-bound to Aperture
392
+ const bobToCarol = await issueVC({
393
+ from: bob,
394
+ to: carol,
395
+ scopes: ['query:x'],
396
+ parentId: aliceToBob.credentialSubject.delegation.id,
397
+ audience: apertureDid,
398
+ });
399
+
400
+ // Cyberdyne's server has a chain resolver but its DID differs from the
401
+ // audience in the credentials. The audience check fires during chain
402
+ // validation when each credential is individually verified.
403
+ const { middleware: cyberdyneMiddleware } = await createServer({
404
+ delegation: {
405
+ resolveDelegationChain: async () => [aliceToBob],
406
+ },
407
+ });
408
+
409
+ const handler = cyberdyneMiddleware.wrapWithDelegation(
410
+ 'query_x',
411
+ { scopeId: 'query:x', consentUrl: 'https://cyberdyne.example/consent' },
412
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
413
+ );
414
+
415
+ const result = await handler({ _mcpi_delegation: bobToCarol });
416
+
417
+ expect(result.isError).toBe(true);
418
+ const parsed = JSON.parse(result.content[0].text);
419
+ expect(parsed.error).toBe('delegation_invalid');
420
+ expect(parsed.reason).toContain('audience');
421
+ });
422
+
423
+ it('rejects when Carol invokes a tool requiring a scope she was not delegated', async () => {
424
+ // Karp scenario: Alice delegated [query:x] to Bob.
425
+ // Bob delegated [query:x] to Carol. Carol tries to use update:y.
426
+ // Even if the chain is perfectly valid, the scope check at the tool
427
+ // level prevents the confused deputy from exceeding her authority.
428
+
429
+ const aliceToBob = await issueVC({
430
+ from: alice,
431
+ to: bob,
432
+ scopes: ['query:x'],
433
+ });
434
+
435
+ const bobToCarol = await issueVC({
436
+ from: bob,
437
+ to: carol,
438
+ scopes: ['query:x'],
439
+ parentId: aliceToBob.credentialSubject.delegation.id,
440
+ });
441
+
442
+ const { middleware } = await createServer({
443
+ delegation: {
444
+ resolveDelegationChain: async () => [aliceToBob],
445
+ },
446
+ });
447
+
448
+ // Tool requires update:y — Carol only has query:x
449
+ const handler = middleware.wrapWithDelegation(
450
+ 'update_y',
451
+ { scopeId: 'update:y', consentUrl: 'https://aperture.example/consent' },
452
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
453
+ );
454
+
455
+ const result = await handler({ _mcpi_delegation: bobToCarol });
456
+
457
+ expect(result.isError).toBe(true);
458
+ const parsed = JSON.parse(result.content[0].text);
459
+ expect(parsed.error).toBe('insufficient_scope');
460
+ });
461
+
462
+ it('prevents "Backup X to Z" — credential bound to Aperture cannot act at Cyberdyne', async () => {
463
+ // Karp's "How Bad It Can Get" scenario:
464
+ // Alice says "Backup X and put the result in Z" where Z is in Cyberdyne.
465
+ // In ACL world, Bob's backup service (running as Bluth) has Carol's
466
+ // permissions via federation and can clobber Carol's resource Z.
467
+ //
468
+ // With capabilities: Alice's delegation is bound to Aperture's server.
469
+ // Bob cannot forward it to Cyberdyne because the audience doesn't match.
470
+
471
+ const { did: apertureDid } = await createServer();
472
+ const { middleware: cyberdyneServer } = await createServer();
473
+
474
+ const aliceToBob = await issueVC({
475
+ from: alice,
476
+ to: bob,
477
+ scopes: ['query:x', 'update:y'],
478
+ audience: apertureDid,
479
+ });
480
+
481
+ // Bob tries to forward Alice's credential to Cyberdyne
482
+ const handler = cyberdyneServer.wrapWithDelegation(
483
+ 'update_z',
484
+ { scopeId: 'update:y', consentUrl: 'https://cyberdyne.example/consent' },
485
+ async () => ({
486
+ content: [{ type: 'text', text: 'clobbered Z — should not reach' }],
487
+ }),
488
+ );
489
+
490
+ const result = await handler({ _mcpi_delegation: aliceToBob });
491
+
492
+ expect(result.isError).toBe(true);
493
+ const parsed = JSON.parse(result.content[0].text);
494
+ expect(parsed.error).toBe('delegation_invalid');
495
+ expect(parsed.reason).toContain('audience');
496
+ });
497
+
498
+ it('prevents "Backup Z to X" — Carol cannot query Z through Bob\'s delegation', async () => {
499
+ // Karp's reverse confused-deputy scenario:
500
+ // Alice says "Backup Z to X", hoping to read Carol's resource Z
501
+ // via Bob's backup service. With ACLs, Bob (federated as Bluth) has
502
+ // query access to Carol's resources.
503
+ //
504
+ // With capabilities: Alice only granted query:x and update:y.
505
+ // No scope covers Z, so Carol's server rejects the request.
506
+
507
+ const aliceToBob = await issueVC({
508
+ from: alice,
509
+ to: bob,
510
+ scopes: ['query:x', 'update:y'],
511
+ });
512
+
513
+ const { middleware: cyberdyneServer } = await createServer();
514
+
515
+ // Try to query Z at Cyberdyne using Alice's credential
516
+ const handler = cyberdyneServer.wrapWithDelegation(
517
+ 'query_z',
518
+ { scopeId: 'query:z', consentUrl: 'https://cyberdyne.example/consent' },
519
+ async () => ({
520
+ content: [{ type: 'text', text: 'read Z — should not reach' }],
521
+ }),
522
+ );
523
+
524
+ const result = await handler({ _mcpi_delegation: aliceToBob });
525
+
526
+ expect(result.isError).toBe(true);
527
+ const parsed = JSON.parse(result.content[0].text);
528
+ // Either audience mismatch (if audience was set) or scope mismatch
529
+ // In this case, query:z is not in the delegation's scopes
530
+ expect(parsed.error).toBe('insufficient_scope');
531
+ });
532
+ });
533
+
534
+ // =========================================================================
535
+ // 5. Cascading revocation
536
+ // =========================================================================
537
+
538
+ describe('5. Cascading revocation (Alice revokes Bob → Carol is invalidated)', () => {
539
+ it('revoking Alice→Bob automatically revokes Bob→Carol and all descendants', async () => {
540
+ // Karp notes that with capabilities, Bob can "forget" credentials he
541
+ // holds after delegating to Carol. But the dual requirement is that
542
+ // Alice can revoke the entire chain if Bluth (or Cyberdyne) is compromised.
543
+
544
+ const graphStorage = new MemoryDelegationGraphStorage();
545
+ const statusListStorage = new MemoryStatusListStorage();
546
+
547
+ const graph = new DelegationGraphManager(graphStorage);
548
+ const crypto = new NodeCryptoProvider();
549
+ const keyPair = await crypto.generateKeyPair();
550
+
551
+ const mockSigningFn = async (
552
+ canonicalVC: string,
553
+ _did: string,
554
+ _kid: string,
555
+ ): Promise<Proof> => {
556
+ const data = new TextEncoder().encode(canonicalVC);
557
+ const sigBytes = await crypto.sign(data, keyPair.privateKey);
558
+ return {
559
+ type: 'Ed25519Signature2020',
560
+ created: new Date().toISOString(),
561
+ verificationMethod: _kid,
562
+ proofPurpose: 'assertionMethod',
563
+ proofValue: base64urlEncodeFromBytes(sigBytes),
564
+ };
565
+ };
566
+
567
+ const compressor = { compress: async (data: Uint8Array) => data };
568
+ const decompressor = { decompress: async (data: Uint8Array) => data };
569
+
570
+ const statusListManager = new StatusList2021Manager(
571
+ statusListStorage,
572
+ {
573
+ getDid: () => alice.did,
574
+ getKeyId: () => alice.kid,
575
+ },
576
+ mockSigningFn,
577
+ compressor,
578
+ decompressor,
579
+ );
580
+
581
+ const revocationManager = new CascadingRevocationManager(
582
+ graph,
583
+ statusListManager,
584
+ );
585
+
586
+ // Register Alice→Bob in the graph
587
+ const aliceEntry = await statusListManager.allocateStatusEntry('revocation');
588
+ await graph.registerDelegation({
589
+ id: 'del-alice-bob',
590
+ parentId: null,
591
+ issuerDid: alice.did,
592
+ subjectDid: bob.did,
593
+ credentialStatusId: aliceEntry.id,
594
+ });
595
+
596
+ // Register Bob→Carol in the graph
597
+ const bobEntry = await statusListManager.allocateStatusEntry('revocation');
598
+ await graph.registerDelegation({
599
+ id: 'del-bob-carol',
600
+ parentId: 'del-alice-bob',
601
+ issuerDid: bob.did,
602
+ subjectDid: carol.did,
603
+ credentialStatusId: bobEntry.id,
604
+ });
605
+
606
+ // Before revocation: both are valid
607
+ const preCheck = await revocationManager.isRevoked('del-bob-carol');
608
+ expect(preCheck.revoked).toBe(false);
609
+
610
+ // Alice revokes her delegation to Bob
611
+ const events = await revocationManager.revokeDelegation('del-alice-bob');
612
+
613
+ // Cascading: both Alice→Bob and Bob→Carol are revoked
614
+ expect(events).toHaveLength(2);
615
+ expect(events[0].delegationId).toBe('del-alice-bob');
616
+ expect(events[0].isRoot).toBe(true);
617
+ expect(events[1].delegationId).toBe('del-bob-carol');
618
+ expect(events[1].isRoot).toBe(false);
619
+
620
+ // Verify Bob→Carol is now revoked.
621
+ // Cascading revocation atomically sets the status bit on every
622
+ // descendant, so isRevoked reports it as "Directly revoked" —
623
+ // the bit for del-bob-carol was explicitly flipped by the cascade.
624
+ const postCheck = await revocationManager.isRevoked('del-bob-carol');
625
+ expect(postCheck.revoked).toBe(true);
626
+
627
+ // Full chain validation also fails
628
+ const chainCheck = await revocationManager.validateDelegation('del-bob-carol');
629
+ expect(chainCheck.valid).toBe(false);
630
+ expect(chainCheck.reason).toContain('revoked');
631
+ });
632
+
633
+ it('validates delegation status when checking a leaf credential', async () => {
634
+ // The revocation manager checks the entire chain from root to leaf.
635
+ // If any ancestor is revoked, the leaf is invalid.
636
+
637
+ const graphStorage = new MemoryDelegationGraphStorage();
638
+ const statusListStorage = new MemoryStatusListStorage();
639
+ const graph = new DelegationGraphManager(graphStorage);
640
+ const crypto = new NodeCryptoProvider();
641
+ const keyPair = await crypto.generateKeyPair();
642
+
643
+ const mockSigningFn = async (
644
+ canonicalVC: string,
645
+ _did: string,
646
+ _kid: string,
647
+ ): Promise<Proof> => {
648
+ const data = new TextEncoder().encode(canonicalVC);
649
+ const sigBytes = await crypto.sign(data, keyPair.privateKey);
650
+ return {
651
+ type: 'Ed25519Signature2020',
652
+ created: new Date().toISOString(),
653
+ verificationMethod: _kid,
654
+ proofPurpose: 'assertionMethod',
655
+ proofValue: base64urlEncodeFromBytes(sigBytes),
656
+ };
657
+ };
658
+
659
+ const compressor = { compress: async (data: Uint8Array) => data };
660
+ const decompressor = { decompress: async (data: Uint8Array) => data };
661
+
662
+ const statusListManager = new StatusList2021Manager(
663
+ statusListStorage,
664
+ { getDid: () => alice.did, getKeyId: () => alice.kid },
665
+ mockSigningFn,
666
+ compressor,
667
+ decompressor,
668
+ );
669
+
670
+ const revocationManager = new CascadingRevocationManager(
671
+ graph,
672
+ statusListManager,
673
+ );
674
+
675
+ // Three-hop chain: Alice → Bob → Carol → Dave
676
+ const dave = await createAgentIdentity();
677
+
678
+ const entry0 = await statusListManager.allocateStatusEntry('revocation');
679
+ await graph.registerDelegation({
680
+ id: 'del-a-b',
681
+ parentId: null,
682
+ issuerDid: alice.did,
683
+ subjectDid: bob.did,
684
+ credentialStatusId: entry0.id,
685
+ });
686
+
687
+ const entry1 = await statusListManager.allocateStatusEntry('revocation');
688
+ await graph.registerDelegation({
689
+ id: 'del-b-c',
690
+ parentId: 'del-a-b',
691
+ issuerDid: bob.did,
692
+ subjectDid: carol.did,
693
+ credentialStatusId: entry1.id,
694
+ });
695
+
696
+ const entry2 = await statusListManager.allocateStatusEntry('revocation');
697
+ await graph.registerDelegation({
698
+ id: 'del-c-d',
699
+ parentId: 'del-b-c',
700
+ issuerDid: carol.did,
701
+ subjectDid: dave.did,
702
+ credentialStatusId: entry2.id,
703
+ });
704
+
705
+ // Revoke the middle link (Bob→Carol)
706
+ await revocationManager.revokeDelegation('del-b-c');
707
+
708
+ // Dave's leaf is invalid because ancestor del-b-c is revoked
709
+ const daveCheck = await revocationManager.validateDelegation('del-c-d');
710
+ expect(daveCheck.valid).toBe(false);
711
+ expect(daveCheck.reason).toContain('revoked');
712
+
713
+ // Alice→Bob is still valid (parent of the revoked node, not a descendant)
714
+ const aliceCheck = await revocationManager.validateDelegation('del-a-b');
715
+ expect(aliceCheck.valid).toBe(true);
716
+ });
717
+ });
718
+
719
+ // =========================================================================
720
+ // 6. Chain integrity enforcement
721
+ // =========================================================================
722
+
723
+ describe('6. Chain integrity enforcement', () => {
724
+ it('rejects a chain with broken issuer/subject continuity', async () => {
725
+ // If Carol forges a credential claiming Bob delegated to her, but the
726
+ // chain's issuer/subject links don't match, validation must fail.
727
+ // This is the cryptographic enforcement of Karp's "capability
728
+ // certificates carry provenance" property.
729
+
730
+ // Alice delegates to Bob
731
+ const aliceToBob = await issueVC({
732
+ from: alice,
733
+ to: bob,
734
+ scopes: ['query:x'],
735
+ });
736
+
737
+ // Eve (a fourth party) creates a credential claiming to be from Bob,
738
+ // but signed with Eve's key — not Bob's
739
+ const eve = await createAgentIdentity();
740
+ const eveToCarol = await issueVC({
741
+ from: eve, // Eve signs, but claims parentId from aliceToBob
742
+ to: carol,
743
+ scopes: ['query:x'],
744
+ parentId: aliceToBob.credentialSubject.delegation.id,
745
+ });
746
+
747
+ const { middleware } = await createServer({
748
+ delegation: {
749
+ resolveDelegationChain: async () => [aliceToBob],
750
+ },
751
+ });
752
+
753
+ const handler = middleware.wrapWithDelegation(
754
+ 'query_x',
755
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
756
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
757
+ );
758
+
759
+ const result = await handler({ _mcpi_delegation: eveToCarol });
760
+
761
+ expect(result.isError).toBe(true);
762
+ const parsed = JSON.parse(result.content[0].text);
763
+ expect(parsed.error).toBe('delegation_invalid');
764
+ // Eve's DID ≠ Bob's DID (the parent's subject)
765
+ expect(parsed.reason).toContain('issued by');
766
+ expect(parsed.reason).toContain('parent subject');
767
+ });
768
+
769
+ it('rejects a chain where the leaf references a non-existent parent', async () => {
770
+ // Bob creates a delegation to Carol referencing a parent that doesn't
771
+ // exist. The chain resolver returns an empty array.
772
+
773
+ const bobToCarol = await issueVC({
774
+ from: bob,
775
+ to: carol,
776
+ scopes: ['query:x'],
777
+ parentId: 'non-existent-parent-id',
778
+ });
779
+
780
+ const { middleware } = await createServer({
781
+ delegation: {
782
+ resolveDelegationChain: async () => [],
783
+ },
784
+ });
785
+
786
+ const handler = middleware.wrapWithDelegation(
787
+ 'query_x',
788
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
789
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
790
+ );
791
+
792
+ const result = await handler({ _mcpi_delegation: bobToCarol });
793
+
794
+ expect(result.isError).toBe(true);
795
+ const parsed = JSON.parse(result.content[0].text);
796
+ expect(parsed.error).toBe('delegation_invalid');
797
+ expect(parsed.reason).toContain('empty');
798
+ });
799
+
800
+ it('detects circular references in the delegation chain', async () => {
801
+ // A malicious actor constructs a chain that loops. The circular
802
+ // reference detector must catch this.
803
+
804
+ const graph = new DelegationGraphManager(new MemoryDelegationGraphStorage());
805
+
806
+ // Create a valid chain: A→B→C
807
+ await graph.registerDelegation({
808
+ id: 'del-1',
809
+ parentId: null,
810
+ issuerDid: alice.did,
811
+ subjectDid: bob.did,
812
+ });
813
+
814
+ await graph.registerDelegation({
815
+ id: 'del-2',
816
+ parentId: 'del-1',
817
+ issuerDid: bob.did,
818
+ subjectDid: carol.did,
819
+ });
820
+
821
+ // Verify valid chain works
822
+ const validResult = await graph.validateChain('del-2');
823
+ expect(validResult.valid).toBe(true);
824
+
825
+ // Verify chain depth
826
+ const depth = await graph.getDepth('del-2');
827
+ expect(depth).toBe(1); // root=0, child=1
828
+
829
+ // Verify ancestry
830
+ const isAncestor = await graph.isAncestor('del-1', 'del-2');
831
+ expect(isAncestor).toBe(true);
832
+ });
833
+ });
834
+
835
+ // =========================================================================
836
+ // 7. Three-hop chain validation
837
+ // =========================================================================
838
+
839
+ describe('7. Three-hop chain (Alice → Bob → Carol → Dave)', () => {
840
+ it('validates a three-hop chain with progressive attenuation', async () => {
841
+ // Extended transitive scenario: four parties, three delegations,
842
+ // each narrowing the scope further.
843
+
844
+ const dave = await createAgentIdentity();
845
+
846
+ // Alice → Bob: [query:x, update:y, delete:y]
847
+ const aliceToBob = await issueVC({
848
+ from: alice,
849
+ to: bob,
850
+ scopes: ['query:x', 'update:y', 'delete:y'],
851
+ });
852
+
853
+ // Bob → Carol: [query:x, update:y] (drops delete:y)
854
+ const bobToCarol = await issueVC({
855
+ from: bob,
856
+ to: carol,
857
+ scopes: ['query:x', 'update:y'],
858
+ parentId: aliceToBob.credentialSubject.delegation.id,
859
+ });
860
+
861
+ // Carol → Dave: [query:x] (drops update:y)
862
+ const carolToDave = await issueVC({
863
+ from: carol,
864
+ to: dave,
865
+ scopes: ['query:x'],
866
+ parentId: bobToCarol.credentialSubject.delegation.id,
867
+ });
868
+
869
+ const { middleware } = await createServer({
870
+ delegation: {
871
+ resolveDelegationChain: async () => [aliceToBob, bobToCarol],
872
+ },
873
+ });
874
+
875
+ const handler = middleware.wrapWithDelegation(
876
+ 'query_x',
877
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
878
+ async () => ({ content: [{ type: 'text', text: 'ok from Dave' }] }),
879
+ );
880
+
881
+ const result = await handler({ _mcpi_delegation: carolToDave });
882
+ expect(result.isError).toBeUndefined();
883
+ expect(result.content[0].text).toBe('ok from Dave');
884
+ });
885
+
886
+ it('rejects if any link in a three-hop chain widens scope', async () => {
887
+ const dave = await createAgentIdentity();
888
+
889
+ // Alice → Bob: [query:x]
890
+ const aliceToBob = await issueVC({
891
+ from: alice,
892
+ to: bob,
893
+ scopes: ['query:x'],
894
+ });
895
+
896
+ // Bob → Carol: [query:x] — valid
897
+ const bobToCarol = await issueVC({
898
+ from: bob,
899
+ to: carol,
900
+ scopes: ['query:x'],
901
+ parentId: aliceToBob.credentialSubject.delegation.id,
902
+ });
903
+
904
+ // Carol → Dave: [query:x, update:y] — INVALID, widens scope
905
+ const carolToDave = await issueVC({
906
+ from: carol,
907
+ to: dave,
908
+ scopes: ['query:x', 'update:y'],
909
+ parentId: bobToCarol.credentialSubject.delegation.id,
910
+ });
911
+
912
+ const { middleware } = await createServer({
913
+ delegation: {
914
+ resolveDelegationChain: async () => [aliceToBob, bobToCarol],
915
+ },
916
+ });
917
+
918
+ const handler = middleware.wrapWithDelegation(
919
+ 'query_x',
920
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
921
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
922
+ );
923
+
924
+ const result = await handler({ _mcpi_delegation: carolToDave });
925
+
926
+ expect(result.isError).toBe(true);
927
+ const parsed = JSON.parse(result.content[0].text);
928
+ expect(parsed.error).toBe('delegation_invalid');
929
+ expect(parsed.reason).toContain('widens scopes');
930
+ expect(parsed.reason).toContain('update:y');
931
+ });
932
+ });
933
+
934
+ // =========================================================================
935
+ // 8. Outbound delegation propagation
936
+ // =========================================================================
937
+
938
+ describe('8. Outbound delegation propagation (provenance across service boundaries)', () => {
939
+ it('downstream service receives original agent DID and chain context in headers', async () => {
940
+ // Karp's key insight: the full delegation chain must be visible at
941
+ // every hop. MCP-I propagates this via outbound headers so downstream
942
+ // services can independently verify the chain.
943
+
944
+ const aliceToBob = await issueVC({
945
+ from: alice,
946
+ to: bob,
947
+ scopes: ['query:x'],
948
+ });
949
+
950
+ const delegation = aliceToBob.credentialSubject.delegation;
951
+
952
+ const headers = await buildOutboundDelegationHeaders({
953
+ session: {
954
+ sessionId: 'mcpi_test-session',
955
+ audience: 'aperture.example.com',
956
+ nonce: 'test-nonce',
957
+ timestamp: Math.floor(Date.now() / 1000),
958
+ createdAt: Date.now(),
959
+ lastActivity: Date.now(),
960
+ ttlMinutes: 60,
961
+ agentDid: alice.did,
962
+ identityState: 'authenticated',
963
+ },
964
+ delegation: {
965
+ id: delegation.id,
966
+ issuerDid: delegation.issuerDid,
967
+ subjectDid: delegation.subjectDid,
968
+ vcId: aliceToBob.id!,
969
+ constraints: delegation.constraints,
970
+ signature: aliceToBob.proof?.proofValue ?? '',
971
+ status: 'active',
972
+ },
973
+ serverIdentity: {
974
+ did: bob.did,
975
+ kid: bob.kid,
976
+ privateKey: bob.keyPair.privateKey,
977
+ },
978
+ targetUrl: 'https://cyberdyne.example.com/api/backup',
979
+ });
980
+
981
+ // Headers expose the full provenance
982
+ expect(headers['KYA-Agent-DID']).toBe(alice.did);
983
+ expect(headers['KYA-Delegation-Chain']).toBe(aliceToBob.id);
984
+ expect(headers['KYA-Session-Id']).toBe('mcpi_test-session');
985
+ // The proof is a signed JWT that downstream can verify
986
+ expect(headers['KYA-Delegation-Proof']).toMatch(
987
+ /^eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/,
988
+ );
989
+ });
990
+ });
991
+
992
+ // =========================================================================
993
+ // 9. Credential theft and replay prevention
994
+ // =========================================================================
995
+
996
+ describe('9. Credential theft mitigation', () => {
997
+ it('a stolen credential cannot be used at a different server (audience binding)', async () => {
998
+ // MCP-I Spec §11.8: If a DelegationCredential is intercepted, the
999
+ // audience constraint limits where it can be replayed.
1000
+
1001
+ const { did: legitimateServerDid } = await createServer();
1002
+ const { middleware: attackerServer } = await createServer();
1003
+
1004
+ // Legitimate delegation bound to specific server
1005
+ const vc = await issueVC({
1006
+ from: alice,
1007
+ to: bob,
1008
+ scopes: ['query:x'],
1009
+ audience: legitimateServerDid,
1010
+ });
1011
+
1012
+ // Attacker intercepts VC and tries to use it at their server
1013
+ const handler = attackerServer.wrapWithDelegation(
1014
+ 'query_x',
1015
+ { scopeId: 'query:x', consentUrl: 'https://attacker.example/consent' },
1016
+ async () => ({ content: [{ type: 'text', text: 'stolen data' }] }),
1017
+ );
1018
+
1019
+ const result = await handler({ _mcpi_delegation: vc });
1020
+
1021
+ expect(result.isError).toBe(true);
1022
+ const parsed = JSON.parse(result.content[0].text);
1023
+ expect(parsed.error).toBe('delegation_invalid');
1024
+ expect(parsed.reason).toContain('audience');
1025
+ });
1026
+ });
1027
+
1028
+ // =========================================================================
1029
+ // 10. No delegation — fail-closed behavior
1030
+ // =========================================================================
1031
+
1032
+ describe('10. Fail-closed: no delegation means no access', () => {
1033
+ it('returns needs_authorization when no delegation is presented', async () => {
1034
+ // Karp's premise: with ACLs, the absence of an explicit deny means
1035
+ // implicit allow in many systems. With capabilities, the absence of
1036
+ // a capability means no access — fail-closed by default.
1037
+
1038
+ const { middleware } = await createServer();
1039
+
1040
+ const handler = middleware.wrapWithDelegation(
1041
+ 'sensitive_op',
1042
+ {
1043
+ scopeId: 'admin:danger',
1044
+ consentUrl: 'https://aperture.example/consent',
1045
+ },
1046
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
1047
+ );
1048
+
1049
+ // Call without any delegation credential
1050
+ const result = await handler({ some_arg: 'value' });
1051
+
1052
+ const parsed = JSON.parse(result.content[0].text);
1053
+ expect(parsed.error).toBe('needs_authorization');
1054
+ expect(parsed.scopes).toContain('admin:danger');
1055
+ expect(parsed.authorizationUrl).toBe(
1056
+ 'https://aperture.example/consent',
1057
+ );
1058
+ });
1059
+ });
1060
+
1061
+ // =========================================================================
1062
+ // 11. requireAudienceOnRedelegation enforcement
1063
+ // =========================================================================
1064
+
1065
+ describe('11. requireAudienceOnRedelegation — strict confused-deputy prevention', () => {
1066
+ it('rejects a re-delegation without audience when enforcement is enabled', async () => {
1067
+ // Core fix for transitive access: re-delegations MUST carry an
1068
+ // audience constraint so they cannot be forwarded to unintended servers.
1069
+
1070
+ const { middleware, did: serverDid } = await createServer({
1071
+ delegation: {
1072
+ requireAudienceOnRedelegation: true,
1073
+ resolveDelegationChain: async () => [aliceToBob],
1074
+ },
1075
+ });
1076
+
1077
+ // Alice delegates to Bob WITH audience (root — binds to this server)
1078
+ const aliceToBob = await issueVC({
1079
+ from: alice,
1080
+ to: bob,
1081
+ scopes: ['query:x'],
1082
+ audience: serverDid,
1083
+ });
1084
+
1085
+ // Bob re-delegates to Carol WITHOUT audience — this is the hazard
1086
+ const bobToCarol = await issueVC({
1087
+ from: bob,
1088
+ to: carol,
1089
+ scopes: ['query:x'],
1090
+ parentId: aliceToBob.credentialSubject.delegation.id,
1091
+ // no audience
1092
+ });
1093
+
1094
+ const handler = middleware.wrapWithDelegation(
1095
+ 'query_x',
1096
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
1097
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
1098
+ );
1099
+
1100
+ const result = await handler({ _mcpi_delegation: bobToCarol });
1101
+
1102
+ expect(result.isError).toBe(true);
1103
+ const parsed = JSON.parse(result.content[0].text);
1104
+ expect(parsed.error).toBe('delegation_invalid');
1105
+ expect(parsed.reason).toContain('re-delegation');
1106
+ expect(parsed.reason).toContain('audience');
1107
+ });
1108
+
1109
+ it('accepts a re-delegation WITH audience when enforcement is enabled', async () => {
1110
+ const { middleware, did: serverDid } = await createServer({
1111
+ delegation: {
1112
+ requireAudienceOnRedelegation: true,
1113
+ resolveDelegationChain: async (leaf) => {
1114
+ // Dynamically return the correct parent
1115
+ return [aliceToBobVC];
1116
+ },
1117
+ },
1118
+ });
1119
+
1120
+ // Both delegations carry audience — compliant
1121
+ const aliceToBobVC = await issueVC({
1122
+ from: alice,
1123
+ to: bob,
1124
+ scopes: ['query:x'],
1125
+ audience: serverDid,
1126
+ });
1127
+
1128
+ const bobToCarol = await issueVC({
1129
+ from: bob,
1130
+ to: carol,
1131
+ scopes: ['query:x'],
1132
+ parentId: aliceToBobVC.credentialSubject.delegation.id,
1133
+ audience: serverDid,
1134
+ });
1135
+
1136
+ const handler = middleware.wrapWithDelegation(
1137
+ 'query_x',
1138
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
1139
+ async () => ({ content: [{ type: 'text', text: 'ok' }] }),
1140
+ );
1141
+
1142
+ const result = await handler({ _mcpi_delegation: bobToCarol });
1143
+ expect(result.isError).toBeUndefined();
1144
+ expect(result.content[0].text).toBe('ok');
1145
+ });
1146
+
1147
+ it('allows re-delegations without audience when enforcement is disabled (default)', async () => {
1148
+ // Backward compatibility: the flag defaults to false, so existing
1149
+ // integrations that omit audience on re-delegations continue to work.
1150
+
1151
+ const aliceToBob = await issueVC({
1152
+ from: alice,
1153
+ to: bob,
1154
+ scopes: ['query:x'],
1155
+ });
1156
+
1157
+ const bobToCarol = await issueVC({
1158
+ from: bob,
1159
+ to: carol,
1160
+ scopes: ['query:x'],
1161
+ parentId: aliceToBob.credentialSubject.delegation.id,
1162
+ // no audience — allowed when flag is off
1163
+ });
1164
+
1165
+ const { middleware } = await createServer({
1166
+ delegation: {
1167
+ // requireAudienceOnRedelegation not set (defaults to false)
1168
+ resolveDelegationChain: async () => [aliceToBob],
1169
+ },
1170
+ });
1171
+
1172
+ const handler = middleware.wrapWithDelegation(
1173
+ 'query_x',
1174
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
1175
+ async () => ({ content: [{ type: 'text', text: 'ok' }] }),
1176
+ );
1177
+
1178
+ const result = await handler({ _mcpi_delegation: bobToCarol });
1179
+ expect(result.isError).toBeUndefined();
1180
+ expect(result.content[0].text).toBe('ok');
1181
+ });
1182
+
1183
+ it('rejects at any hop in a three-hop chain missing audience', async () => {
1184
+ const dave = await createAgentIdentity();
1185
+
1186
+ const { middleware, did: serverDid } = await createServer({
1187
+ delegation: {
1188
+ requireAudienceOnRedelegation: true,
1189
+ resolveDelegationChain: async () => [aliceToBobVC, bobToCarolVC],
1190
+ },
1191
+ });
1192
+
1193
+ const aliceToBobVC = await issueVC({
1194
+ from: alice,
1195
+ to: bob,
1196
+ scopes: ['query:x'],
1197
+ audience: serverDid,
1198
+ });
1199
+
1200
+ // Bob → Carol has audience — OK
1201
+ const bobToCarolVC = await issueVC({
1202
+ from: bob,
1203
+ to: carol,
1204
+ scopes: ['query:x'],
1205
+ parentId: aliceToBobVC.credentialSubject.delegation.id,
1206
+ audience: serverDid,
1207
+ });
1208
+
1209
+ // Carol → Dave is missing audience — should fail
1210
+ const carolToDave = await issueVC({
1211
+ from: carol,
1212
+ to: dave,
1213
+ scopes: ['query:x'],
1214
+ parentId: bobToCarolVC.credentialSubject.delegation.id,
1215
+ // no audience
1216
+ });
1217
+
1218
+ const handler = middleware.wrapWithDelegation(
1219
+ 'query_x',
1220
+ { scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
1221
+ async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
1222
+ );
1223
+
1224
+ const result = await handler({ _mcpi_delegation: carolToDave });
1225
+
1226
+ expect(result.isError).toBe(true);
1227
+ const parsed = JSON.parse(result.content[0].text);
1228
+ expect(parsed.error).toBe('delegation_invalid');
1229
+ expect(parsed.reason).toContain('re-delegation');
1230
+ expect(parsed.reason).toContain('audience');
1231
+ });
1232
+ });
1233
+ });