@learncard/lca-api-service 1.5.1 → 1.5.3

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 (41) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +32 -0
  3. package/config/config.dev.json +1 -1
  4. package/dist/app.d.ts +239 -1
  5. package/dist/index.js +2 -2
  6. package/dist/models/EscrowHold.d.ts +30 -2
  7. package/dist/models/UserKey.d.ts +89 -5
  8. package/dist/publish-metafile.json +1 -1
  9. package/dist/routes/ai.test.d.ts +1 -0
  10. package/dist/routes/escrow.d.ts +237 -0
  11. package/dist/routes/index.d.ts +4 -4
  12. package/dist/routes/keys.d.ts +2 -1
  13. package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
  14. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  15. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  16. package/dist/services/escrow-enclave/types.d.ts +55 -3
  17. package/esbuildPlugins.cjs +9 -6
  18. package/package.json +10 -10
  19. package/src/config/environment.ts +1 -1
  20. package/src/config/escrowDeployment.test.ts +8 -0
  21. package/src/jobs/escrowBlobRewrap.ts +45 -2
  22. package/src/jobs/escrowHoldReminders.ts +50 -1
  23. package/src/models/EscrowHold.ts +128 -10
  24. package/src/models/UserKey.ts +59 -8
  25. package/src/routes/ai.test.ts +103 -0
  26. package/src/routes/ai.ts +2 -1
  27. package/src/routes/escrow.ts +386 -53
  28. package/src/routes/index.ts +1 -1
  29. package/src/routes/keys.ts +5 -0
  30. package/src/services/escrow-enclave/README.md +31 -0
  31. package/src/services/escrow-enclave/authorityContract.ts +126 -0
  32. package/src/services/escrow-enclave/cancellation.ts +2 -2
  33. package/src/services/escrow-enclave/logFailure.test.ts +64 -0
  34. package/src/services/escrow-enclave/logFailure.ts +29 -0
  35. package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
  36. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
  37. package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
  38. package/src/services/escrow-enclave/types.ts +77 -5
  39. package/test/jobs/escrowHoldReminders.spec.ts +195 -3
  40. package/test/keys-escrow.spec.ts +587 -11
  41. package/test/models/escrow.spec.ts +13 -0
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ encodeEscrowEnrollmentIntent,
4
+ ESCROW_ALGORITHM,
5
+ ESCROW_ENVELOPE_VERSION,
6
+ } from '@learncard/sss-key-manager';
7
+
8
+ export const safeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
9
+ const label = z
10
+ .string()
11
+ .min(1)
12
+ .max(128)
13
+ .regex(/^[A-Za-z0-9_-]+$/);
14
+ export const AuthorityEnvelopeValidator = z
15
+ .object({
16
+ version: z.literal(ESCROW_ENVELOPE_VERSION),
17
+ algorithm: z.literal(ESCROW_ALGORITHM),
18
+ keyId: z.string().min(1).max(128),
19
+ ephemeralPublicKey: z.string().max(256),
20
+ salt: z.string().max(128),
21
+ iv: z.string().max(64),
22
+ ciphertext: z.string().max(16_384),
23
+ })
24
+ .strict();
25
+ export const AuthorityRecordValidator = z
26
+ .object({
27
+ version: z.literal(1),
28
+ kind: z.enum(['proposal', 'commit', 'snapshot']),
29
+ keyId: z.string().min(1).max(128),
30
+ measurement: z.array(z.number().int().min(0).max(255)).length(32),
31
+ payload: z.string().min(1).max(49_152),
32
+ signature: z.string().length(88),
33
+ })
34
+ .strict();
35
+ export const AuthorityCommitValidator = AuthorityRecordValidator.extend({
36
+ kind: z.literal('commit'),
37
+ payload: z.string().min(1).max(8192),
38
+ });
39
+ // Idempotent-retry attestation for confirm-hold / send-release-warning (see
40
+ // `authority_hold_snapshot` in policy/authority.rs): a fresh call returns a
41
+ // `commit`; a retry of an already-applied operation returns this instead,
42
+ // signed over the hold's current fields rather than a new ledger transition.
43
+ export const AuthorityHoldSnapshotValidator = AuthorityRecordValidator.extend({
44
+ kind: z.literal('snapshot'),
45
+ });
46
+ // confirm-hold and send-release-warning responses carry whichever shape the
47
+ // enclave actually returned; callers must not assume `commit`.
48
+ export const AuthorityHoldReceiptValidator = z.union([
49
+ AuthorityCommitValidator,
50
+ AuthorityHoldSnapshotValidator,
51
+ ]);
52
+ export const SignedEnrollmentIntentValidator = z
53
+ .object({
54
+ intent: z
55
+ .object({
56
+ version: z.literal(2),
57
+ tenant: label,
58
+ did: z.string().length(56),
59
+ authorityGeneration: label,
60
+ expectedEnrollmentVersion: safeInteger,
61
+ action: z.enum(['enroll', 'replace', 'disable', 'carry']),
62
+ sourceBlobIdentity: z
63
+ .string()
64
+ .regex(/^[0-9a-f]{64}$/)
65
+ .nullable(),
66
+ targetBlobIdentity: z
67
+ .string()
68
+ .regex(/^[0-9a-f]{64}$/)
69
+ .nullable(),
70
+ pinAction: z.enum(['set', 'change', 'clear', 'keep']),
71
+ shareVersion: z.number().int().positive().max(0xffffffff),
72
+ enrollmentEpoch: safeInteger.positive(),
73
+ requestId: z.string().min(1).max(112),
74
+ allowRewrap: z.boolean(),
75
+ // P9.1 (attack C): shape only — `encodeEscrowEnrollmentIntent` below
76
+ // enforces normalization, the paired requirement, and the language
77
+ // allow-list; lca-api never re-derives or overrides these values.
78
+ recoveryEmail: z.string().min(3).max(254).nullable(),
79
+ recoveryLanguage: z.string().length(2).nullable(),
80
+ })
81
+ .strict()
82
+ .refine(intent => {
83
+ try {
84
+ encodeEscrowEnrollmentIntent(intent);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }, 'Invalid enrollment intent'),
90
+ signature: z.string().length(88),
91
+ })
92
+ .strict();
93
+ export const AuthorityStateValidator = z
94
+ .object({
95
+ tenant: label,
96
+ authorityGeneration: label,
97
+ did: z.string().min(1).max(8192),
98
+ enrollmentVersion: safeInteger,
99
+ enrollmentEpoch: safeInteger,
100
+ status: z.enum(['empty', 'active', 'disabled']),
101
+ envelope: AuthorityEnvelopeValidator.nullable(),
102
+ shareVersion: z.number().int().positive().max(0xffffffff).nullable(),
103
+ hasPin: z.boolean(),
104
+ pinGeneration: safeInteger,
105
+ attemptsUsed: z.number().int().min(0).max(10),
106
+ })
107
+ .strict();
108
+ export const AuthorityStateResponseValidator = z
109
+ .object({
110
+ state: AuthorityStateValidator,
111
+ proof: AuthorityRecordValidator.extend({ kind: z.literal('snapshot') }),
112
+ })
113
+ .strict();
114
+ export const AuthorityMutationValidator = z
115
+ .object({
116
+ envelope: AuthorityEnvelopeValidator.nullable(),
117
+ receipt: AuthorityCommitValidator,
118
+ authorityGeneration: label,
119
+ enrollmentVersion: safeInteger.positive(),
120
+ enrollmentEpoch: safeInteger.positive(),
121
+ })
122
+ .strict();
123
+ export type AuthorityRecord = z.infer<typeof AuthorityRecordValidator>;
124
+ export type AuthorityHoldReceipt = z.infer<typeof AuthorityHoldReceiptValidator>;
125
+ export type AuthorityMutationResult = z.infer<typeof AuthorityMutationValidator>;
126
+ export type AuthorityStateResponse = z.infer<typeof AuthorityStateResponseValidator>;
@@ -40,13 +40,13 @@ export const cancelHoldInEnclave = async (
40
40
  ) {
41
41
  throw new Error('Cancellation material unavailable');
42
42
  }
43
- await getEscrowEnclave().cancelHold({
43
+ const result = await getEscrowEnclave().cancelHold({
44
44
  envelope,
45
45
  hold: hold.holdRecord,
46
46
  clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
47
47
  expectedDid: hold.primaryDid,
48
48
  });
49
- await confirmEscrowEnclaveCancellation(hold._id);
49
+ await confirmEscrowEnclaveCancellation(hold._id, result?.receipt);
50
50
  return true;
51
51
  } catch {
52
52
  // Policy/unknown/terminal errors are ambiguous, not proof of cancellation.
@@ -0,0 +1,64 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createRemoteEnclave } from './remoteEnclave';
3
+ import { logEscrowFailure } from './logFailure';
4
+ import { EscrowUnavailableError } from './types';
5
+
6
+ afterEach(() => vi.restoreAllMocks());
7
+
8
+ describe('escrow failure reasons', () => {
9
+ it('logs invalid outbound holds without error details', () => {
10
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
11
+ const error = new EscrowUnavailableError('invalid_hold');
12
+ error.message = 'private hold contents';
13
+ logEscrowFailure(error);
14
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
15
+ reason: 'invalid_hold',
16
+ });
17
+ });
18
+ it.each([
19
+ [403, 'policy', 'policy'],
20
+ [403, 'pinMismatch', 'pin_mismatch'],
21
+ [403, 'codeMismatch', 'code_mismatch'],
22
+ [403, 'codeExpired', 'code_expired'],
23
+ [403, 'codeLocked', 'code_locked'],
24
+ [403, 'blob', 'blob'],
25
+ [503, 'ledger', 'ledger'],
26
+ [503, 'time', 'time'],
27
+ [412, 'unavailable', 'unavailable'],
28
+ [403, 'secret-unknown-code', 'http_refusal'],
29
+ [429, 'unknown', 'rate_limit'],
30
+ [200, 'unknown', 'invalid_response'],
31
+ ])('classifies HTTP %s / %s without logging payloads', async (status, code, reason) => {
32
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
33
+ const enclave = createRemoteEnclave({
34
+ baseUrl: 'https://example.invalid',
35
+ token: 'secret-token',
36
+ timeoutMs: 100,
37
+ transport: {
38
+ post: async () => ({ status, data: { code, message: 'secret@example.com' } }),
39
+ },
40
+ });
41
+ await enclave.getAttestation().catch(logEscrowFailure);
42
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
43
+ reason,
44
+ });
45
+ });
46
+
47
+ it('drops transport error details', async () => {
48
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
49
+ const enclave = createRemoteEnclave({
50
+ baseUrl: 'https://example.invalid',
51
+ token: 'secret-token',
52
+ timeoutMs: 100,
53
+ transport: {
54
+ post: async () => {
55
+ throw new Error('secret-token');
56
+ },
57
+ },
58
+ });
59
+ await enclave.getAttestation().catch(logEscrowFailure);
60
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
61
+ reason: 'transport',
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,29 @@
1
+ import {
2
+ EscrowBlobError,
3
+ EscrowCodeError,
4
+ EscrowPinMismatchError,
5
+ EscrowPolicyError,
6
+ EscrowUnavailableError,
7
+ } from './types';
8
+
9
+ /** Only allowlisted classifications reach logs; never serialize remote errors or payloads. */
10
+ export const logEscrowFailure = (error: unknown): void => {
11
+ const reason =
12
+ error instanceof EscrowCodeError
13
+ ? {
14
+ codeMismatch: 'code_mismatch',
15
+ codeExpired: 'code_expired',
16
+ codeLocked: 'code_locked',
17
+ }[error.reason]
18
+ : error instanceof EscrowUnavailableError
19
+ ? error.reason
20
+ : error instanceof EscrowPolicyError
21
+ ? 'policy'
22
+ : error instanceof EscrowPinMismatchError
23
+ ? 'pin_mismatch'
24
+ : error instanceof EscrowBlobError
25
+ ? 'blob'
26
+ : 'unexpected';
27
+ // lca-api uses structured console warnings rather than the browser logger.
28
+ console.warn('[LCA escrow] enclave operation failed', { reason });
29
+ };
@@ -3,6 +3,7 @@ import type { EscrowEnvelope } from '@learncard/sss-key-manager';
3
3
  import { createRemoteEnclave, type RemoteEnclaveTransportResponse } from './remoteEnclave';
4
4
  import {
5
5
  EscrowBlobError,
6
+ EscrowCodeError,
6
7
  EscrowPinMismatchError,
7
8
  EscrowPolicyError,
8
9
  EscrowUnavailableError,
@@ -12,6 +13,35 @@ import {
12
13
  const baseUrl = 'https://enclave-host.internal';
13
14
  const token = 't'.repeat(32);
14
15
  const timeoutMs = 10_000;
16
+ const receipt = {
17
+ version: 1 as const,
18
+ kind: 'commit' as const,
19
+ keyId: 'key-1',
20
+ measurement: Array(32).fill(0),
21
+ payload: 'AQ==',
22
+ signature: Buffer.alloc(64).toString('base64'),
23
+ };
24
+
25
+ // Real Rust shape for the idempotent-retry path (`authority_hold_snapshot`,
26
+ // policy/authority.rs): a signed tuple, never an `AuthorityCommitValidator`.
27
+ const snapshotReceipt = {
28
+ version: 1 as const,
29
+ kind: 'snapshot' as const,
30
+ keyId: 'key-1',
31
+ measurement: Array(32).fill(0),
32
+ payload: Buffer.from(
33
+ JSON.stringify([
34
+ 'learncard-authority-hold-snapshot-v2',
35
+ 'hold-1',
36
+ Array(32).fill(7),
37
+ 'pending',
38
+ 1700000000000,
39
+ 1700086400000,
40
+ 'g1',
41
+ ])
42
+ ).toString('base64'),
43
+ signature: Buffer.alloc(64).toString('base64'),
44
+ };
15
45
 
16
46
  const envelope: EscrowEnvelope = {
17
47
  version: 1,
@@ -85,6 +115,40 @@ describe('createRemoteEnclave', () => {
85
115
  );
86
116
  });
87
117
 
118
+ it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
119
+ 'maps %s without forwarding diagnostic text',
120
+ async code => {
121
+ const post = vi
122
+ .fn()
123
+ .mockResolvedValue(respond(403, { code, message: 'sensitive diagnostic' }));
124
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
125
+ await expect(enclave.confirmHold!(confirmInput)).rejects.toEqual(
126
+ new EscrowCodeError(code)
127
+ );
128
+ }
129
+ );
130
+
131
+ it('separates delivery evidence from the signed hold before subsequent requests', async () => {
132
+ const delivery = { codeSent: false, proof: snapshotReceipt };
133
+ const post = vi.fn().mockResolvedValue(respond(200, { ...hold, delivery }));
134
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
135
+ await expect(enclave.createHold(createInput)).resolves.toEqual({
136
+ holdRecord: hold,
137
+ delivery,
138
+ });
139
+ });
140
+
141
+ it('forwards fresh hold-state nonces under the existing bearer authentication', async () => {
142
+ const post = vi.fn().mockResolvedValue(respond(200, { proof: snapshotReceipt }));
143
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
144
+ const input = { expectedDid: hold.hold.did, hold, nonce: 'ab'.repeat(32) };
145
+ await expect(enclave.getHoldState!(input)).resolves.toEqual({ proof: snapshotReceipt });
146
+ expect(post).toHaveBeenCalledWith(`${baseUrl}/v1/hold-state`, input, {
147
+ headers: { Authorization: `Bearer ${token}` },
148
+ timeout: timeoutMs,
149
+ });
150
+ });
151
+
88
152
  it('sends an empty nonce array when none is given', async () => {
89
153
  const post = vi.fn().mockResolvedValue(respond(200, attestationBody));
90
154
  const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
@@ -223,7 +287,14 @@ describe('createRemoteEnclave', () => {
223
287
  });
224
288
 
225
289
  it('rewraps an escrow blob sealed under a previous key onto the current key', async () => {
226
- const post = vi.fn().mockResolvedValue(respond(200, { envelope }));
290
+ const authority = {
291
+ envelope,
292
+ receipt,
293
+ authorityGeneration: 'g1',
294
+ enrollmentVersion: 2,
295
+ enrollmentEpoch: 1,
296
+ };
297
+ const post = vi.fn().mockResolvedValue(respond(200, { result: authority }));
227
298
  const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
228
299
  const input = {
229
300
  envelope,
@@ -232,10 +303,10 @@ describe('createRemoteEnclave', () => {
232
303
  sourceEnrollmentEpoch: 1,
233
304
  };
234
305
 
235
- await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope });
306
+ await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope, authority });
236
307
 
237
308
  expect(post).toHaveBeenCalledWith(
238
- `${baseUrl}/v1/rewrap-escrow-blob`,
309
+ `${baseUrl}/v1/authority-rewrap`,
239
310
  {
240
311
  envelope,
241
312
  expectedDid: 'did:example:alice',
@@ -246,6 +317,243 @@ describe('createRemoteEnclave', () => {
246
317
  );
247
318
  });
248
319
 
320
+ it('preserves v2 cancellation receipts and stable release IDs, refusing absent commit evidence', async () => {
321
+ const v2 = {
322
+ ...hold,
323
+ hold: {
324
+ ...hold.hold,
325
+ policyVersion: 2,
326
+ signature: Buffer.from(JSON.stringify(receipt)).toString('base64'),
327
+ },
328
+ };
329
+ const post = vi.fn().mockResolvedValue(respond(200, { ok: true, receipt }));
330
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
331
+ const input = {
332
+ envelope,
333
+ hold: v2,
334
+ expectedDid: v2.hold.did,
335
+ clientEphemeralPublicKey: v2.hold.clientEphemeralPublicKey,
336
+ requestId: 'stable-id',
337
+ };
338
+ await expect(enclave.cancelHold(input)).resolves.toEqual({ receipt });
339
+ post.mockResolvedValue(respond(200, { sealed: envelope, receipt }));
340
+ await expect(enclave.releaseEscrow(input)).resolves.toEqual({ sealed: envelope, receipt });
341
+ expect(post.mock.calls.at(-1)?.[1]).toMatchObject({ requestId: 'stable-id' });
342
+ post.mockResolvedValue(respond(200, { sealed: envelope }));
343
+ await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
344
+ reason: 'invalid_response',
345
+ });
346
+ post.mockResolvedValue(respond(200, { ok: true }));
347
+ await expect(enclave.cancelHold(input)).rejects.toMatchObject({
348
+ reason: 'invalid_response',
349
+ });
350
+ });
351
+
352
+ it.each([
353
+ { ...hold, ledgerSeq: -1 },
354
+ { ...hold, hold: { ...hold.hold, policyVersion: 2, signature: 'invalid' } },
355
+ ])('distinguishes invalid outbound holds from invalid returned holds', async invalidHold => {
356
+ const post = vi.fn().mockResolvedValue(respond(200, invalidHold));
357
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
358
+ const input = {
359
+ envelope,
360
+ hold: invalidHold,
361
+ expectedDid: hold.hold.did,
362
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
363
+ };
364
+ await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
365
+ reason: 'invalid_hold',
366
+ });
367
+ await expect(enclave.cancelHold(input)).rejects.toMatchObject({ reason: 'invalid_hold' });
368
+ await expect(enclave.confirmHold!({ ...input, code: '12345678' })).rejects.toMatchObject({
369
+ reason: 'invalid_hold',
370
+ });
371
+ await expect(enclave.resendCode!(input)).rejects.toMatchObject({ reason: 'invalid_hold' });
372
+ expect(post).not.toHaveBeenCalled();
373
+ await expect(
374
+ enclave.createHold({
375
+ envelope,
376
+ ...hold.hold,
377
+ expectedDid: hold.hold.did,
378
+ expectedShareVersion: 1,
379
+ })
380
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
381
+ });
382
+
383
+ it.each([
384
+ { envelope: null, enrollmentEpoch: 1 },
385
+ { envelope, enrollmentEpoch: 2 },
386
+ ])('classifies invalid rewrap envelope/epoch as a response failure', async result => {
387
+ const post = vi.fn().mockResolvedValue(
388
+ respond(200, {
389
+ result: {
390
+ ...result,
391
+ receipt,
392
+ authorityGeneration: 'g1',
393
+ enrollmentVersion: 2,
394
+ },
395
+ })
396
+ );
397
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
398
+ await expect(
399
+ enclave.rewrapEscrowBlob({
400
+ envelope,
401
+ expectedDid: hold.hold.did,
402
+ expectedShareVersion: 1,
403
+ sourceEnrollmentEpoch: 1,
404
+ })
405
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
406
+ });
407
+
408
+ // [A] confirm-hold/send-release-warning: a fresh call returns a signed
409
+ // `commit`; an idempotent retry (lost response, hourly job re-run)
410
+ // returns a signed `snapshot` instead — both are real response shapes
411
+ // the enclave can return, never a stub that only ever returns `commit`.
412
+ const confirmInput = {
413
+ envelope,
414
+ hold,
415
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
416
+ expectedDid: hold.hold.did,
417
+ code: '12345678',
418
+ };
419
+ it('confirmHold accepts a fresh commit receipt and returns the authoritative confirmation time', async () => {
420
+ const post = vi
421
+ .fn()
422
+ .mockResolvedValue(
423
+ respond(200, { confirmed: true, receipt, confirmedHiMs: 1700000000000 })
424
+ );
425
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
426
+
427
+ await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
428
+ receipt,
429
+ confirmedAtMs: 1700000000000,
430
+ });
431
+ });
432
+ it('confirmHold accepts a retry snapshot receipt (not a commit) and returns the SAME confirmation time', async () => {
433
+ const post = vi.fn().mockResolvedValue(
434
+ respond(200, {
435
+ confirmed: true,
436
+ receipt: snapshotReceipt,
437
+ confirmedHiMs: 1700000000000,
438
+ })
439
+ );
440
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
441
+
442
+ await expect(enclave.confirmHold!(confirmInput)).resolves.toEqual({
443
+ receipt: snapshotReceipt,
444
+ confirmedAtMs: 1700000000000,
445
+ });
446
+ });
447
+ it('confirmHold fails closed when the receipt is neither a commit nor a snapshot', async () => {
448
+ const post = vi.fn().mockResolvedValue(
449
+ respond(200, {
450
+ confirmed: true,
451
+ receipt: { ...receipt, kind: 'proposal' },
452
+ confirmedHiMs: 1700000000000,
453
+ })
454
+ );
455
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
456
+
457
+ await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
458
+ EscrowUnavailableError
459
+ );
460
+ });
461
+ it('confirmHold fails closed when confirmedHiMs is missing (pre-fix response shape)', async () => {
462
+ const post = vi.fn().mockResolvedValue(respond(200, { confirmed: true, receipt }));
463
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
464
+
465
+ await expect(enclave.confirmHold!(confirmInput)).rejects.toBeInstanceOf(
466
+ EscrowUnavailableError
467
+ );
468
+ });
469
+
470
+ const warningInput = { holdId: hold.hold.holdId, expectedDid: hold.hold.did };
471
+ it('sendReleaseWarning accepts a fresh commit receipt and returns the authoritative send time', async () => {
472
+ const post = vi
473
+ .fn()
474
+ .mockResolvedValue(
475
+ respond(200, { sent: true, receipt, releaseWarningSentHiMs: 1700086400000 })
476
+ );
477
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
478
+
479
+ await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
480
+ receipt,
481
+ releaseWarningSentAtMs: 1700086400000,
482
+ });
483
+ });
484
+ it('sendReleaseWarning accepts a retry snapshot receipt (not a commit) and returns the SAME send time', async () => {
485
+ const post = vi.fn().mockResolvedValue(
486
+ respond(200, {
487
+ sent: true,
488
+ receipt: snapshotReceipt,
489
+ releaseWarningSentHiMs: 1700086400000,
490
+ })
491
+ );
492
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
493
+
494
+ await expect(enclave.sendReleaseWarning!(warningInput)).resolves.toEqual({
495
+ receipt: snapshotReceipt,
496
+ releaseWarningSentAtMs: 1700086400000,
497
+ });
498
+ });
499
+ it('sendReleaseWarning fails closed when releaseWarningSentHiMs is missing (pre-fix response shape)', async () => {
500
+ const post = vi.fn().mockResolvedValue(respond(200, { sent: true, receipt }));
501
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
502
+
503
+ await expect(enclave.sendReleaseWarning!(warningInput)).rejects.toBeInstanceOf(
504
+ EscrowUnavailableError
505
+ );
506
+ });
507
+
508
+ it('forwards signed enrollment once and rejects a proposal or mismatched result generation', async () => {
509
+ const input = {
510
+ signedIntent: {
511
+ intent: {
512
+ version: 2 as const,
513
+ tenant: 'learncard',
514
+ did: 'did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw',
515
+ authorityGeneration: 'g1',
516
+ expectedEnrollmentVersion: 0,
517
+ action: 'enroll' as const,
518
+ sourceBlobIdentity: null,
519
+ targetBlobIdentity: 'ab'.repeat(32),
520
+ pinAction: 'keep' as const,
521
+ shareVersion: 1,
522
+ enrollmentEpoch: 1,
523
+ requestId: 'request',
524
+ allowRewrap: true,
525
+ recoveryEmail: 'owner@example.com',
526
+ recoveryLanguage: 'en',
527
+ },
528
+ signature: Buffer.alloc(64).toString('base64'),
529
+ },
530
+ sourceEnvelope: null,
531
+ targetEnvelope: envelope,
532
+ };
533
+ const result = {
534
+ envelope,
535
+ receipt,
536
+ authorityGeneration: 'g1',
537
+ enrollmentVersion: 1,
538
+ enrollmentEpoch: 1,
539
+ };
540
+ const post = vi.fn().mockResolvedValue(respond(200, { result }));
541
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
542
+ await expect(enclave.applyEnrollmentIntent!(input)).resolves.toEqual(result);
543
+ expect(post).toHaveBeenCalledTimes(1);
544
+ expect(post.mock.calls[0]?.slice(0, 2)).toEqual([`${baseUrl}/v1/apply-enrollment`, input]);
545
+ post.mockResolvedValue(respond(200, { result: { ...result, authorityGeneration: 'g2' } }));
546
+ await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
547
+ EscrowUnavailableError
548
+ );
549
+ post.mockResolvedValue(
550
+ respond(200, { result: { ...result, receipt: { ...receipt, kind: 'proposal' } } })
551
+ );
552
+ await expect(enclave.applyEnrollmentIntent!(input)).rejects.toBeInstanceOf(
553
+ EscrowUnavailableError
554
+ );
555
+ });
556
+
249
557
  it.each([
250
558
  ['policy', EscrowPolicyError],
251
559
  ['pinMismatch', EscrowPinMismatchError],
@@ -412,7 +720,10 @@ describe('createRemoteEnclave', () => {
412
720
  const created = await enclave.createHold(createInput);
413
721
  expect(created).toEqual({ holdRecord: opaque });
414
722
  const config = { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs };
415
- expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput, config);
723
+ expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput, {
724
+ ...config,
725
+ headers: { ...config.headers, 'X-Escrow-Delivery-Evidence': '1' },
726
+ });
416
727
  await expect(
417
728
  enclave.cancelHold({ ...cancelInput, hold: created.holdRecord })
418
729
  ).resolves.toBeUndefined();