@learncard/lca-api-service 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +44 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +23 -1
  5. package/dist/config/cacheEnvironment.d.ts +4 -4
  6. package/dist/config/environment.d.ts +145 -114
  7. package/dist/config/oidcEnvironment.d.ts +22 -22
  8. package/dist/didkit_wasm_bg.wasm +0 -0
  9. package/dist/helpers/bedrockGeneration.d.ts +17 -0
  10. package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
  11. package/dist/helpers/bedrockSchema.d.ts +10 -0
  12. package/dist/helpers/imageGeneration.d.ts +42 -0
  13. package/dist/helpers/imageGeneration.test.d.ts +1 -0
  14. package/dist/index.js +2 -2
  15. package/dist/models/Notification.d.ts +3 -1
  16. package/dist/publish-metafile.json +1 -1
  17. package/dist/routes/ai.skill-icons.test.d.ts +1 -0
  18. package/dist/routes/ai.test.d.ts +1 -0
  19. package/dist/routes/escrow.d.ts +19 -0
  20. package/dist/routes/keys.d.ts +1 -0
  21. package/dist/routes/notifications.d.ts +3 -1
  22. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  23. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  24. package/dist/services/escrow-enclave/types.d.ts +17 -1
  25. package/dist/types/notifications.d.ts +3 -1
  26. package/docs/bedrock-generation.md +119 -0
  27. package/docs/image-generation.md +120 -0
  28. package/esbuildPlugins.cjs +9 -6
  29. package/package.json +21 -16
  30. package/scripts/node24-runtime.cjs +9 -0
  31. package/scripts/verify-node24-layer.cjs +49 -0
  32. package/serverless-local.yml +3 -1
  33. package/serverless.function-env.cjs +2 -4
  34. package/serverless.yml +76 -3
  35. package/src/config/environment.ts +14 -0
  36. package/src/helpers/bedrockGeneration.test.ts +222 -0
  37. package/src/helpers/bedrockGeneration.ts +110 -0
  38. package/src/helpers/bedrockSchema.ts +352 -0
  39. package/src/helpers/imageGeneration.test.ts +456 -0
  40. package/src/helpers/imageGeneration.ts +432 -0
  41. package/src/routes/ai.skill-icons.test.ts +92 -0
  42. package/src/routes/ai.test.ts +80 -0
  43. package/src/routes/ai.ts +33 -144
  44. package/src/routes/escrow.ts +15 -2
  45. package/src/routes/keys.ts +18 -0
  46. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  47. package/src/services/escrow-enclave/logFailure.test.ts +72 -0
  48. package/src/services/escrow-enclave/logFailure.ts +46 -0
  49. package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
  50. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
  51. package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
  52. package/src/services/escrow-enclave/types.ts +26 -2
  53. package/test/keys-auth-share-escrow.spec.ts +103 -0
  54. package/test/keys-escrow.spec.ts +65 -0
  55. package/test/oidc.spec.ts +41 -2
  56. package/tsconfig.bedrock.json +10 -0
package/src/routes/ai.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { environment } from '@environment';
2
2
  import OpenAI from 'openai';
3
- import { zodResponseFormat } from 'openai/helpers/zod';
3
+ import { createBedrockClient, generateStructuredOutput } from '@helpers/bedrockGeneration';
4
+ import { generateStoredImage } from '@helpers/imageGeneration';
4
5
  import { z } from 'zod';
5
6
 
6
7
  import { t, didAndChallengeRoute } from '@routes';
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';
@@ -79,7 +80,7 @@ const IconMapValidator = z.record(
79
80
  z.string().min(1).max(8)
80
81
  );
81
82
 
82
- // LLM output schema: object-wrapped list to satisfy response_format type 'object'
83
+ // Required-function output schema: object-wrapped list.
83
84
  // We'll convert this into the IconMapValidator shape before returning
84
85
  const IconListContainerValidator = z.object({
85
86
  items: z.array(
@@ -94,6 +95,10 @@ const openai = environment.OPENAI_API_KEY
94
95
  ? new OpenAI({ apiKey: environment.OPENAI_API_KEY })
95
96
  : undefined;
96
97
 
98
+ let generationClient: OpenAI | undefined;
99
+ const getGenerationClient = (): OpenAI =>
100
+ (generationClient ??= createBedrockClient(environment.BEDROCK_BASE_URL));
101
+
97
102
  export const aiRouter = t.router({
98
103
  generateBoostInfo: didAndChallengeRoute
99
104
  .meta({
@@ -109,41 +114,19 @@ export const aiRouter = t.router({
109
114
  })
110
115
  .input(z.object({ description: z.string().nonempty(), locale: z.string().optional() }))
111
116
  .output(AIResponseValidator)
112
- .query(async ({ input, ctx }) => {
113
- const {
114
- user: { did },
115
- } = ctx;
116
- if (!openai) {
117
- throw new TRPCError({
118
- code: 'INTERNAL_SERVER_ERROR',
119
- message: 'No OpenAI Key Set',
120
- });
121
- }
122
-
117
+ .query(async ({ input }) => {
123
118
  const { description } = input;
124
119
 
125
- const completion = await openai.chat.completions.create({
126
- model: 'gpt-4o-2024-08-06',
127
- messages: [
128
- {
129
- role: 'system',
130
- content:
131
- `Generate a title ${aiTitleCharsetRule(
132
- input.locale
133
- )} with a maximum length of 24 characters and separate words with spaces if there is more than one, description, category, type, and narrative for a boost based on an arbitrary description. The narrative is no longer than 300 characters and answers the question "How do you earn this boost?"\n\nStrict constraints:\n- category MUST be EXACTLY one of: "Social Badge", "Achievement", "Course", "ID", "Work History", "Learning History", "Accomplishment", "Accommodation". Use exact casing and spelling; do NOT invent other categories.\n- type MUST be EXACTLY one of: "Certificate", "Badge", "ID". Use exact casing and spelling; do NOT invent other types.\n\nOutput requirements:\n- Return ONLY a JSON object with the keys "title", "description", "category", "type", and optional "narrative".\n- Do not include explanations, markdown, or extra fields.\n- Ensure the JSON parses without code fences.` +
134
- aiLocaleInstruction(input.locale),
135
- },
136
- { role: 'user', content: description },
137
- ],
138
- // response_format: zodResponseFormat(AIResponseValidator, 'info'),
139
- response_format: { type: 'json_object' },
140
- user: did,
120
+ return generateStructuredOutput(getGenerationClient(), {
121
+ task: 'reasoning',
122
+ instructions:
123
+ `Generate a title ${aiTitleCharsetRule(
124
+ input.locale
125
+ )} with a maximum length of 24 characters and separate words with spaces if there is more than one, description, category, type, and narrative for a boost based on an arbitrary description. The narrative is no longer than 300 characters and answers the question "How do you earn this boost?"\n\nStrict constraints:\n- category MUST be EXACTLY one of: "Social Badge", "Achievement", "Course", "ID", "Work History", "Learning History", "Accomplishment", "Accommodation". Use exact casing and spelling; do NOT invent other categories.\n- type MUST be EXACTLY one of: "Certificate", "Badge", "ID". Use exact casing and spelling; do NOT invent other types.\n\nOutput requirements:\n- Return ONLY a JSON object with the keys "title", "description", "category", "type", and "narrative".\n- Do not include explanations, markdown, or extra fields.\n- Ensure the JSON parses without code fences.` +
126
+ aiLocaleInstruction(input.locale),
127
+ input: description,
128
+ schema: AIResponseValidator,
141
129
  });
142
-
143
- const content = completion.choices[0]?.message.content ?? '';
144
- const response = JSON.parse(content);
145
-
146
- return AIResponseValidator.parseAsync(response);
147
130
  }),
148
131
 
149
132
  generateBoostSkills: didAndChallengeRoute
@@ -159,36 +142,15 @@ export const aiRouter = t.router({
159
142
  })
160
143
  .input(z.object({ description: z.string().nonempty() }))
161
144
  .output(z.array(BoostSkillsValidator))
162
- .query(async ({ input, ctx }) => {
163
- const {
164
- user: { did },
165
- } = ctx;
166
- if (!openai) {
167
- throw new TRPCError({
168
- code: 'INTERNAL_SERVER_ERROR',
169
- message: 'No OpenAI Key Set',
170
- });
171
- }
172
-
145
+ .query(async ({ input }) => {
173
146
  const { description } = input;
174
147
 
175
- const completion = await openai.chat.completions.create({
176
- model: 'gpt-4o-2024-08-06',
177
- messages: [
178
- {
179
- role: 'system',
180
- content: `Generate *NO MORE THAN 3* skills for the provided description about a boost that matches the given skills hierarchy. ${aiSkillsHierarchyRule}`,
181
- },
182
- { role: 'user', content: description },
183
- ],
184
- response_format: zodResponseFormat(BoostSkillHierarchyValidator, 'skills'),
185
- user: did,
148
+ const parsedResponse = await generateStructuredOutput(getGenerationClient(), {
149
+ task: 'reasoning',
150
+ instructions: `Generate *NO MORE THAN 3* skills for the provided description about a boost that matches the given skills hierarchy. ${aiSkillsHierarchyRule}`,
151
+ input: description,
152
+ schema: BoostSkillHierarchyValidator,
186
153
  });
187
-
188
- const response = JSON.parse(completion.choices[0]?.message.content ?? '');
189
-
190
- const parsedResponse = await BoostSkillHierarchyValidator.parseAsync(response);
191
-
192
154
  return transformLLMResultToOutputShape(parsedResponse);
193
155
  }),
194
156
 
@@ -204,20 +166,9 @@ export const aiRouter = t.router({
204
166
  '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
167
  },
206
168
  })
207
- .input(z.object({ names: z.array(z.string().nonempty()).min(1) }))
169
+ .input(GenerateSkillIconsInputValidator)
208
170
  .output(IconMapValidator)
209
- .query(async ({ input, ctx }) => {
210
- const {
211
- user: { did },
212
- } = ctx;
213
-
214
- if (!openai) {
215
- throw new TRPCError({
216
- code: 'INTERNAL_SERVER_ERROR',
217
- message: 'No OpenAI Key Set',
218
- });
219
- }
220
-
171
+ .query(async ({ input }) => {
221
172
  const { names } = input;
222
173
 
223
174
  const systemPrompt = `You are assigning a single, standard Unicode emoji to represent each skill name.
@@ -233,42 +184,12 @@ Rules:
233
184
  '\n'
234
185
  )}\n\nReturn a JSON object of the form { items: [{ name, icon }] } for these names.`;
235
186
 
236
- // TODO switch to gpt 5 and use responses API
237
- // will have to bump the openai package
238
- let completion: any;
239
- try {
240
- completion = await openai.chat.completions.create({
241
- model: 'gpt-4o-2024-08-06',
242
- messages: [
243
- {
244
- role: 'system',
245
- content: systemPrompt,
246
- },
247
- { role: 'user', content: userContent },
248
- ],
249
- // Use the object-wrapped list schema for structured outputs
250
- // currently breaks because response is wrapped in a code fence (```json ... ```)
251
- // likely would need to to bump openai package version
252
- // response_format: zodResponseFormat(IconListContainerValidator, 'icons'),
253
- response_format: { type: 'json_object' },
254
- user: did,
255
- });
256
- } catch (error) {
257
- console.error('🔥🔥🔥🔥🔥🔥🔥🔥🔥');
258
- console.error('error:', error);
259
- }
260
-
261
- const content = completion.choices[0]?.message.content ?? '';
262
-
263
- const response = JSON.parse(content);
264
- // First, validate the model output with the object-wrapped list schema
265
- const parsed = await IconListContainerValidator.parseAsync(response);
266
- if (!parsed) {
267
- throw new TRPCError({
268
- code: 'INTERNAL_SERVER_ERROR',
269
- message: 'No parsed structured output returned.',
270
- });
271
- }
187
+ const parsed = await generateStructuredOutput(getGenerationClient(), {
188
+ task: 'formatting',
189
+ instructions: systemPrompt,
190
+ input: userContent,
191
+ schema: IconListContainerValidator,
192
+ });
272
193
 
273
194
  // Transform into a record mapping input names to icons
274
195
  const record: Record<string, string> = {};
@@ -306,39 +227,7 @@ Rules:
306
227
  const {
307
228
  user: { did },
308
229
  } = ctx;
309
- if (!openai) {
310
- throw new TRPCError({
311
- code: 'INTERNAL_SERVER_ERROR',
312
- message: 'No OpenAI Key Set',
313
- });
314
- }
315
-
316
- const { prompt } = input;
317
- const res = await openai.images.generate({
318
- prompt,
319
- model: 'dall-e-3',
320
- size: '1024x1024',
321
- user: did,
322
- });
323
-
324
- if (!res.data || !res.data[0]?.url) {
325
- throw new TRPCError({
326
- code: 'INTERNAL_SERVER_ERROR',
327
- message: 'No image URL returned from OpenAI',
328
- });
329
- }
330
-
331
- const filestackRes = (await client.storeURL(res?.data[0]?.url)) as any;
332
- if (!filestackRes || !filestackRes.url) {
333
- throw new TRPCError({
334
- code: 'INTERNAL_SERVER_ERROR',
335
- message: 'Failed to store image URL',
336
- });
337
- }
338
-
339
- return {
340
- url: filestackRes.url,
341
- };
230
+ return generateStoredImage(input.prompt, did, environment, openai, client);
342
231
  }),
343
232
  });
344
233
 
@@ -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,
@@ -643,6 +643,13 @@ export const keysRouter = t.router({
643
643
  escrowOptedOut: z.boolean(),
644
644
  escrowPin: EscrowPinStatusValidator,
645
645
  escrowStale: z.enum(['mode-mismatch', 'key-rotated']).optional(),
646
+ /**
647
+ * The account has an escrow recovery method (any confirmation state)
648
+ * but none confirmed at the current share version, e.g. `pending`
649
+ * after an identity-recovery rebind. A hint only; the client still
650
+ * proves prior enrollment against the signed escrow authority.
651
+ */
652
+ priorEscrowEnrollment: z.boolean(),
646
653
  sssActivationState: z.enum(['provisional', 'active']),
647
654
  })
648
655
  .nullable()
@@ -702,6 +709,10 @@ export const keysRouter = t.router({
702
709
  }
703
710
  : {}),
704
711
  }));
712
+ const currentVersion = userKey.shareVersion ?? 1;
713
+ const escrowMethods = (userKey.recoveryMethods ?? []).filter(
714
+ rm => rm?.type === 'escrow'
715
+ );
705
716
 
706
717
  return {
707
718
  authShare,
@@ -720,6 +731,13 @@ export const keysRouter = t.router({
720
731
  escrowStale: userKey.escrowBlob
721
732
  ? await getEscrowBlobStaleReason(userKey.escrowBlob)
722
733
  : undefined,
734
+ priorEscrowEnrollment:
735
+ escrowMethods.length > 0 &&
736
+ !escrowMethods.some(
737
+ rm =>
738
+ rm.shareVersion === currentVersion &&
739
+ isRecoveryMethodConfirmed(userKey, rm)
740
+ ),
723
741
  };
724
742
  }),
725
743
 
@@ -27,6 +27,25 @@ MAoGCCqGSM49BAMDA2cAMGQCMGp/wTQLmtB6/GK6hMFaFTA27gXZcxrgJ0Uy+8C0
27
27
  Ztl5uoRwN99inHNWv1cnKGdNQgIwSoQgKs8uMnBUcfiB9ZR6xb98z/9athp5pDIE
28
28
  /KUv6cNmJtuCgPOJxC+4fVKl8xC3
29
29
  -----END CERTIFICATE-----
30
+ `,
31
+ // Production CA, valid until 2029-10-26. SHA-256 C017B1482D96679D6F725F8A46A52DAC
32
+ // CE9921AB47B4FEDFDB4C814B38584859; source: SSM
33
+ // /learncard/escrow-enclave/production/ca-cert in account 934654685669.
34
+ 'escrow-enclave.production.internal': `-----BEGIN CERTIFICATE-----
35
+ MIICaDCCAe6gAwIBAgIUCMYQNgdrxBILfOeoVNOWuZBiG1cwCgYIKoZIzj0EAwMw
36
+ VzEkMCIGA1UECgwbTGVhcm5pbmcgRWNvbm9teSBGb3VuZGF0aW9uMS8wLQYDVQQD
37
+ DCZMZWFybkNhcmQgRXNjcm93IEVuY2xhdmUgUHJvZHVjdGlvbiBDQTAeFw0yNjEw
38
+ MjcxOTE5MzBaFw0yOTEwMjYxOTE5MzBaMFcxJDAiBgNVBAoMG0xlYXJuaW5nIEVj
39
+ b25vbXkgRm91bmRhdGlvbjEvMC0GA1UEAwwmTGVhcm5DYXJkIEVzY3JvdyBFbmNs
40
+ YXZlIFByb2R1Y3Rpb24gQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARE/z2wlD3g
41
+ oj5XyHZEJOjdffpvXHav9802AHod0h7QQP4NCLpw4Aofhx6+Qrx/le8KUGdu63V7
42
+ 948yzbrvW08XL20lmd2kFkb/23589kOzaTPeE1Ngs2y08n5cAkCdxWOjezB5MBIG
43
+ A1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMDQGA1UdHgEB/wQqMCig
44
+ JjAkgiJlc2Nyb3ctZW5jbGF2ZS5wcm9kdWN0aW9uLmludGVybmFsMB0GA1UdDgQW
45
+ BBTHUF7Yd+mLuA2XOthizgvTc8WW3zAKBggqhkjOPQQDAwNoADBlAjEAiFXqrpcc
46
+ nR6ttLI4EEZ5qLMMGTksNypiTa+WyiUmJCtW3o6Luj9o/wlFXsFParINAjA8zeDx
47
+ b1+05o+sCl0kQzEq1b2pN9/Xco5NQpMDfG2/BvKuSOVLhOQ1tdkZLo+uTgk=
48
+ -----END CERTIFICATE-----
30
49
  `,
31
50
  };
32
51
 
@@ -0,0 +1,72 @@
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 log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
11
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
12
+ const error = new EscrowUnavailableError('invalid_hold');
13
+ error.message = 'private hold contents';
14
+ logEscrowFailure(error);
15
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
16
+ reason: 'invalid_hold',
17
+ });
18
+ const metric = JSON.parse(String(log.mock.calls[0]?.[0]));
19
+ expect(metric).toMatchObject({ Reason: 'invalid_hold', EnclaveFailure: 1 });
20
+ expect(metric._aws.CloudWatchMetrics[0]).toMatchObject({
21
+ Namespace: 'LearnCard/Escrow',
22
+ Dimensions: [['Reason']],
23
+ });
24
+ expect(JSON.stringify(log.mock.calls)).not.toContain('private hold contents');
25
+ });
26
+ it.each([
27
+ [403, 'policy', 'policy'],
28
+ [403, 'pinMismatch', 'pin_mismatch'],
29
+ [403, 'codeMismatch', 'code_mismatch'],
30
+ [403, 'codeExpired', 'code_expired'],
31
+ [403, 'codeLocked', 'code_locked'],
32
+ [403, 'blob', 'blob'],
33
+ [503, 'ledger', 'ledger'],
34
+ [503, 'time', 'time'],
35
+ [412, 'unavailable', 'unavailable'],
36
+ [403, 'secret-unknown-code', 'http_refusal'],
37
+ [429, 'unknown', 'rate_limit'],
38
+ [200, 'unknown', 'invalid_response'],
39
+ ])('classifies HTTP %s / %s without logging payloads', async (status, code, reason) => {
40
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
41
+ const enclave = createRemoteEnclave({
42
+ baseUrl: 'https://example.invalid',
43
+ token: 'secret-token',
44
+ timeoutMs: 100,
45
+ transport: {
46
+ post: async () => ({ status, data: { code, message: 'secret@example.com' } }),
47
+ },
48
+ });
49
+ await enclave.getAttestation().catch(logEscrowFailure);
50
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
51
+ reason,
52
+ });
53
+ });
54
+
55
+ it('drops transport error details', async () => {
56
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
57
+ const enclave = createRemoteEnclave({
58
+ baseUrl: 'https://example.invalid',
59
+ token: 'secret-token',
60
+ timeoutMs: 100,
61
+ transport: {
62
+ post: async () => {
63
+ throw new Error('secret-token');
64
+ },
65
+ },
66
+ });
67
+ await enclave.getAttestation().catch(logEscrowFailure);
68
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
69
+ reason: 'transport',
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,46 @@
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
+ // One EMF event alongside the existing warning; no error text, request or identity fields.
30
+ console.log(
31
+ JSON.stringify({
32
+ _aws: {
33
+ Timestamp: Date.now(),
34
+ CloudWatchMetrics: [
35
+ {
36
+ Namespace: 'LearnCard/Escrow',
37
+ Dimensions: [['Reason']],
38
+ Metrics: [{ Name: 'EnclaveFailure', Unit: 'Count' }],
39
+ },
40
+ ],
41
+ },
42
+ Reason: reason,
43
+ EnclaveFailure: 1,
44
+ })
45
+ );
46
+ };
@@ -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();