@learncard/lca-api-service 1.5.2 → 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.
@@ -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,4 +1,5 @@
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';
4
5
  import {
@@ -16,6 +17,7 @@ import {
16
17
  import {
17
18
  EscrowBlobError,
18
19
  EscrowPinMismatchError,
20
+ EscrowCodeError,
19
21
  EscrowPolicyError,
20
22
  EscrowUnavailableError,
21
23
  type CarryPinVerifierInput,
@@ -91,15 +93,18 @@ const holdRecordValidator = z
91
93
  ledgerSeq: z.number().int().nonnegative(),
92
94
  })
93
95
  .passthrough();
94
- const validatedHold = (hold: ReleaseRequest['hold']): ReleaseRequest['hold'] => {
95
- 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);
96
101
  if (hold.hold.policyVersion === 2) {
97
102
  try {
98
103
  AuthorityCommitValidator.parse(
99
104
  JSON.parse(Buffer.from(hold.hold.signature, 'base64').toString('utf8'))
100
105
  );
101
106
  } catch {
102
- throw new EscrowUnavailableError();
107
+ throw new EscrowUnavailableError(reason);
103
108
  }
104
109
  }
105
110
  return hold;
@@ -112,6 +117,9 @@ const releaseResponseValidator = z.object({
112
117
  const errorCodeValidator = z.enum([
113
118
  'policy',
114
119
  'pinMismatch',
120
+ 'codeMismatch',
121
+ 'codeExpired',
122
+ 'codeLocked',
115
123
  'blob',
116
124
  'unavailable',
117
125
  'ledger',
@@ -122,8 +130,10 @@ const errorResponseValidator = z.object({ code: errorCodeValidator, message: z.s
122
130
  const errorForCode = (code: z.infer<typeof errorCodeValidator>): Error => {
123
131
  if (code === 'policy') return new EscrowPolicyError();
124
132
  if (code === 'pinMismatch') return new EscrowPinMismatchError();
133
+ if (code === 'codeMismatch' || code === 'codeExpired' || code === 'codeLocked')
134
+ return new EscrowCodeError(code);
125
135
  if (code === 'blob') return new EscrowBlobError();
126
- return new EscrowUnavailableError(); // 'unavailable' | 'ledger' | 'time'
136
+ return new EscrowUnavailableError(code); // 'unavailable' | 'ledger' | 'time'
127
137
  };
128
138
 
129
139
  export interface RemoteEnclaveTransportResponse {
@@ -141,15 +151,57 @@ export interface RemoteEnclaveTransport {
141
151
 
142
152
  /** With `caPem`, the connection trusts only that CA (system roots are not consulted). */
143
153
  export const createHttpTransport = (caPem?: string): RemoteEnclaveTransport => {
144
- 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 });
145
158
  return {
146
159
  post: async (url, body, config) => {
147
- const response = await axios.post(url, body, {
148
- ...config,
149
- httpsAgent,
150
- validateStatus: () => true,
151
- });
152
- 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
+ }
153
205
  },
154
206
  };
155
207
  };
@@ -183,21 +235,24 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
183
235
  let data: unknown;
184
236
  try {
185
237
  const response = await transport.post(`${config.baseUrl}${path}`, body, {
186
- headers: { Authorization: `Bearer ${config.token}` },
238
+ headers: {
239
+ Authorization: `Bearer ${config.token}`,
240
+ ...(path === '/v1/create-hold' ? { 'X-Escrow-Delivery-Evidence': '1' } : {}),
241
+ },
187
242
  timeout: config.timeoutMs,
188
243
  });
189
244
  status = response.status;
190
245
  data = response.data;
191
246
  } catch {
192
- throw new EscrowUnavailableError();
247
+ throw new EscrowUnavailableError('transport');
193
248
  }
194
249
  if (status < 200 || status >= 300) {
195
250
  const parsedError = errorResponseValidator.safeParse(data);
196
251
  if (parsedError.success) throw errorForCode(parsedError.data.code);
197
- throw new EscrowUnavailableError();
252
+ throw new EscrowUnavailableError(status === 429 ? 'rate_limit' : 'http_refusal');
198
253
  }
199
254
  const parsed = validator.safeParse(data);
200
- if (!parsed.success) throw new EscrowUnavailableError();
255
+ if (!parsed.success) throw new EscrowUnavailableError('invalid_response');
201
256
  return parsed.data;
202
257
  };
203
258
 
@@ -217,7 +272,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
217
272
  result.enrollmentEpoch !== input.signedIntent.intent.enrollmentEpoch ||
218
273
  (result.envelope === null) !== (input.signedIntent.intent.action === 'disable')
219
274
  )
220
- throw new EscrowUnavailableError();
275
+ throw new EscrowUnavailableError('invalid_response');
221
276
  return result;
222
277
  },
223
278
  getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
@@ -258,26 +313,36 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
258
313
  z.object({ result: AuthorityMutationValidator }).strict()
259
314
  );
260
315
  if (!result.envelope || result.enrollmentEpoch !== input.sourceEnrollmentEpoch)
261
- throw new EscrowUnavailableError();
316
+ throw new EscrowUnavailableError('invalid_response');
262
317
  return { envelope: result.envelope, authority: result };
263
318
  },
264
- createHold: async input => ({
265
- holdRecord: validatedHold(
266
- await call(
267
- '/v1/create-hold',
268
- {
269
- envelope: input.envelope,
270
- holdId: input.holdId,
271
- expectedDid: input.expectedDid,
272
- expectedShareVersion: input.expectedShareVersion,
273
- enrollmentEpoch: input.enrollmentEpoch,
274
- releasePolicy: input.releasePolicy,
275
- clientEphemeralPublicKey: input.clientEphemeralPublicKey,
276
- },
277
- holdRecordValidator
278
- )
279
- ),
280
- }),
319
+ createHold: async input => {
320
+ const result = await call(
321
+ '/v1/create-hold',
322
+ {
323
+ envelope: input.envelope,
324
+ holdId: input.holdId,
325
+ expectedDid: input.expectedDid,
326
+ expectedShareVersion: input.expectedShareVersion,
327
+ enrollmentEpoch: input.enrollmentEpoch,
328
+ releasePolicy: input.releasePolicy,
329
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
330
+ },
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 })),
281
346
  cancelHold: async input => {
282
347
  const result = await call(
283
348
  '/v1/cancel-hold',
@@ -291,7 +356,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
291
356
  z.object({ ok: z.literal(true), receipt: AuthorityCommitValidator.optional() })
292
357
  );
293
358
  if (input.hold.hold.policyVersion === 2 && !result.receipt)
294
- throw new EscrowUnavailableError();
359
+ throw new EscrowUnavailableError('invalid_response');
295
360
  if (result.receipt) return { receipt: result.receipt };
296
361
  return undefined;
297
362
  },
@@ -362,7 +427,7 @@ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave
362
427
  releaseResponseValidator
363
428
  );
364
429
  if (input.hold.hold.policyVersion === 2 && !result.receipt)
365
- throw new EscrowUnavailableError();
430
+ throw new EscrowUnavailableError('invalid_response');
366
431
  return result;
367
432
  },
368
433
  };
@@ -127,7 +127,15 @@ export interface EscrowEnclave {
127
127
  input: RewrapEscrowBlobInput
128
128
  ): Promise<{ envelope: EscrowEnvelope; authority?: AuthorityMutationResult }>;
129
129
  releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
130
- createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }>;
130
+ createHold(input: EnclaveCreateHoldInput): Promise<{
131
+ holdRecord: EscrowHoldRecord;
132
+ delivery?: { codeSent: boolean; proof: AuthorityHoldReceipt };
133
+ }>;
134
+ getHoldState?(input: {
135
+ expectedDid: string;
136
+ hold: EscrowHoldRecord;
137
+ nonce: string;
138
+ }): Promise<{ proof: AuthorityRecord }>;
131
139
  cancelHold(input: CancelHoldRequest): Promise<void | { receipt: AuthorityRecord }>;
132
140
  /** P9.1 (attack C): authority (nitro) mode only — software mode never
133
141
  * produces an awaiting-confirmation hold, so it has nothing to confirm.
@@ -150,6 +158,12 @@ export class EscrowPolicyError extends Error {
150
158
  this.name = 'EscrowPolicyError';
151
159
  }
152
160
  }
161
+ export class EscrowCodeError extends Error {
162
+ constructor(public readonly reason: 'codeMismatch' | 'codeExpired' | 'codeLocked') {
163
+ super(reason);
164
+ this.name = 'EscrowCodeError';
165
+ }
166
+ }
153
167
  export class EscrowPinMismatchError extends Error {
154
168
  constructor() {
155
169
  super('Incorrect PIN.');
@@ -163,7 +177,17 @@ export class EscrowBlobError extends Error {
163
177
  }
164
178
  }
165
179
  export class EscrowUnavailableError extends Error {
166
- constructor() {
180
+ constructor(
181
+ readonly reason:
182
+ | 'unavailable'
183
+ | 'ledger'
184
+ | 'time'
185
+ | 'transport'
186
+ | 'invalid_response'
187
+ | 'invalid_hold'
188
+ | 'http_refusal'
189
+ | 'rate_limit' = 'unavailable'
190
+ ) {
167
191
  super('Escrow recovery is not available.');
168
192
  this.name = 'EscrowUnavailableError';
169
193
  }
@@ -48,6 +48,7 @@ import {
48
48
  getEscrowEnclave,
49
49
  getEscrowBlobStaleReason,
50
50
  EscrowPolicyError,
51
+ EscrowCodeError,
51
52
  EscrowBlobError,
52
53
  EscrowPinMismatchError,
53
54
  EscrowUnavailableError,
@@ -419,6 +420,26 @@ describe('authority enrollment persistence', () => {
419
420
  expect(stored?.requiresConfirmation).toBe(true);
420
421
  expect(stored?.startedNotificationPending).toBe(false);
421
422
  });
423
+ it('persists an awaiting-confirmation hold even when code delivery failed', async () => {
424
+ await enroll();
425
+ const enclave = getEscrowEnclave();
426
+ const create = enclave.createHold.bind(enclave);
427
+ const delivery = { codeSent: false, proof: { ...receipt, kind: 'snapshot' as const } };
428
+ vi.spyOn(enclave, 'createHold').mockImplementation(async input => ({
429
+ ...(await create(input)),
430
+ delivery,
431
+ }));
432
+ __setEscrowEnclaveForTests(
433
+ Object.assign(enclave, { confirmHold: async () => ({ success: true as const }) })
434
+ );
435
+ const started = await start();
436
+ expect(started.codeSent).toBe(false);
437
+ expect(started.delivery).toEqual(delivery);
438
+ const stored = await findEscrowHoldById(started.holdId);
439
+ expect(stored?.holdRecord).toEqual(started.holdRecord);
440
+ expect(stored?.requiresConfirmation).toBe(true);
441
+ expect(stored?.confirmedAt).toBeUndefined();
442
+ });
422
443
  it('persists a cancellation commit receipt before clearing the durable pending marker', async () => {
423
444
  await enroll();
424
445
  const started = await start();
@@ -1464,6 +1485,27 @@ describe('P2-c confirmRecovery reconciliation', () => {
1464
1485
  }));
1465
1486
  beforeEach(() => idempotentConfirmHold.mockClear());
1466
1487
 
1488
+ it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
1489
+ 'returns sanitized confirmation reason %s without confirming',
1490
+ async reason => {
1491
+ const { hold, resumeToken } = await createConfirmableHold();
1492
+ const enclave = getEscrowEnclave();
1493
+ enclave.confirmHold = vi.fn().mockRejectedValue(new EscrowCodeError(reason));
1494
+ try {
1495
+ await expect(
1496
+ getClient().escrow.confirmRecovery({
1497
+ holdId: hold._id,
1498
+ resumeToken,
1499
+ code: '12345678',
1500
+ })
1501
+ ).rejects.toMatchObject({ code: 'FORBIDDEN', message: reason });
1502
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
1503
+ } finally {
1504
+ delete enclave.confirmHold;
1505
+ }
1506
+ }
1507
+ );
1508
+
1467
1509
  it('allows the SDK commit-to-v2-snapshot readback without a second persistence or deadline shift', async () => {
1468
1510
  const { hold, resumeToken } = await createConfirmableHold();
1469
1511
  const snapshotReceipt = {
@@ -1908,10 +1950,14 @@ describe('escrow PIN release', () => {
1908
1950
  it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
1909
1951
  await enrollPin();
1910
1952
  const first = await startPin();
1953
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
1911
1954
  await expect(completePin(first, wrongProof)).rejects.toMatchObject({
1912
1955
  code: 'FORBIDDEN',
1913
1956
  message: 'Incorrect PIN. 9 attempts left.',
1914
1957
  });
1958
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
1959
+ reason: 'pin_mismatch',
1960
+ });
1915
1961
  expect(await findEscrowHoldById(first.holdId)).toMatchObject({
1916
1962
  status: 'cancelled',
1917
1963
  cancelledBy: 'system',
@@ -2259,6 +2305,25 @@ describe('escrow PIN release', () => {
2259
2305
  expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
2260
2306
  });
2261
2307
 
2308
+ it('logs an unexpected non-PIN mismatch once without refunding a PIN attempt', async () => {
2309
+ await enroll();
2310
+ const hold = await start();
2311
+ advanceToRelease(hold);
2312
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
2313
+ const refund = vi.spyOn(models, 'refundEscrowPinAttempt');
2314
+ vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(
2315
+ new EscrowPinMismatchError()
2316
+ );
2317
+ await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
2318
+ code: 'INTERNAL_SERVER_ERROR',
2319
+ });
2320
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
2321
+ reason: 'pin_mismatch',
2322
+ });
2323
+ expect(refund).not.toHaveBeenCalled();
2324
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({ status: 'pending' });
2325
+ });
2326
+
2262
2327
  it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
2263
2328
  'refunds non-mismatch enclave failures: %s',
2264
2329
  async error => {