@learncard/lca-api-service 1.5.3 → 1.6.1

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 (51) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +40 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +4 -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/keys.d.ts +1 -0
  19. package/dist/routes/notifications.d.ts +3 -1
  20. package/dist/types/notifications.d.ts +3 -1
  21. package/docs/bedrock-generation.md +119 -0
  22. package/docs/image-generation.md +120 -0
  23. package/package.json +21 -16
  24. package/project.json +1 -1
  25. package/scripts/node24-runtime.cjs +9 -0
  26. package/scripts/verify-node24-layer.cjs +49 -0
  27. package/serverless-local.yml +3 -1
  28. package/serverless.function-env.cjs +2 -4
  29. package/serverless.yml +76 -3
  30. package/src/config/environment.ts +14 -0
  31. package/src/helpers/bedrockGeneration.test.ts +222 -0
  32. package/src/helpers/bedrockGeneration.ts +110 -0
  33. package/src/helpers/bedrockSchema.ts +352 -0
  34. package/src/helpers/imageGeneration.test.ts +456 -0
  35. package/src/helpers/imageGeneration.ts +432 -0
  36. package/src/models/EscrowHold.ts +6 -3
  37. package/src/models/UserKey.ts +14 -0
  38. package/src/routes/ai.skill-icons.test.ts +92 -0
  39. package/src/routes/ai.test.ts +68 -91
  40. package/src/routes/ai.ts +31 -143
  41. package/src/routes/keys.ts +18 -0
  42. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  43. package/src/services/escrow-enclave/logFailure.test.ts +8 -0
  44. package/src/services/escrow-enclave/logFailure.ts +17 -0
  45. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +10 -0
  46. package/test/jobs/escrowHoldReminders.spec.ts +88 -4
  47. package/test/keys-auth-share-escrow.spec.ts +103 -0
  48. package/test/keys-lifecycle-invariants.spec.ts +76 -0
  49. package/test/oidc.spec.ts +41 -2
  50. package/tsconfig.bedrock.json +10 -0
  51. package/tsconfig.json +13 -14
@@ -1,103 +1,80 @@
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
- },
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { z } from 'zod';
3
+
4
+ const { generate } = vi.hoisted(() => ({ generate: vi.fn() }));
5
+ vi.mock('@environment', () => ({ environment: {} }));
6
+ vi.mock('filestack-js', () => ({ init: vi.fn() }));
7
+ vi.mock('@helpers/bedrockGeneration', () => ({
8
+ createBedrockClient: vi.fn(() => ({})),
9
+ generateStructuredOutput: generate,
16
10
  }));
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 };
11
+ // Capture route resolvers without booting Mongo, Redis, Firebase or the HTTP server.
12
+ vi.mock('@routes', () => {
13
+ const procedure = {
14
+ meta: () => procedure,
15
+ input: () => procedure,
16
+ output: () => procedure,
17
+ query: (resolver: unknown) => resolver,
18
+ };
19
+ return { t: { router: (routes: unknown) => routes }, didAndChallengeRoute: procedure };
22
20
  });
23
-
24
21
  import { aiRouter } from './ai';
25
22
 
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
- ],
23
+ const routes = aiRouter as unknown as Record<
24
+ string,
25
+ (params: { input: unknown; ctx?: unknown }) => Promise<unknown>
26
+ >;
27
+ describe('LCA generation route contracts', () => {
28
+ it('uses Sol for boost generation and preserves locale and schema', async () => {
29
+ const output = {
30
+ title: 'Título',
31
+ description: 'Descripción',
32
+ category: 'Achievement',
33
+ type: 'Badge',
34
+ narrative: 'Narración',
42
35
  };
36
+ generate.mockResolvedValueOnce(output);
37
+ expect(
38
+ await routes.generateBoostInfo!({ input: { description: 'Synthetic', locale: 'es' } })
39
+ ).toEqual(output);
40
+ const params = generate.mock.calls.at(-1)![1];
41
+ expect(params.task).toBe('reasoning');
42
+ expect(params.instructions).toContain('Spanish');
43
+ expect(params.schema.parse(output)).toEqual(output);
44
+ expect(() => params.schema.parse({ ...output, category: 'Invented' })).toThrow();
43
45
  });
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',
46
+ it('uses Sol for hierarchy reasoning and keeps the flattened API shape', async () => {
47
+ generate.mockResolvedValueOnce({
48
+ digital: [{ skill: 'cybersecurity', subskills: ['dataPrivacy'] }],
83
49
  });
84
- expect(mocks.create).not.toHaveBeenCalled();
50
+ expect(await routes.generateBoostSkills!({ input: { description: 'Synthetic' } })).toEqual([
51
+ { category: 'digital', skill: 'cybersecurity', subskills: ['dataPrivacy'] },
52
+ ]);
53
+ expect(generate.mock.calls.at(-1)![1].task).toBe('reasoning');
85
54
  });
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',
55
+ it('uses Luna for icon mapping and keeps missing-name fallback and requested-name filtering', async () => {
56
+ generate.mockResolvedValueOnce({
57
+ items: [
58
+ { name: 'Typing', icon: '⌨️' },
59
+ { name: 'Extra', icon: '🎯' },
60
+ ],
92
61
  });
62
+ expect(
63
+ await routes.generateSkillIcons!({ input: { names: ['Typing', 'Planning'] } })
64
+ ).toEqual({ Typing: '⌨️', Planning: '⭐' });
65
+ const params = generate.mock.calls.at(-1)![1];
66
+ expect(params.task).toBe('formatting');
67
+ expect(() =>
68
+ (params.schema as z.ZodType).parse({
69
+ items: [{ name: 'Typing', icon: 'too long for icon' }],
70
+ })
71
+ ).toThrow();
93
72
  });
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
- });
73
+ it('propagates provider failures rather than manufacturing an icon success', async () => {
74
+ const error = new Error('AI generation unavailable');
75
+ generate.mockRejectedValueOnce(error);
76
+ await expect(routes.generateSkillIcons!({ input: { names: ['Typing'] } })).rejects.toBe(
77
+ error
78
+ );
102
79
  });
103
80
  });
package/src/routes/ai.ts CHANGED
@@ -1,10 +1,10 @@
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
8
  import { GenerateSkillIconsInputValidator } from '@learncard/types';
9
9
 
10
10
  import * as filestack from 'filestack-js';
@@ -80,7 +80,7 @@ const IconMapValidator = z.record(
80
80
  z.string().min(1).max(8)
81
81
  );
82
82
 
83
- // LLM output schema: object-wrapped list to satisfy response_format type 'object'
83
+ // Required-function output schema: object-wrapped list.
84
84
  // We'll convert this into the IconMapValidator shape before returning
85
85
  const IconListContainerValidator = z.object({
86
86
  items: z.array(
@@ -95,6 +95,10 @@ const openai = environment.OPENAI_API_KEY
95
95
  ? new OpenAI({ apiKey: environment.OPENAI_API_KEY })
96
96
  : undefined;
97
97
 
98
+ let generationClient: OpenAI | undefined;
99
+ const getGenerationClient = (): OpenAI =>
100
+ (generationClient ??= createBedrockClient(environment.BEDROCK_BASE_URL));
101
+
98
102
  export const aiRouter = t.router({
99
103
  generateBoostInfo: didAndChallengeRoute
100
104
  .meta({
@@ -110,41 +114,19 @@ export const aiRouter = t.router({
110
114
  })
111
115
  .input(z.object({ description: z.string().nonempty(), locale: z.string().optional() }))
112
116
  .output(AIResponseValidator)
113
- .query(async ({ input, ctx }) => {
114
- const {
115
- user: { did },
116
- } = ctx;
117
- if (!openai) {
118
- throw new TRPCError({
119
- code: 'INTERNAL_SERVER_ERROR',
120
- message: 'No OpenAI Key Set',
121
- });
122
- }
123
-
117
+ .query(async ({ input }) => {
124
118
  const { description } = input;
125
119
 
126
- const completion = await openai.chat.completions.create({
127
- model: 'gpt-4o-2024-08-06',
128
- messages: [
129
- {
130
- role: 'system',
131
- content:
132
- `Generate a title ${aiTitleCharsetRule(
133
- input.locale
134
- )} 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.` +
135
- aiLocaleInstruction(input.locale),
136
- },
137
- { role: 'user', content: description },
138
- ],
139
- // response_format: zodResponseFormat(AIResponseValidator, 'info'),
140
- response_format: { type: 'json_object' },
141
- 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,
142
129
  });
143
-
144
- const content = completion.choices[0]?.message.content ?? '';
145
- const response = JSON.parse(content);
146
-
147
- return AIResponseValidator.parseAsync(response);
148
130
  }),
149
131
 
150
132
  generateBoostSkills: didAndChallengeRoute
@@ -160,36 +142,15 @@ export const aiRouter = t.router({
160
142
  })
161
143
  .input(z.object({ description: z.string().nonempty() }))
162
144
  .output(z.array(BoostSkillsValidator))
163
- .query(async ({ input, ctx }) => {
164
- const {
165
- user: { did },
166
- } = ctx;
167
- if (!openai) {
168
- throw new TRPCError({
169
- code: 'INTERNAL_SERVER_ERROR',
170
- message: 'No OpenAI Key Set',
171
- });
172
- }
173
-
145
+ .query(async ({ input }) => {
174
146
  const { description } = input;
175
147
 
176
- const completion = await openai.chat.completions.create({
177
- model: 'gpt-4o-2024-08-06',
178
- messages: [
179
- {
180
- role: 'system',
181
- content: `Generate *NO MORE THAN 3* skills for the provided description about a boost that matches the given skills hierarchy. ${aiSkillsHierarchyRule}`,
182
- },
183
- { role: 'user', content: description },
184
- ],
185
- response_format: zodResponseFormat(BoostSkillHierarchyValidator, 'skills'),
186
- 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,
187
153
  });
188
-
189
- const response = JSON.parse(completion.choices[0]?.message.content ?? '');
190
-
191
- const parsedResponse = await BoostSkillHierarchyValidator.parseAsync(response);
192
-
193
154
  return transformLLMResultToOutputShape(parsedResponse);
194
155
  }),
195
156
 
@@ -207,18 +168,7 @@ export const aiRouter = t.router({
207
168
  })
208
169
  .input(GenerateSkillIconsInputValidator)
209
170
  .output(IconMapValidator)
210
- .query(async ({ input, ctx }) => {
211
- const {
212
- user: { did },
213
- } = ctx;
214
-
215
- if (!openai) {
216
- throw new TRPCError({
217
- code: 'INTERNAL_SERVER_ERROR',
218
- message: 'No OpenAI Key Set',
219
- });
220
- }
221
-
171
+ .query(async ({ input }) => {
222
172
  const { names } = input;
223
173
 
224
174
  const systemPrompt = `You are assigning a single, standard Unicode emoji to represent each skill name.
@@ -234,42 +184,12 @@ Rules:
234
184
  '\n'
235
185
  )}\n\nReturn a JSON object of the form { items: [{ name, icon }] } for these names.`;
236
186
 
237
- // TODO switch to gpt 5 and use responses API
238
- // will have to bump the openai package
239
- let completion: any;
240
- try {
241
- completion = await openai.chat.completions.create({
242
- model: 'gpt-4o-2024-08-06',
243
- messages: [
244
- {
245
- role: 'system',
246
- content: systemPrompt,
247
- },
248
- { role: 'user', content: userContent },
249
- ],
250
- // Use the object-wrapped list schema for structured outputs
251
- // currently breaks because response is wrapped in a code fence (```json ... ```)
252
- // likely would need to to bump openai package version
253
- // response_format: zodResponseFormat(IconListContainerValidator, 'icons'),
254
- response_format: { type: 'json_object' },
255
- user: did,
256
- });
257
- } catch (error) {
258
- console.error('🔥🔥🔥🔥🔥🔥🔥🔥🔥');
259
- console.error('error:', error);
260
- }
261
-
262
- const content = completion.choices[0]?.message.content ?? '';
263
-
264
- const response = JSON.parse(content);
265
- // First, validate the model output with the object-wrapped list schema
266
- const parsed = await IconListContainerValidator.parseAsync(response);
267
- if (!parsed) {
268
- throw new TRPCError({
269
- code: 'INTERNAL_SERVER_ERROR',
270
- message: 'No parsed structured output returned.',
271
- });
272
- }
187
+ const parsed = await generateStructuredOutput(getGenerationClient(), {
188
+ task: 'formatting',
189
+ instructions: systemPrompt,
190
+ input: userContent,
191
+ schema: IconListContainerValidator,
192
+ });
273
193
 
274
194
  // Transform into a record mapping input names to icons
275
195
  const record: Record<string, string> = {};
@@ -307,39 +227,7 @@ Rules:
307
227
  const {
308
228
  user: { did },
309
229
  } = ctx;
310
- if (!openai) {
311
- throw new TRPCError({
312
- code: 'INTERNAL_SERVER_ERROR',
313
- message: 'No OpenAI Key Set',
314
- });
315
- }
316
-
317
- const { prompt } = input;
318
- const res = await openai.images.generate({
319
- prompt,
320
- model: 'dall-e-3',
321
- size: '1024x1024',
322
- user: did,
323
- });
324
-
325
- if (!res.data || !res.data[0]?.url) {
326
- throw new TRPCError({
327
- code: 'INTERNAL_SERVER_ERROR',
328
- message: 'No image URL returned from OpenAI',
329
- });
330
- }
331
-
332
- const filestackRes = (await client.storeURL(res?.data[0]?.url)) as any;
333
- if (!filestackRes || !filestackRes.url) {
334
- throw new TRPCError({
335
- code: 'INTERNAL_SERVER_ERROR',
336
- message: 'Failed to store image URL',
337
- });
338
- }
339
-
340
- return {
341
- url: filestackRes.url,
342
- };
230
+ return generateStoredImage(input.prompt, did, environment, openai, client);
343
231
  }),
344
232
  });
345
233
 
@@ -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
 
@@ -7,6 +7,7 @@ afterEach(() => vi.restoreAllMocks());
7
7
 
8
8
  describe('escrow failure reasons', () => {
9
9
  it('logs invalid outbound holds without error details', () => {
10
+ const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
10
11
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
11
12
  const error = new EscrowUnavailableError('invalid_hold');
12
13
  error.message = 'private hold contents';
@@ -14,6 +15,13 @@ describe('escrow failure reasons', () => {
14
15
  expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
15
16
  reason: 'invalid_hold',
16
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');
17
25
  });
18
26
  it.each([
19
27
  [403, 'policy', 'policy'],
@@ -26,4 +26,21 @@ export const logEscrowFailure = (error: unknown): void => {
26
26
  : 'unexpected';
27
27
  // lca-api uses structured console warnings rather than the browser logger.
28
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
+ );
29
46
  };
@@ -60,6 +60,16 @@ describe('remote enclave TLS trust', () => {
60
60
  );
61
61
  expect(pinnedEnclaveCa('https://enclave-host.internal')).toBeUndefined();
62
62
  });
63
+
64
+ it('pins a distinct production CA by hostname only', () => {
65
+ const production = pinnedEnclaveCa('https://escrow-enclave.production.internal:8443');
66
+
67
+ expect(production).toContain('BEGIN CERTIFICATE');
68
+ expect(production).not.toEqual(
69
+ pinnedEnclaveCa('https://escrow-enclave.staging.internal:8443')
70
+ );
71
+ expect(pinnedEnclaveCa('https://production.internal:8443')).toBeUndefined();
72
+ });
63
73
  });
64
74
 
65
75
  describe('remote enclave connection recovery', () => {
@@ -15,6 +15,13 @@ import {
15
15
  hashEscrowResumeToken,
16
16
  recordEscrowHoldNotification,
17
17
  computeEffectiveReleaseAfter,
18
+ findEscrowHoldsDueForReminder,
19
+ findEscrowHoldsPendingStartNotification,
20
+ findEscrowHoldsPendingEnclaveCancellation,
21
+ claimEscrowHoldForReminder,
22
+ releaseEscrowReminderClaim,
23
+ recordEscrowStartNotificationAttempt,
24
+ recordEscrowEnclaveCancelAttempt,
18
25
  ESCROW_RELEASE_TIME_MARGIN_MS,
19
26
  ESCROW_RELEASE_WARNING_GAP_MS,
20
27
  type AuthProviderMapping,
@@ -41,7 +48,11 @@ const makeProvider = (): AuthProviderMapping => {
41
48
 
42
49
  /** Every seed() call gets its own authProvider (see test/models/escrow.spec.ts
43
50
  * for why: only one pending hold-policy hold is allowed per authProvider). */
44
- const seed = async (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold') => {
51
+ const seed = async (
52
+ releaseAfter: Date,
53
+ releasePolicy: 'hold' | 'pin' = 'hold',
54
+ holdId = randomUUID()
55
+ ) => {
45
56
  const provider = makeProvider();
46
57
  await upsertUserKeyByAuthProvider(
47
58
  { type: 'email', value: `${provider.id}@example.com` },
@@ -54,7 +65,7 @@ const seed = async (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold')
54
65
  return createEscrowHold({
55
66
  holdRecord: {
56
67
  hold: {
57
- holdId: randomUUID(),
68
+ holdId,
58
69
  did: 'did:key:test',
59
70
  shareVersion: 1,
60
71
  blobHash: 'ab'.repeat(32),
@@ -117,6 +128,75 @@ afterAll(async () => {
117
128
  });
118
129
 
119
130
  describe('runEscrowHoldReminders', () => {
131
+ describe.each(['reminder', 'started', 'cancelled'] as const)('%s queue ordering', queue => {
132
+ it.each([false, true])(
133
+ 'keeps tied batches stable and rotates retries (previously attempted: %s)',
134
+ async previouslyAttempted => {
135
+ const now = new Date();
136
+ const previousAttempt = new Date(now.getTime() - HOUR);
137
+ // More than a default delivery batch. Insert backwards so physical
138
+ // insertion order cannot accidentally satisfy the expected ID order.
139
+ const ids = Array.from(
140
+ { length: 13 },
141
+ (_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`
142
+ );
143
+ for (const id of [...ids].reverse()) {
144
+ await seed(new Date(now.getTime() + HOUR), 'hold', id);
145
+ }
146
+ await getEscrowHoldsCollection().updateMany(
147
+ {},
148
+ {
149
+ $set: {
150
+ requestedAt: previousAttempt,
151
+ ...(queue === 'started' ? { startedNotificationPending: true } : {}),
152
+ ...(queue === 'cancelled'
153
+ ? {
154
+ status: 'cancelled' as const,
155
+ enclaveCancelPendingAt: previousAttempt,
156
+ enclaveCancelAttempts: previouslyAttempted ? 2 : 0,
157
+ }
158
+ : previouslyAttempted
159
+ ? queue === 'reminder'
160
+ ? { reminderAttemptedAt: previousAttempt }
161
+ : { startedNotificationAttemptedAt: previousAttempt }
162
+ : {}),
163
+ },
164
+ }
165
+ );
166
+ const readBatch = () =>
167
+ queue === 'reminder'
168
+ ? findEscrowHoldsDueForReminder(now, 5)
169
+ : queue === 'started'
170
+ ? findEscrowHoldsPendingStartNotification(5)
171
+ : findEscrowHoldsPendingEnclaveCancellation(5);
172
+
173
+ for (let repeat = 0; repeat < 3; repeat += 1) {
174
+ expect((await readBatch()).map(hold => hold._id)).toEqual(ids.slice(0, 5));
175
+ }
176
+ // Failed deliveries remain eligible. Updating their real attempt
177
+ // markers must put untouched rows first, even with tied timestamps.
178
+ for (let offset = 0; offset < ids.length; offset += 5) {
179
+ const batch = await readBatch();
180
+ const remaining = Math.min(5, ids.length - offset);
181
+ expect(batch.slice(0, remaining).map(hold => hold._id)).toEqual(
182
+ ids.slice(offset, offset + remaining)
183
+ );
184
+ for (const hold of batch) {
185
+ if (queue === 'reminder') {
186
+ const claimed = await claimEscrowHoldForReminder(hold._id, now);
187
+ expect(claimed).not.toBeNull();
188
+ if (claimed) await releaseEscrowReminderClaim(claimed);
189
+ } else if (queue === 'started') {
190
+ await recordEscrowStartNotificationAttempt(hold._id, now);
191
+ } else {
192
+ expect(await recordEscrowEnclaveCancelAttempt(hold._id)).toBe(true);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ );
198
+ });
199
+
120
200
  it('bounds cancellation retries and gives unattempted cancellations priority', async () => {
121
201
  const now = new Date();
122
202
  const holds = await Promise.all(
@@ -129,9 +209,13 @@ describe('runEscrowHoldReminders', () => {
129
209
  await runEscrowHoldReminders({ now, limit: 200 });
130
210
  const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
131
211
  expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 0, 1, 1]);
132
- const unattempted = firstRun.find(hold => hold?.enclaveCancelAttempts === 0)!;
212
+ // UUIDs are ASCII: default string ordering matches Mongo's byte ordering.
213
+ const unattemptedId = firstRun
214
+ .filter(hold => hold?.enclaveCancelAttempts === 0)
215
+ .map(hold => hold!._id)
216
+ .sort()[0]!;
133
217
  await runEscrowHoldReminders({ now, limit: 1 });
134
- expect((await findEscrowHoldById(unattempted._id))?.enclaveCancelAttempts).toBe(1);
218
+ expect((await findEscrowHoldById(unattemptedId))?.enclaveCancelAttempts).toBe(1);
135
219
  for (const hold of holds) {
136
220
  const stored = await findEscrowHoldById(hold._id);
137
221
  expect(stored?.enclaveCancelPendingAt).toBeInstanceOf(Date);