@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
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { createServer, type Server } from 'node:https';
3
3
  import type { AddressInfo } from 'node:net';
4
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
- import { createHttpTransport } from './remoteEnclave';
4
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
5
+ import { createHttpTransport, createRemoteEnclave } from './remoteEnclave';
6
6
  import { pinnedEnclaveCa } from './enclaveTrustAnchors';
7
+ import { logEscrowFailure } from './logFailure';
7
8
 
8
9
  const fixture = (name: string): string =>
9
10
  readFileSync(new URL(`./__fixtures__/tls/${name}`, import.meta.url), 'utf8');
@@ -60,3 +61,182 @@ describe('remote enclave TLS trust', () => {
60
61
  expect(pinnedEnclaveCa('https://enclave-host.internal')).toBeUndefined();
61
62
  });
62
63
  });
64
+
65
+ describe('remote enclave connection recovery', () => {
66
+ const servers: Server[] = [];
67
+ const config = { headers: { Authorization: 'Bearer test-secret' }, timeout: 1_000 };
68
+ const serve = async (handler: Parameters<typeof createServer>[1]): Promise<string> => {
69
+ const server = createServer(
70
+ { cert: fixture('pinned-leaf.pem'), key: fixture('pinned-leaf.key') },
71
+ handler
72
+ );
73
+ servers.push(server);
74
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
75
+ return url(server).replace('/v1/attest', '');
76
+ };
77
+
78
+ afterEach(async () => {
79
+ vi.restoreAllMocks();
80
+ await Promise.all(
81
+ servers.splice(0).map(
82
+ server =>
83
+ new Promise<void>(resolve => {
84
+ server.closeAllConnections();
85
+ server.close(() => resolve());
86
+ })
87
+ )
88
+ );
89
+ });
90
+
91
+ it.each(['/v1/attest', '/v1/enrollment-state', '/v1/hold-state'])(
92
+ 'retries %s once on a pre-response reset, on a fresh TLS connection',
93
+ async path => {
94
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
95
+ const sockets = new Set();
96
+ let requests = 0;
97
+ const origin = await serve((request, response) => {
98
+ sockets.add(request.socket);
99
+ requests += 1;
100
+ if (requests === 1) request.socket.destroy();
101
+ else response.end('{"ok":true}');
102
+ });
103
+ const transport = createHttpTransport(fixture('pinned-ca.pem'));
104
+ await expect(
105
+ transport.post(`${origin}${path}`, { secret: 'private-body' }, config)
106
+ ).resolves.toEqual({ status: 200, data: { ok: true } });
107
+ expect(requests).toBe(2);
108
+ expect(sockets.size).toBe(2);
109
+ expect(warn.mock.calls).toEqual([
110
+ ['[LCA escrow] retrying enclave read', { code: 'ECONNRESET' }],
111
+ ]);
112
+ }
113
+ );
114
+
115
+ it('does not pool successful requests across invocations', async () => {
116
+ const sockets = new Set();
117
+ const connections: (string | undefined)[] = [];
118
+ const origin = await serve((request, response) => {
119
+ sockets.add(request.socket);
120
+ connections.push(request.headers.connection);
121
+ response.end('{"ok":true}');
122
+ });
123
+ const transport = createHttpTransport(fixture('pinned-ca.pem'));
124
+ await transport.post(`${origin}/v1/attest`, {}, config);
125
+ await transport.post(`${origin}/v1/attest`, {}, config);
126
+ expect(sockets.size).toBe(2);
127
+ expect(connections).toEqual(['close', 'close']);
128
+ });
129
+
130
+ it.each([
131
+ '/v1/create-hold',
132
+ '/v1/confirm-hold',
133
+ '/v1/release',
134
+ '/v1/cancel-hold',
135
+ '/v1/apply-enrollment',
136
+ '/v1/authority-rewrap',
137
+ '/v1/carry-pin-verifier',
138
+ '/v1/resend-code',
139
+ '/v1/send-release-warning',
140
+ '/v1/unknown',
141
+ ])('never retries %s even when no response arrived', async path => {
142
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
143
+ let requests = 0;
144
+ const origin = await serve(request => {
145
+ requests += 1;
146
+ request.socket.destroy();
147
+ });
148
+ await expect(
149
+ createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}${path}`, {}, config)
150
+ ).rejects.toMatchObject({ code: 'ECONNRESET' });
151
+ expect(requests).toBe(1);
152
+ expect(warn).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it.each([429, 503, 302])('does not retry or follow HTTP %s', async status => {
156
+ let requests = 0;
157
+ const origin = await serve((_request, response) => {
158
+ requests += 1;
159
+ response.writeHead(status, { Location: '/v1/attest' });
160
+ response.end('{}');
161
+ });
162
+ await expect(
163
+ createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}/v1/attest`, {}, config)
164
+ ).resolves.toMatchObject({ status });
165
+ expect(requests).toBe(1);
166
+ });
167
+
168
+ it('does not retry a timeout', async () => {
169
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
170
+ let requests = 0;
171
+ const origin = await serve(() => {
172
+ requests += 1;
173
+ });
174
+ await expect(
175
+ createHttpTransport(fixture('pinned-ca.pem')).post(
176
+ `${origin}/v1/attest`,
177
+ {},
178
+ { ...config, timeout: 100 }
179
+ )
180
+ ).rejects.toThrow();
181
+ expect(requests).toBe(1);
182
+ expect(warn).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('does not retry after even partial response headers', async () => {
186
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
187
+ let requests = 0;
188
+ const origin = await serve(request => {
189
+ requests += 1;
190
+ request.socket.write('HTTP/1.1 200 OK\r\n');
191
+ setTimeout(() => request.socket.destroy(), 20);
192
+ });
193
+ await expect(
194
+ createHttpTransport(fixture('pinned-ca.pem')).post(`${origin}/v1/attest`, {}, config)
195
+ ).rejects.toThrow();
196
+ expect(requests).toBe(1);
197
+ expect(warn).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('keeps the second attempt within the original wall-clock budget', async () => {
201
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
202
+ let requests = 0;
203
+ const origin = await serve(request => {
204
+ requests += 1;
205
+ if (requests === 1) setTimeout(() => request.socket.destroy(), 150);
206
+ });
207
+ const start = performance.now();
208
+ await expect(
209
+ createHttpTransport(fixture('pinned-ca.pem')).post(
210
+ `${origin}/v1/attest`,
211
+ {},
212
+ { ...config, timeout: 300 }
213
+ )
214
+ ).rejects.toThrow();
215
+ expect(performance.now() - start).toBeLessThan(430);
216
+ expect(requests).toBe(2);
217
+ expect(warn).toHaveBeenCalledOnce();
218
+ });
219
+
220
+ it('stops after one retry and preserves sanitized transport failure logging', async () => {
221
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
222
+ let requests = 0;
223
+ const origin = await serve(request => {
224
+ requests += 1;
225
+ request.socket.destroy();
226
+ });
227
+ const enclave = createRemoteEnclave({
228
+ baseUrl: origin,
229
+ token: 'private-token',
230
+ timeoutMs: 1_000,
231
+ caPem: fixture('pinned-ca.pem'),
232
+ });
233
+ const error = await enclave.getAttestation().catch(error => error);
234
+ expect(error).toMatchObject({ reason: 'transport' });
235
+ logEscrowFailure(error);
236
+ expect(requests).toBe(2);
237
+ expect(warn.mock.calls).toEqual([
238
+ ['[LCA escrow] retrying enclave read', { code: 'ECONNRESET' }],
239
+ ['[LCA escrow] enclave operation failed', { reason: 'transport' }],
240
+ ]);
241
+ });
242
+ });
@@ -1,6 +1,14 @@
1
1
  import { Agent } from 'node:https';
2
+ import { Agent as HttpAgent, ClientRequest } from 'node:http';
2
3
  import axios from 'axios';
3
4
  import { z } from 'zod';
5
+ import {
6
+ AuthorityCommitValidator,
7
+ AuthorityHoldReceiptValidator,
8
+ AuthorityMutationValidator,
9
+ AuthorityStateResponseValidator,
10
+ safeInteger,
11
+ } from './authorityContract';
4
12
  import {
5
13
  ESCROW_ALGORITHM,
6
14
  ESCROW_ENVELOPE_VERSION,
@@ -9,14 +17,18 @@ import {
9
17
  import {
10
18
  EscrowBlobError,
11
19
  EscrowPinMismatchError,
20
+ EscrowCodeError,
12
21
  EscrowPolicyError,
13
22
  EscrowUnavailableError,
14
23
  type CarryPinVerifierInput,
24
+ type ConfirmHoldRequest,
15
25
  type EnclaveAttestation,
16
26
  type EscrowEnclave,
17
27
  type ReleaseRequest,
18
28
  type ReleaseResult,
29
+ type ResendCodeRequest,
19
30
  type RewrapEscrowBlobInput,
31
+ type SendReleaseWarningRequest,
20
32
  type VerifyEscrowBlobInput,
21
33
  type VerifyEscrowBlobResult,
22
34
  } from './types';
@@ -58,7 +70,6 @@ const verifyBlobResponseValidator = z.union([
58
70
  ]);
59
71
 
60
72
  const carryPinVerifierResponseValidator = z.object({ envelope: envelopeValidator });
61
- const rewrapEscrowBlobResponseValidator = z.object({ envelope: envelopeValidator });
62
73
 
63
74
  // Opaque SignedHoldRecord JSON: validate shape while preserving signed extensions.
64
75
  const holdRecordValidator = z
@@ -74,23 +85,41 @@ const holdRecordValidator = z
74
85
  clientEphemeralPublicKey: z.string().min(1),
75
86
  createdLo: z.number().int().nonnegative(),
76
87
  createdHi: z.number().int().nonnegative(),
77
- policyVersion: z.number().int().positive(),
78
- signature: z.string().min(1),
88
+ policyVersion: z.union([z.literal(1), z.literal(2)]),
89
+ signature: z.string().min(1).max(8192),
79
90
  })
80
91
  .passthrough(),
81
92
  holdDurationMs: z.number().int().nonnegative(),
82
93
  ledgerSeq: z.number().int().nonnegative(),
83
94
  })
84
95
  .passthrough();
85
- const validatedHold = (hold: ReleaseRequest['hold']): ReleaseRequest['hold'] => {
86
- if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError();
96
+ const validatedHold = (
97
+ hold: ReleaseRequest['hold'],
98
+ reason: 'invalid_hold' | 'invalid_response' = 'invalid_hold'
99
+ ): ReleaseRequest['hold'] => {
100
+ if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError(reason);
101
+ if (hold.hold.policyVersion === 2) {
102
+ try {
103
+ AuthorityCommitValidator.parse(
104
+ JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
105
+ );
106
+ } catch {
107
+ throw new EscrowUnavailableError(reason);
108
+ }
109
+ }
87
110
  return hold;
88
111
  };
89
- const releaseResponseValidator = z.object({ sealed: envelopeValidator });
112
+ const releaseResponseValidator = z.object({
113
+ sealed: envelopeValidator,
114
+ receipt: AuthorityCommitValidator.optional(),
115
+ });
90
116
 
91
117
  const errorCodeValidator = z.enum([
92
118
  'policy',
93
119
  'pinMismatch',
120
+ 'codeMismatch',
121
+ 'codeExpired',
122
+ 'codeLocked',
94
123
  'blob',
95
124
  'unavailable',
96
125
  'ledger',
@@ -101,8 +130,10 @@ const errorResponseValidator = z.object({ code: errorCodeValidator, message: z.s
101
130
  const errorForCode = (code: z.infer<typeof errorCodeValidator>): Error => {
102
131
  if (code === 'policy') return new EscrowPolicyError();
103
132
  if (code === 'pinMismatch') return new EscrowPinMismatchError();
133
+ if (code === 'codeMismatch' || code === 'codeExpired' || code === 'codeLocked')
134
+ return new EscrowCodeError(code);
104
135
  if (code === 'blob') return new EscrowBlobError();
105
- return new EscrowUnavailableError(); // 'unavailable' | 'ledger' | 'time'
136
+ return new EscrowUnavailableError(code); // 'unavailable' | 'ledger' | 'time'
106
137
  };
107
138
 
108
139
  export interface RemoteEnclaveTransportResponse {
@@ -120,15 +151,57 @@ export interface RemoteEnclaveTransport {
120
151
 
121
152
  /** With `caPem`, the connection trusts only that CA (system roots are not consulted). */
122
153
  export const createHttpTransport = (caPem?: string): RemoteEnclaveTransport => {
123
- const httpsAgent = caPem ? new Agent({ ca: caPem, keepAlive: true }) : undefined;
154
+ // The host enforces a 60s absolute connection lifetime, even for busy sockets.
155
+ // Do not retain sockets across Lambda freezes (or use Node's global pool).
156
+ const httpsAgent = new Agent({ ...(caPem ? { ca: caPem } : {}), keepAlive: false });
157
+ const httpAgent = new HttpAgent({ keepAlive: false });
124
158
  return {
125
159
  post: async (url, body, config) => {
126
- const response = await axios.post(url, body, {
127
- ...config,
128
- httpsAgent,
129
- validateStatus: () => true,
130
- });
131
- return { status: response.status, data: response.data };
160
+ const path = new URL(url).pathname;
161
+ const safeRead = ['/v1/attest', '/v1/enrollment-state', '/v1/hold-state'].includes(
162
+ path
163
+ );
164
+ const deadline = performance.now() + config.timeout;
165
+ const controller = new AbortController();
166
+ // One wall-clock budget covers connection setup and BOTH attempts.
167
+ const timer = setTimeout(() => controller.abort(), config.timeout);
168
+ try {
169
+ for (let attempt = 0; ; attempt += 1) {
170
+ try {
171
+ const response = await axios.post(url, body, {
172
+ ...config,
173
+ timeout: Math.max(1, Math.ceil(deadline - performance.now())),
174
+ signal: controller.signal,
175
+ httpsAgent,
176
+ httpAgent,
177
+ // Keep the native ClientRequest observable; never redirect a vault POST.
178
+ maxRedirects: 0,
179
+ validateStatus: () => true,
180
+ });
181
+ return { status: response.status, data: response.data };
182
+ } catch (error) {
183
+ const code = axios.isAxiosError(error) ? error.code : undefined;
184
+ if (
185
+ attempt !== 0 ||
186
+ !safeRead ||
187
+ (code !== 'ECONNRESET' && code !== 'EPIPE') ||
188
+ !axios.isAxiosError(error) ||
189
+ error.response ||
190
+ !(error.request instanceof ClientRequest) ||
191
+ error.request.socket?.bytesRead !== 0 ||
192
+ controller.signal.aborted ||
193
+ performance.now() >= deadline
194
+ )
195
+ throw error;
196
+ // No response does NOT prove the server did not execute the request.
197
+ // Never replay mutations: holds, PIN attempts, delivery and enrollment
198
+ // may already be committed. Only the three read paths above are safe.
199
+ console.warn('[LCA escrow] retrying enclave read', { code });
200
+ }
201
+ }
202
+ } finally {
203
+ clearTimeout(timer);
204
+ }
132
205
  },
133
206
  };
134
207
  };
@@ -162,25 +235,46 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
162
235
  let data: unknown;
163
236
  try {
164
237
  const response = await transport.post(`${config.baseUrl}${path}`, body, {
165
- headers: { Authorization: `Bearer ${config.token}` },
238
+ headers: {
239
+ Authorization: `Bearer ${config.token}`,
240
+ ...(path === '/v1/create-hold' ? { 'X-Escrow-Delivery-Evidence': '1' } : {}),
241
+ },
166
242
  timeout: config.timeoutMs,
167
243
  });
168
244
  status = response.status;
169
245
  data = response.data;
170
246
  } catch {
171
- throw new EscrowUnavailableError();
247
+ throw new EscrowUnavailableError('transport');
172
248
  }
173
249
  if (status < 200 || status >= 300) {
174
250
  const parsedError = errorResponseValidator.safeParse(data);
175
251
  if (parsedError.success) throw errorForCode(parsedError.data.code);
176
- throw new EscrowUnavailableError();
252
+ throw new EscrowUnavailableError(status === 429 ? 'rate_limit' : 'http_refusal');
177
253
  }
178
254
  const parsed = validator.safeParse(data);
179
- if (!parsed.success) throw new EscrowUnavailableError();
255
+ if (!parsed.success) throw new EscrowUnavailableError('invalid_response');
180
256
  return parsed.data;
181
257
  };
182
258
 
183
259
  return {
260
+ getEnrollmentState: input =>
261
+ call('/v1/enrollment-state', input, AuthorityStateResponseValidator),
262
+ applyEnrollmentIntent: async input => {
263
+ const { result } = await call(
264
+ '/v1/apply-enrollment',
265
+ input,
266
+ z.object({ result: AuthorityMutationValidator }).strict()
267
+ );
268
+ if (
269
+ result.authorityGeneration !== input.signedIntent.intent.authorityGeneration ||
270
+ result.enrollmentVersion !==
271
+ input.signedIntent.intent.expectedEnrollmentVersion + 1 ||
272
+ result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
273
+ (result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
274
+ )
275
+ throw new EscrowUnavailableError('invalid_response');
276
+ return result;
277
+ },
184
278
  getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
185
279
  call('/v1/attest', { nonce: Array.from(nonce ?? []) }, attestResponseValidator),
186
280
  verifyEscrowBlob: (input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> =>
@@ -207,19 +301,23 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
207
301
  },
208
302
  carryPinVerifierResponseValidator
209
303
  ),
210
- rewrapEscrowBlob: (input: RewrapEscrowBlobInput): Promise<{ envelope: EscrowEnvelope }> =>
211
- call(
212
- '/v1/rewrap-escrow-blob',
304
+ rewrapEscrowBlob: async (input: RewrapEscrowBlobInput) => {
305
+ const { result } = await call(
306
+ '/v1/authority-rewrap',
213
307
  {
214
308
  envelope: input.envelope,
215
309
  expectedDid: input.expectedDid,
216
310
  expectedShareVersion: input.expectedShareVersion,
217
311
  sourceEnrollmentEpoch: input.sourceEnrollmentEpoch,
218
312
  },
219
- rewrapEscrowBlobResponseValidator
220
- ),
221
- createHold: async input => ({
222
- holdRecord: await call(
313
+ z.object({ result: AuthorityMutationValidator }).strict()
314
+ );
315
+ if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
316
+ throw new EscrowUnavailableError('invalid_response');
317
+ return { envelope: result.envelope, authority: result };
318
+ },
319
+ createHold: async input => {
320
+ const result = await call(
223
321
  '/v1/create-hold',
224
322
  {
225
323
  envelope: input.envelope,
@@ -230,24 +328,93 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
230
328
  releasePolicy: input.releasePolicy,
231
329
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
232
330
  },
233
- holdRecordValidator
234
- ),
235
- }),
331
+ holdRecordValidator.extend({
332
+ delivery: z
333
+ .object({
334
+ codeSent: z.boolean(),
335
+ proof: AuthorityHoldReceiptValidator,
336
+ })
337
+ .optional(),
338
+ })
339
+ );
340
+ // Response-only evidence must not be echoed into strict old-image hold DTOs.
341
+ const { delivery, ...holdRecord } = result;
342
+ return { holdRecord: validatedHold(holdRecord, 'invalid_response'), delivery };
343
+ },
344
+ getHoldState: async input =>
345
+ call('/v1/hold-state', input, z.object({ proof: AuthorityHoldReceiptValidator })),
236
346
  cancelHold: async input => {
237
- await call(
347
+ const result = await call(
238
348
  '/v1/cancel-hold',
239
349
  {
240
350
  envelope: input.envelope,
241
351
  hold: validatedHold(input.hold),
242
352
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
243
353
  expectedDid: input.expectedDid,
354
+ ...(input.requestId ? { requestId: input.requestId } : {}),
355
+ },
356
+ z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
357
+ );
358
+ if (input.hold.hold.policyVersion === 2 && !result.receipt)
359
+ throw new EscrowUnavailableError('invalid_response');
360
+ if (result.receipt) return { receipt: result.receipt };
361
+ return undefined;
362
+ },
363
+ confirmHold: async (input: ConfirmHoldRequest) => {
364
+ const result = await call(
365
+ '/v1/confirm-hold',
366
+ {
367
+ envelope: input.envelope,
368
+ hold: validatedHold(input.hold),
369
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
370
+ expectedDid: input.expectedDid,
371
+ code: input.code,
372
+ ...(input.requestId ? { requestId: input.requestId } : {}),
373
+ },
374
+ z.object({
375
+ confirmed: z.literal(true),
376
+ receipt: AuthorityHoldReceiptValidator,
377
+ confirmedHiMs: safeInteger,
378
+ })
379
+ );
380
+ return { receipt: result.receipt, confirmedAtMs: result.confirmedHiMs };
381
+ },
382
+ resendCode: async (input: ResendCodeRequest) => {
383
+ const result = await call(
384
+ '/v1/resend-code',
385
+ {
386
+ envelope: input.envelope,
387
+ hold: validatedHold(input.hold),
388
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
389
+ expectedDid: input.expectedDid,
390
+ ...(input.requestId ? { requestId: input.requestId } : {}),
391
+ },
392
+ z.object({ sent: z.literal(true), receipt: AuthorityCommitValidator })
393
+ );
394
+ return { receipt: result.receipt };
395
+ },
396
+ sendReleaseWarning: async (input: SendReleaseWarningRequest) => {
397
+ const result = await call(
398
+ '/v1/send-release-warning',
399
+ {
400
+ holdId: input.holdId,
401
+ expectedDid: input.expectedDid,
402
+ ...(input.requestId ? { requestId: input.requestId } : {}),
244
403
  },
245
- z.object({ ok: z.literal(true) })
404
+ z.object({
405
+ sent: z.literal(true),
406
+ receipt: AuthorityHoldReceiptValidator,
407
+ releaseWarningSentHiMs: safeInteger,
408
+ })
246
409
  );
410
+ return {
411
+ receipt: result.receipt,
412
+ releaseWarningSentAtMs: result.releaseWarningSentHiMs,
413
+ };
247
414
  },
248
415
  releaseEscrow: async (input: ReleaseRequest): Promise<ReleaseResult> => {
249
416
  validatedHold(input.hold);
250
- return call(
417
+ const result = await call(
251
418
  '/v1/release',
252
419
  {
253
420
  envelope: input.envelope,
@@ -255,9 +422,13 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
255
422
  clientEphemeralPublicKey: input.clientEphemeralPublicKey,
256
423
  expectedDid: input.expectedDid,
257
424
  ...(input.pinProof !== undefined ? { pinProof: input.pinProof } : {}),
425
+ ...(input.requestId ? { requestId: input.requestId } : {}),
258
426
  },
259
427
  releaseResponseValidator
260
428
  );
429
+ if (input.hold.hold.policyVersion === 2 && !result.receipt)
430
+ throw new EscrowUnavailableError('invalid_response');
431
+ return result;
261
432
  },
262
433
  };
263
434
  };