@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.
- package/.env.example +10 -0
- package/CHANGELOG.md +44 -0
- package/bundle.mjs +1 -1
- package/dist/app.d.ts +23 -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/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +19 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +3 -1
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +17 -1
- package/dist/types/notifications.d.ts +3 -1
- package/docs/bedrock-generation.md +119 -0
- package/docs/image-generation.md +120 -0
- package/esbuildPlugins.cjs +9 -6
- package/package.json +21 -16
- 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/routes/ai.skill-icons.test.ts +92 -0
- package/src/routes/ai.test.ts +80 -0
- package/src/routes/ai.ts +33 -144
- package/src/routes/escrow.ts +15 -2
- package/src/routes/keys.ts +18 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
- package/src/services/escrow-enclave/logFailure.test.ts +72 -0
- package/src/services/escrow-enclave/logFailure.ts +46 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
- package/src/services/escrow-enclave/types.ts +26 -2
- package/test/keys-auth-share-escrow.spec.ts +103 -0
- package/test/keys-escrow.spec.ts +65 -0
- package/test/oidc.spec.ts +41 -2
- 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 {
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
|
169
|
+
.input(GenerateSkillIconsInputValidator)
|
|
208
170
|
.output(IconMapValidator)
|
|
209
|
-
.query(async ({ input
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
package/src/routes/escrow.ts
CHANGED
|
@@ -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,
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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,
|
|
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();
|