@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.
@@ -0,0 +1 @@
1
+ export {};
@@ -251,6 +251,25 @@ export declare const escrowRouter: import("@trpc/server").TRPCBuiltRouter<{
251
251
  } | undefined;
252
252
  requiresConfirmation?: boolean | undefined;
253
253
  pinSalt?: string | undefined;
254
+ codeSent?: boolean | undefined;
255
+ delivery?: {
256
+ codeSent: boolean;
257
+ proof: {
258
+ version: 1;
259
+ keyId: string;
260
+ measurement: number[];
261
+ signature: string;
262
+ kind: "commit";
263
+ payload: string;
264
+ } | {
265
+ version: 1;
266
+ keyId: string;
267
+ measurement: number[];
268
+ payload: string;
269
+ signature: string;
270
+ kind: "snapshot";
271
+ };
272
+ } | undefined;
254
273
  };
255
274
  meta: import("trpc-to-openapi").OpenApiMeta;
256
275
  }>;
@@ -0,0 +1 @@
1
+ export declare const logEscrowFailure: (error: unknown) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -117,6 +117,17 @@ export interface EscrowEnclave {
117
117
  releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
118
118
  createHold(input: EnclaveCreateHoldInput): Promise<{
119
119
  holdRecord: EscrowHoldRecord;
120
+ delivery?: {
121
+ codeSent: boolean;
122
+ proof: AuthorityHoldReceipt;
123
+ };
124
+ }>;
125
+ getHoldState?(input: {
126
+ expectedDid: string;
127
+ hold: EscrowHoldRecord;
128
+ nonce: string;
129
+ }): Promise<{
130
+ proof: AuthorityRecord;
120
131
  }>;
121
132
  cancelHold(input: CancelHoldRequest): Promise<void | {
122
133
  receipt: AuthorityRecord;
@@ -136,6 +147,10 @@ export interface EscrowEnclave {
136
147
  export declare class EscrowPolicyError extends Error {
137
148
  constructor();
138
149
  }
150
+ export declare class EscrowCodeError extends Error {
151
+ readonly reason: 'codeMismatch' | 'codeExpired' | 'codeLocked';
152
+ constructor(reason: 'codeMismatch' | 'codeExpired' | 'codeLocked');
153
+ }
139
154
  export declare class EscrowPinMismatchError extends Error {
140
155
  constructor();
141
156
  }
@@ -143,5 +158,6 @@ export declare class EscrowBlobError extends Error {
143
158
  constructor();
144
159
  }
145
160
  export declare class EscrowUnavailableError extends Error {
146
- constructor();
161
+ readonly reason: 'unavailable' | 'ledger' | 'time' | 'transport' | 'invalid_response' | 'invalid_hold' | 'http_refusal' | 'rate_limit';
162
+ constructor(reason?: 'unavailable' | 'ledger' | 'time' | 'transport' | 'invalid_response' | 'invalid_hold' | 'http_refusal' | 'rate_limit');
147
163
  }
@@ -2,6 +2,7 @@ const path = require('node:path');
2
2
  const fs = require('node:fs');
3
3
 
4
4
  const { sentryEsbuildPlugin } = require('@sentry/esbuild-plugin');
5
+ const { withSentryRateLimitRetry } = require('../../../scripts/sentry-rate-limit-retry.cjs');
5
6
 
6
7
  const forceCjsSodiumWrapperPlugin = {
7
8
  name: 'force-cjs-sodium-wrapper',
@@ -40,10 +41,12 @@ const copyDidkitWasmPlugin = {
40
41
  module.exports = [
41
42
  forceCjsSodiumWrapperPlugin,
42
43
  copyDidkitWasmPlugin,
43
- sentryEsbuildPlugin({
44
- telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
45
- authToken: process.env.SENTRY_AUTH_TOKEN,
46
- org: process.env.SENTRY_ORG,
47
- project: process.env.SENTRY_PROJECT,
48
- }),
44
+ withSentryRateLimitRetry(
45
+ sentryEsbuildPlugin({
46
+ telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
47
+ authToken: process.env.SENTRY_AUTH_TOKEN,
48
+ org: process.env.SENTRY_ORG,
49
+ project: process.env.SENTRY_PROJECT,
50
+ })
51
+ ),
49
52
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/lca-api-service",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -63,21 +63,21 @@
63
63
  "@fastify/formbody": "^8.0.2",
64
64
  "@fastify/rate-limit": "^10.2.0",
65
65
  "@fastify/static": "^10.1.1",
66
- "@learncard/core": "9.4.39",
67
- "@learncard/did-web-plugin": "1.1.39",
68
- "@learncard/didkit-plugin": "1.10.4",
69
- "@learncard/didkit-plugin-node": "0.3.4",
66
+ "@learncard/core": "9.4.40",
67
+ "@learncard/did-web-plugin": "1.1.40",
68
+ "@learncard/didkit-plugin": "1.10.5",
69
+ "@learncard/didkit-plugin-node": "0.3.5",
70
70
  "@learncard/email-templates": "1.2.1",
71
- "@learncard/helpers": "1.6.3",
72
- "@learncard/init": "2.5.4",
73
- "@learncard/types": "5.23.1",
74
- "@learncard/sss-key-manager": "0.2.2",
71
+ "@learncard/helpers": "1.7.0",
72
+ "@learncard/init": "2.5.5",
73
+ "@learncard/types": "5.23.2",
74
+ "@learncard/sss-key-manager": "0.2.3",
75
75
  "@sentry/serverless": "^7.61.0",
76
76
  "@trpc/server": "11.8.0",
77
77
  "@types/jsonwebtoken": "^9.0.8",
78
78
  "@types/lodash": "^4.14.191",
79
79
  "@types/node-forge": "^1.3.11",
80
- "axios": "^0.33.0",
80
+ "axios": "^0.34.0",
81
81
  "dag-jose-utils": "2.0.0",
82
82
  "did-jwt": "5.9.0",
83
83
  "dotenv": "^16.0.3",
@@ -0,0 +1,103 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST,
4
+ SKILL_ICON_MAX_NAMES_PER_REQUEST,
5
+ } from '@learncard/types';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ create: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@environment', () => ({ environment: { OPENAI_API_KEY: 'test-key' } }));
12
+ vi.mock('openai', () => ({
13
+ default: class {
14
+ chat = { completions: { create: mocks.create } };
15
+ },
16
+ }));
17
+ vi.mock('filestack-js', () => ({ init: () => ({}) }));
18
+ vi.mock('@routes', async () => {
19
+ const { initTRPC } = await import('@trpc/server');
20
+ const t = initTRPC.create();
21
+ return { t, didAndChallengeRoute: t.procedure };
22
+ });
23
+
24
+ import { aiRouter } from './ai';
25
+
26
+ const caller = aiRouter.createCaller({ user: { did: 'did:example:123' } } as never);
27
+
28
+ const respondWithIcons = (): void => {
29
+ mocks.create.mockImplementation(async ({ messages }: { messages: { content: string }[] }) => {
30
+ const names = [...messages[1]!.content.matchAll(/^- (.*)$/gm)].map(match => match[1]);
31
+
32
+ return {
33
+ choices: [
34
+ {
35
+ message: {
36
+ content: JSON.stringify({
37
+ items: names.map(name => ({ name, icon: '🎯' })),
38
+ }),
39
+ },
40
+ },
41
+ ],
42
+ };
43
+ });
44
+ };
45
+
46
+ describe('generateSkillIcons input validation', () => {
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ respondWithIcons();
50
+ });
51
+
52
+ it('accepts a batch at both limits', async () => {
53
+ const names = Array.from({ length: SKILL_ICON_MAX_NAMES_PER_REQUEST }, (_, i) =>
54
+ `${i}`.padStart(
55
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST / SKILL_ICON_MAX_NAMES_PER_REQUEST,
56
+ 'x'
57
+ )
58
+ );
59
+
60
+ const result = await caller.generateSkillIcons({ names });
61
+
62
+ expect(Object.keys(result)).toHaveLength(SKILL_ICON_MAX_NAMES_PER_REQUEST);
63
+ expect(mocks.create).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('rejects too many names with BAD_REQUEST before calling the model', async () => {
67
+ const names = Array.from(
68
+ { length: SKILL_ICON_MAX_NAMES_PER_REQUEST + 1 },
69
+ (_, i) => `s${i}`
70
+ );
71
+
72
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
73
+ code: 'BAD_REQUEST',
74
+ });
75
+ expect(mocks.create).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('rejects names totalling more than the character limit with BAD_REQUEST', async () => {
79
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST), 'b'];
80
+
81
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
82
+ code: 'BAD_REQUEST',
83
+ });
84
+ expect(mocks.create).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('rejects a single name over the character limit', async () => {
88
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST + 1)];
89
+
90
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
91
+ code: 'BAD_REQUEST',
92
+ });
93
+ });
94
+
95
+ it('still rejects empty lists and empty names', async () => {
96
+ await expect(caller.generateSkillIcons({ names: [] })).rejects.toMatchObject({
97
+ code: 'BAD_REQUEST',
98
+ });
99
+ await expect(caller.generateSkillIcons({ names: [''] })).rejects.toMatchObject({
100
+ code: 'BAD_REQUEST',
101
+ });
102
+ });
103
+ });
package/src/routes/ai.ts CHANGED
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
 
6
6
  import { t, didAndChallengeRoute } from '@routes';
7
7
  import { TRPCError } from '@trpc/server';
8
+ import { GenerateSkillIconsInputValidator } from '@learncard/types';
8
9
 
9
10
  import * as filestack from 'filestack-js';
10
11
  import { BoostSkillHierarchy, BoostSkillHierarchyValidator } from 'types/skills';
@@ -204,7 +205,7 @@ export const aiRouter = t.router({
204
205
  'Given a list of skill names, returns a mapping of each name to a single unicode emoji that best represents it. No skin tones, flags, or multi-character sequences.',
205
206
  },
206
207
  })
207
- .input(z.object({ names: z.array(z.string().nonempty()).min(1) }))
208
+ .input(GenerateSkillIconsInputValidator)
208
209
  .output(IconMapValidator)
209
210
  .query(async ({ input, ctx }) => {
210
211
  const {
@@ -1,4 +1,5 @@
1
1
  import { timingSafeEqual, randomUUID, randomBytes } from 'crypto';
2
+ import { logEscrowFailure } from '../services/escrow-enclave/logFailure';
2
3
  import {
3
4
  AuthorityCommitValidator,
4
5
  AuthorityHoldReceiptValidator,
@@ -79,6 +80,7 @@ import {
79
80
  getEnclaveAttestationIdentity,
80
81
  notifyEscrowHoldEvent,
81
82
  EscrowPolicyError,
83
+ EscrowCodeError,
82
84
  EscrowBlobError,
83
85
  EscrowUnavailableError,
84
86
  EscrowPinMismatchError,
@@ -247,6 +249,10 @@ const enclaveOperation = async <T>(
247
249
  try {
248
250
  return await operation();
249
251
  } catch (error) {
252
+ logEscrowFailure(error);
253
+ if (error instanceof EscrowCodeError) {
254
+ throw new TRPCError({ code: 'FORBIDDEN', message: error.reason });
255
+ }
250
256
  if (error instanceof EscrowUnavailableError) {
251
257
  throw new TRPCError({
252
258
  code: 'PRECONDITION_FAILED',
@@ -760,6 +766,10 @@ export const escrowRouter = t.router({
760
766
  status: z.literal('pending'),
761
767
  resumeToken: z.string().nullable(),
762
768
  pinSalt: EscrowPinSaltValidator.optional(),
769
+ codeSent: z.boolean().optional(),
770
+ delivery: z
771
+ .object({ codeSent: z.boolean(), proof: AuthorityHoldReceiptValidator })
772
+ .optional(),
763
773
  })
764
774
  )
765
775
  .mutation(async ({ input, ctx }) => {
@@ -872,7 +882,7 @@ export const escrowRouter = t.router({
872
882
  // suppressed entirely rather than racing/duplicating it.
873
883
  const isAuthorityManagedHold =
874
884
  input.releasePolicy === 'hold' && Boolean(getEscrowEnclave().confirmHold);
875
- const { holdRecord } = await enclaveOperation(() =>
885
+ const { holdRecord, delivery } = await enclaveOperation(() =>
876
886
  getEscrowEnclave().createHold({
877
887
  envelope: userKey.escrowBlob!.envelope,
878
888
  holdId: randomUUID(),
@@ -959,6 +969,7 @@ export const escrowRouter = t.router({
959
969
  ...serializeHold(hold),
960
970
  status: 'pending' as const,
961
971
  resumeToken,
972
+ ...(delivery ? { delivery, codeSent: delivery.codeSent } : {}),
962
973
  ...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
963
974
  };
964
975
  }),
@@ -1302,8 +1313,10 @@ export const escrowRouter = t.router({
1302
1313
  };
1303
1314
  } catch (error) {
1304
1315
  // Keep the typed mismatch inside the wrapper; never expose enclave internals.
1305
- if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
1316
+ if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError) {
1317
+ logEscrowFailure(error);
1306
1318
  return { mismatch: true as const };
1319
+ }
1307
1320
  if (reserved && hold.holdRecord.hold.policyVersion !== 2)
1308
1321
  await refundEscrowPinAttempt(
1309
1322
  hold.authProvider,
@@ -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,
@@ -114,6 +115,40 @@ describe('createRemoteEnclave', () => {
114
115
  );
115
116
  });
116
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
+
117
152
  it('sends an empty nonce array when none is given', async () => {
118
153
  const post = vi.fn().mockResolvedValue(respond(200, attestationBody));
119
154
  const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
@@ -305,9 +340,69 @@ describe('createRemoteEnclave', () => {
305
340
  await expect(enclave.releaseEscrow(input)).resolves.toEqual({ sealed: envelope, receipt });
306
341
  expect(post.mock.calls.at(-1)?.[1]).toMatchObject({ requestId: 'stable-id' });
307
342
  post.mockResolvedValue(respond(200, { sealed: envelope }));
308
- await expect(enclave.releaseEscrow(input)).rejects.toBeInstanceOf(EscrowUnavailableError);
343
+ await expect(enclave.releaseEscrow(input)).rejects.toMatchObject({
344
+ reason: 'invalid_response',
345
+ });
309
346
  post.mockResolvedValue(respond(200, { ok: true }));
310
- await expect(enclave.cancelHold(input)).rejects.toBeInstanceOf(EscrowUnavailableError);
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' });
311
406
  });
312
407
 
313
408
  // [A] confirm-hold/send-release-warning: a fresh call returns a signed
@@ -625,7 +720,10 @@ describe('createRemoteEnclave', () => {
625
720
  const created = await enclave.createHold(createInput);
626
721
  expect(created).toEqual({ holdRecord: opaque });
627
722
  const config = { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs };
628
- 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
+ });
629
727
  await expect(
630
728
  enclave.cancelHold({ ...cancelInput, hold: created.holdRecord })
631
729
  ).resolves.toBeUndefined();