@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.
- package/.env.example +10 -0
- package/CHANGELOG.md +40 -0
- package/bundle.mjs +1 -1
- package/dist/app.d.ts +4 -1
- package/dist/config/cacheEnvironment.d.ts +4 -4
- package/dist/config/environment.d.ts +145 -114
- package/dist/config/oidcEnvironment.d.ts +22 -22
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrockGeneration.d.ts +17 -0
- package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
- package/dist/helpers/bedrockSchema.d.ts +10 -0
- package/dist/helpers/imageGeneration.d.ts +42 -0
- package/dist/helpers/imageGeneration.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Notification.d.ts +3 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.skill-icons.test.d.ts +1 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +3 -1
- package/dist/types/notifications.d.ts +3 -1
- package/docs/bedrock-generation.md +119 -0
- package/docs/image-generation.md +120 -0
- package/package.json +21 -16
- package/project.json +1 -1
- package/scripts/node24-runtime.cjs +9 -0
- package/scripts/verify-node24-layer.cjs +49 -0
- package/serverless-local.yml +3 -1
- package/serverless.function-env.cjs +2 -4
- package/serverless.yml +76 -3
- package/src/config/environment.ts +14 -0
- package/src/helpers/bedrockGeneration.test.ts +222 -0
- package/src/helpers/bedrockGeneration.ts +110 -0
- package/src/helpers/bedrockSchema.ts +352 -0
- package/src/helpers/imageGeneration.test.ts +456 -0
- package/src/helpers/imageGeneration.ts +432 -0
- package/src/models/EscrowHold.ts +6 -3
- package/src/models/UserKey.ts +14 -0
- package/src/routes/ai.skill-icons.test.ts +92 -0
- package/src/routes/ai.test.ts +68 -91
- package/src/routes/ai.ts +31 -143
- package/src/routes/keys.ts +18 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
- package/src/services/escrow-enclave/logFailure.test.ts +8 -0
- package/src/services/escrow-enclave/logFailure.ts +17 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +10 -0
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-auth-share-escrow.spec.ts +103 -0
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/test/oidc.spec.ts +41 -2
- package/tsconfig.bedrock.json +10 -0
- package/tsconfig.json +13 -14
package/src/routes/ai.test.ts
CHANGED
|
@@ -1,103 +1,80 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
18
|
-
vi.mock('@routes',
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
package/src/routes/keys.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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);
|