@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
@@ -0,0 +1,432 @@
1
+ import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
2
+ import { TRPCError } from '@trpc/server';
3
+ import type OpenAI from 'openai';
4
+ import { PNG } from 'pngjs';
5
+ import { createHash } from 'node:crypto';
6
+ import { inflateSync } from 'node:zlib';
7
+
8
+ export interface ImageGenerationConfig {
9
+ IMAGE_PROVIDER: 'openai' | 'bedrock';
10
+ BEDROCK_IMAGE_REGION: string;
11
+ BEDROCK_IMAGE_MODEL_ID: string;
12
+ IMAGE_TIMEOUT_MS: number;
13
+ }
14
+
15
+ interface ImageStorage {
16
+ storeURL: (url: string) => Promise<{ url?: string }>;
17
+ upload: (
18
+ bytes: Buffer,
19
+ options: { timeout: number },
20
+ storeOptions: { filename: string }
21
+ ) => Promise<{ url?: string }>;
22
+ }
23
+
24
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
25
+ const MAX_RESPONSE_BYTES = 14 * 1024 * 1024;
26
+ /** At most two quick retries, and only for rejections Bedrock does not bill (throttling/unavailable). */
27
+ const MAX_BEDROCK_ATTEMPTS = 3;
28
+ const RETRY_BASE_DELAY_MS = 250;
29
+ const RETRY_MAX_DELAY_MS = 2_000;
30
+ /** Do not start another attempt unless a full generation (~6 s) plus upload still fits the deadline. */
31
+ const MIN_ATTEMPT_BUDGET_MS = 12_000;
32
+
33
+ const failure = (): TRPCError =>
34
+ new TRPCError({
35
+ code: 'INTERNAL_SERVER_ERROR',
36
+ message: 'Image generation failed. Please try again later.',
37
+ });
38
+ const rateLimited = (): TRPCError =>
39
+ new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'AI generation rate limited' });
40
+ const unavailable = (): TRPCError =>
41
+ new TRPCError({
42
+ code: 'SERVICE_UNAVAILABLE',
43
+ message: 'Image generation unavailable. Please try again later.',
44
+ });
45
+ const filtered = (): TRPCError =>
46
+ new TRPCError({ code: 'BAD_REQUEST', message: 'The image request was filtered.' });
47
+
48
+ /** Sanitized, allowlisted diagnostics for one request. Never holds prompts, bodies or headers. */
49
+ export interface ImageDiagnostics {
50
+ stage?: 'invoke' | 'decode' | 'storage' | 'timeout' | 'config';
51
+ category?: ProviderErrorCategory;
52
+ decodeReason?: string;
53
+ finishReason?: string | null;
54
+ errorName?: string;
55
+ errorCode?: string;
56
+ httpStatus?: number;
57
+ awsRequestId?: string;
58
+ sdkRetryable?: boolean;
59
+ attempts?: number;
60
+ }
61
+
62
+ type ProviderErrorCategory = 'throttled' | 'unavailable' | 'timeout' | 'filtered' | 'rejected';
63
+
64
+ const SAFE_IDENTIFIER = /^[A-Za-z][\w.]{0,63}$/;
65
+ const SAFE_REQUEST_ID = /^[\w-]{1,128}$/;
66
+ const SAFE_FINISH_REASON = /^Filter reason: [a-z ]{1,32}$/;
67
+
68
+ /** HTTP status from an OpenAI SDK APIError or an AWS SDK service exception, if any. */
69
+ const providerStatus = (error: unknown): number | undefined => {
70
+ if (!error || typeof error !== 'object') return undefined;
71
+ const { status, $metadata: metadata } = error as {
72
+ status?: unknown;
73
+ $metadata?: { httpStatusCode?: unknown };
74
+ };
75
+ if (typeof status === 'number') return status;
76
+ return typeof metadata?.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined;
77
+ };
78
+
79
+ /** Allowlisted metadata from an AWS SDK / OpenAI SDK / Node network error. Messages are never read. */
80
+ export const providerErrorInfo = (error: unknown): Partial<ImageDiagnostics> => {
81
+ if (!error || typeof error !== 'object') return {};
82
+ const {
83
+ name,
84
+ code,
85
+ $metadata: metadata,
86
+ $retryable: retryable,
87
+ } = error as {
88
+ name?: unknown;
89
+ code?: unknown;
90
+ $metadata?: { requestId?: unknown };
91
+ $retryable?: unknown;
92
+ };
93
+ const requestId = metadata?.requestId;
94
+ return {
95
+ errorName: typeof name === 'string' && SAFE_IDENTIFIER.test(name) ? name : undefined,
96
+ errorCode: typeof code === 'string' && SAFE_IDENTIFIER.test(code) ? code : undefined,
97
+ httpStatus: providerStatus(error),
98
+ awsRequestId:
99
+ typeof requestId === 'string' && SAFE_REQUEST_ID.test(requestId)
100
+ ? requestId
101
+ : undefined,
102
+ sdkRetryable: retryable ? true : undefined,
103
+ };
104
+ };
105
+
106
+ const THROTTLED_NAMES = new Set([
107
+ 'ThrottlingException',
108
+ // Bedrock reports some account quotas (e.g. requests/tokens per day or minute for a newly
109
+ // enabled model) as HTTP 400 ServiceQuotaExceededException rather than 429.
110
+ 'ServiceQuotaExceededException',
111
+ 'ModelNotReadyException',
112
+ 'TooManyRequestsException',
113
+ ]);
114
+ const UNAVAILABLE_NAMES = new Set(['ServiceUnavailableException', 'InternalServerException']);
115
+ const TIMEOUT_NAMES = new Set(['ModelTimeoutException', 'TimeoutError', 'RequestTimeout']);
116
+ /** Matched only to classify; the provider message is never logged or returned. */
117
+ const CONTENT_FILTER_MESSAGE = /content filter|blocked by our|moderation|flagged/i;
118
+
119
+ /** Classify a provider failure. Only throttling/unavailable rejections are retried. */
120
+ export const classifyProviderError = (
121
+ error: unknown
122
+ ): { category: ProviderErrorCategory; retryable: boolean } => {
123
+ const { errorName, httpStatus } = providerErrorInfo(error);
124
+ const name = errorName ?? '';
125
+ if (THROTTLED_NAMES.has(name) || httpStatus === 429) {
126
+ return { category: 'throttled', retryable: true };
127
+ }
128
+ if (UNAVAILABLE_NAMES.has(name) || httpStatus === 503 || httpStatus === 500) {
129
+ return { category: 'unavailable', retryable: true };
130
+ }
131
+ if (TIMEOUT_NAMES.has(name) || httpStatus === 504 || httpStatus === 408) {
132
+ // The model may have run (and billed); never retry inside the same request.
133
+ return { category: 'timeout', retryable: false };
134
+ }
135
+ const message = (error as { message?: unknown } | null)?.message;
136
+ if (
137
+ name === 'ValidationException' &&
138
+ typeof message === 'string' &&
139
+ CONTENT_FILTER_MESSAGE.test(message)
140
+ ) {
141
+ return { category: 'filtered', retryable: false };
142
+ }
143
+ return { category: 'rejected', retryable: false };
144
+ };
145
+
146
+ const clientError = (category: ProviderErrorCategory): TRPCError => {
147
+ if (category === 'throttled') return rateLimited();
148
+ if (category === 'unavailable' || category === 'timeout') return unavailable();
149
+ if (category === 'filtered') return filtered();
150
+ return failure();
151
+ };
152
+
153
+ const PASSTHROUGH_CODES = new Set(['BAD_REQUEST', 'TOO_MANY_REQUESTS', 'SERVICE_UNAVAILABLE']);
154
+
155
+ const promptHash = (prompt: string): string =>
156
+ createHash('sha256').update(prompt).digest('hex').slice(0, 12);
157
+
158
+ const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
159
+ new Promise((resolve, reject) => {
160
+ if (signal.aborted) reject(unavailable());
161
+ const timer = setTimeout(() => {
162
+ signal.removeEventListener('abort', onAbort);
163
+ resolve();
164
+ }, ms);
165
+ const onAbort = () => {
166
+ clearTimeout(timer);
167
+ reject(unavailable());
168
+ };
169
+ signal.addEventListener('abort', onAbort, { once: true });
170
+ });
171
+
172
+ /** Full jitter: uniform in [0, min(cap, base * 2^(attempt-1))]. */
173
+ const retryDelay = (attempt: number): number =>
174
+ Math.floor(Math.random() * Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt));
175
+
176
+ /** Validate a single unfiltered, bounded square PNG before handing bytes to storage. */
177
+ export const decodeBedrockImage = (
178
+ body: Uint8Array,
179
+ diagnostics: ImageDiagnostics = {}
180
+ ): Buffer => {
181
+ function reject(reason: string): never {
182
+ diagnostics.decodeReason = reason;
183
+ throw failure();
184
+ }
185
+ if (body.byteLength > MAX_RESPONSE_BYTES) reject('response_too_large');
186
+ let result: unknown;
187
+ try {
188
+ result = JSON.parse(Buffer.from(body).toString('utf8'));
189
+ } catch {
190
+ reject('json_invalid');
191
+ }
192
+ if (!result || typeof result !== 'object') reject('json_invalid');
193
+ const { images, finish_reasons: reasons } = result as Record<string, unknown>;
194
+ if (!Array.isArray(reasons) || reasons.length !== 1) reject('finish_reasons_invalid');
195
+ const reason: unknown = reasons[0];
196
+ diagnostics.finishReason =
197
+ reason === null
198
+ ? null
199
+ : typeof reason === 'string' && SAFE_FINISH_REASON.test(reason)
200
+ ? reason
201
+ : 'other';
202
+ if (typeof reason === 'string' && reason.startsWith('Filter reason:')) {
203
+ diagnostics.decodeReason = 'filtered';
204
+ throw filtered();
205
+ }
206
+ if (reason !== null) reject('not_success');
207
+ if (!Array.isArray(images) || images.length !== 1) reject('image_count_invalid');
208
+ const encoded: unknown = images[0];
209
+ if (
210
+ typeof encoded !== 'string' ||
211
+ !encoded.length ||
212
+ encoded.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 ||
213
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)
214
+ )
215
+ reject('base64_invalid');
216
+ const bytes = Buffer.from(encoded, 'base64');
217
+ if (
218
+ bytes.length < 33 ||
219
+ bytes.length > MAX_IMAGE_BYTES ||
220
+ bytes.toString('base64') !== encoded ||
221
+ !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
222
+ bytes.readUInt32BE(8) !== 13 ||
223
+ bytes.toString('ascii', 12, 16) !== 'IHDR'
224
+ )
225
+ reject('png_header_invalid');
226
+ const width = bytes.readUInt32BE(16);
227
+ if (width !== bytes.readUInt32BE(20) || width < 640 || width > 1536) {
228
+ reject('png_dimensions_invalid');
229
+ }
230
+ // Reject duplicate headers and bound decompression, including interlaced PNGs.
231
+ const compressed: Buffer[] = [];
232
+ let ended = false;
233
+ for (let offset = 8; offset < bytes.length;) {
234
+ if (offset + 12 > bytes.length || ended) reject('png_structure_invalid');
235
+ const length = bytes.readUInt32BE(offset);
236
+ const end = offset + 12 + length;
237
+ if (end > bytes.length) reject('png_structure_invalid');
238
+ const type = bytes.toString('ascii', offset + 4, offset + 8);
239
+ if (type === 'IHDR' && offset !== 8) reject('png_structure_invalid');
240
+ if (type === 'IDAT') compressed.push(bytes.subarray(offset + 8, end - 4));
241
+ if (type === 'IEND') {
242
+ if (length !== 0) reject('png_structure_invalid');
243
+ ended = true;
244
+ }
245
+ offset = end;
246
+ }
247
+ if (!ended || !compressed.length) reject('png_structure_invalid');
248
+ try {
249
+ inflateSync(Buffer.concat(compressed), { maxOutputLength: 20 * 1024 * 1024 });
250
+ // Dimensions are bounded before decode, preventing oversized pixel allocations.
251
+ const image = PNG.sync.read(bytes, { checkCRC: true });
252
+ if (image.width !== width || image.height !== width) reject('png_dimensions_invalid');
253
+ } catch (error) {
254
+ if (error instanceof TRPCError) throw error;
255
+ reject('png_decode_failed');
256
+ }
257
+ return bytes;
258
+ };
259
+
260
+ /**
261
+ * One successful paid invocation per request, no provider fallback. Bedrock throttling/unavailable
262
+ * rejections (not billed) get at most two jittered retries, only while a full attempt still fits
263
+ * inside IMAGE_TIMEOUT_MS. Every failure logs one sanitized `image_generation_failure` record.
264
+ */
265
+ export const generateStoredImage = async (
266
+ prompt: string,
267
+ did: string,
268
+ config: ImageGenerationConfig,
269
+ openai: OpenAI | undefined,
270
+ storage: ImageStorage
271
+ ): Promise<{ url: string }> => {
272
+ if (config.IMAGE_PROVIDER === 'bedrock' && prompt.length > 10_000) {
273
+ throw new TRPCError({
274
+ code: 'BAD_REQUEST',
275
+ message: 'Image prompt exceeds 10000 characters.',
276
+ });
277
+ }
278
+ const startedAt = Date.now();
279
+ const deadline = startedAt + config.IMAGE_TIMEOUT_MS;
280
+ const bedrock = config.IMAGE_PROVIDER === 'bedrock';
281
+ const logBase = () => ({
282
+ provider: config.IMAGE_PROVIDER,
283
+ modelId: bedrock ? config.BEDROCK_IMAGE_MODEL_ID : 'dall-e-3',
284
+ region: bedrock ? config.BEDROCK_IMAGE_REGION : undefined,
285
+ durationMs: Date.now() - startedAt,
286
+ promptLength: prompt.length,
287
+ promptHash: promptHash(prompt),
288
+ });
289
+ const diagnostics: ImageDiagnostics = { attempts: 0 };
290
+ const providerError = (error: unknown): TRPCError => {
291
+ if (error instanceof TRPCError) return error;
292
+ const { category } = classifyProviderError(error);
293
+ Object.assign(diagnostics, providerErrorInfo(error), { stage: 'invoke', category });
294
+ return clientError(category);
295
+ };
296
+ const controller = new AbortController();
297
+ let timeout: ReturnType<typeof setTimeout> | undefined;
298
+ const work = async (): Promise<{ url: string }> => {
299
+ let stored: { url?: string };
300
+ if (bedrock) {
301
+ // SigV4 with the execution role's rotating credentials only: never a long-term
302
+ // AWS_BEARER_TOKEN_BEDROCK API key, matching the text adapter's credential policy.
303
+ const client = new BedrockRuntimeClient({
304
+ region: config.BEDROCK_IMAGE_REGION,
305
+ maxAttempts: 1,
306
+ authSchemePreference: ['sigv4'],
307
+ });
308
+ try {
309
+ let response: { body: Uint8Array } | undefined;
310
+ for (let attempt = 1; !response; attempt += 1) {
311
+ diagnostics.attempts = attempt;
312
+ try {
313
+ response = await client.send(
314
+ new InvokeModelCommand({
315
+ modelId: config.BEDROCK_IMAGE_MODEL_ID,
316
+ contentType: 'application/json',
317
+ accept: 'application/json',
318
+ body: JSON.stringify({
319
+ prompt,
320
+ aspect_ratio: '1:1',
321
+ output_format: 'png',
322
+ }),
323
+ }),
324
+ { abortSignal: controller.signal }
325
+ );
326
+ } catch (error) {
327
+ // Our own deadline aborted the call: the timeout path owns the outcome/log.
328
+ if (controller.signal.aborted) throw unavailable();
329
+ const mapped = providerError(error);
330
+ const delay = retryDelay(attempt);
331
+ if (
332
+ !classifyProviderError(error).retryable ||
333
+ attempt >= MAX_BEDROCK_ATTEMPTS ||
334
+ controller.signal.aborted ||
335
+ deadline - Date.now() - delay < MIN_ATTEMPT_BUDGET_MS
336
+ ) {
337
+ throw mapped;
338
+ }
339
+ console.warn({
340
+ event: 'image_generation_retry',
341
+ ...logBase(),
342
+ ...providerErrorInfo(error),
343
+ category: diagnostics.category,
344
+ attempt,
345
+ delayMs: delay,
346
+ });
347
+ await abortableDelay(delay, controller.signal);
348
+ }
349
+ }
350
+ diagnostics.stage = 'decode';
351
+ const bytes = decodeBedrockImage(response.body, diagnostics);
352
+ if (controller.signal.aborted) throw failure();
353
+ diagnostics.stage = 'storage';
354
+ // The upload isn't tied to the abort signal, so cap it at the remaining budget
355
+ // rather than a fresh IMAGE_TIMEOUT_MS that would outlive the request.
356
+ const uploadTimeoutMs = Math.max(1000, deadline - Date.now());
357
+ stored = await storage.upload(
358
+ bytes,
359
+ { timeout: uploadTimeoutMs },
360
+ { filename: 'generated-image.png' }
361
+ );
362
+ } finally {
363
+ client.destroy();
364
+ }
365
+ } else {
366
+ if (!openai) {
367
+ diagnostics.stage = 'config';
368
+ throw failure();
369
+ }
370
+ diagnostics.attempts = 1;
371
+ diagnostics.stage = 'invoke';
372
+ const response = await openai.images
373
+ .generate(
374
+ { prompt, model: 'dall-e-3', size: '1024x1024', n: 1, user: did },
375
+ { signal: controller.signal, timeout: config.IMAGE_TIMEOUT_MS, maxRetries: 0 }
376
+ )
377
+ .catch((error: unknown) => {
378
+ throw providerError(error);
379
+ });
380
+ if (!response.data?.[0]?.url || controller.signal.aborted) throw failure();
381
+ diagnostics.stage = 'storage';
382
+ stored = await storage.storeURL(response.data[0].url);
383
+ }
384
+ if (!stored?.url) throw failure();
385
+ return { url: stored.url };
386
+ };
387
+ try {
388
+ const result = await Promise.race([
389
+ work(),
390
+ new Promise<never>((_, reject) => {
391
+ timeout = setTimeout(() => {
392
+ diagnostics.stage = 'timeout';
393
+ controller.abort();
394
+ reject(unavailable());
395
+ }, config.IMAGE_TIMEOUT_MS);
396
+ }),
397
+ ]);
398
+ console.info({
399
+ event: 'image_generation_success',
400
+ ...logBase(),
401
+ attempts: diagnostics.attempts,
402
+ });
403
+ return result;
404
+ } catch (error) {
405
+ // Storage/config/decode failures are not provider statuses: keep them generic.
406
+ const clientFacing =
407
+ error instanceof TRPCError && PASSTHROUGH_CODES.has(error.code) ? error : failure();
408
+ // Allowlisted fields only: no prompt text, provider messages, headers, URLs or bytes.
409
+ console.error({
410
+ event: 'image_generation_failure',
411
+ ...logBase(),
412
+ stage: diagnostics.stage,
413
+ attempts: diagnostics.attempts,
414
+ category: diagnostics.category,
415
+ errorName: diagnostics.errorName,
416
+ errorCode: diagnostics.errorCode,
417
+ httpStatus: diagnostics.httpStatus,
418
+ awsRequestId: diagnostics.awsRequestId,
419
+ sdkRetryable: diagnostics.sdkRetryable,
420
+ retryable: diagnostics.category
421
+ ? diagnostics.category === 'throttled' || diagnostics.category === 'unavailable'
422
+ : undefined,
423
+ finishReason: diagnostics.finishReason,
424
+ decodeReason: diagnostics.decodeReason,
425
+ ...(diagnostics.stage === 'storage' ? { storage: providerErrorInfo(error) } : {}),
426
+ clientCode: clientFacing.code,
427
+ });
428
+ throw clientFacing;
429
+ } finally {
430
+ clearTimeout(timeout);
431
+ }
432
+ };
@@ -282,7 +282,8 @@ export const findEscrowHoldsPendingEnclaveCancellation = async (
282
282
  enclaveCancelPendingAt: { $exists: true },
283
283
  enclaveCancelConfirmedAt: { $exists: false },
284
284
  })
285
- .sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
285
+ // Mongo sorts are unstable on ties; the unique ID makes bounded batches repeatable.
286
+ .sort({ enclaveCancelAttempts: 1, releaseAfter: 1, _id: 1 })
286
287
  .limit(limit)
287
288
  .toArray();
288
289
 
@@ -499,7 +500,8 @@ export const findEscrowHoldsPendingStartNotification = async (
499
500
  ): Promise<EscrowHold[]> =>
500
501
  getEscrowHoldsCollection()
501
502
  .find({ status: 'pending', startedNotificationPending: true })
502
- .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
503
+ // Keep tied start-notification batches deterministic before the limit.
504
+ .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1, _id: 1 })
503
505
  .limit(limit)
504
506
  .toArray();
505
507
 
@@ -563,7 +565,8 @@ export const findEscrowHoldsDueForReminder = async (
563
565
  releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
564
566
  'notifications.kind': { $ne: 'reminder' },
565
567
  })
566
- .sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
568
+ // Keep tied reminder batches deterministic before the limit.
569
+ .sort({ reminderAttemptedAt: 1, releaseAfter: 1, _id: 1 })
567
570
  .limit(limit)
568
571
  .toArray();
569
572
 
@@ -1301,6 +1301,12 @@ export const activateUserKeyByAuthProvider = async (
1301
1301
  /**
1302
1302
  * Lazily discard abandoned migration shares after the P0-1 TTL. New-user
1303
1303
  * provisional records are excluded because their keyProvider is already SSS.
1304
+ *
1305
+ * A migration with a confirmed escrow enrollment is not abandoned: the user
1306
+ * skipped the PIN, but escrow can recover them, and staying provisional keeps
1307
+ * the faster Web3Auth re-derive on new devices. Such records stay until the
1308
+ * user activates (e.g. sets a PIN) or operators activate them before Web3Auth
1309
+ * is retired (internal-docs/architecture/web3auth-sss-migration-runbook.md).
1304
1310
  */
1305
1311
  export const purgeExpiredProvisionalMigrationByAuthProvider = async (
1306
1312
  authProvider: AuthProviderMapping,
@@ -1313,6 +1319,14 @@ export const purgeExpiredProvisionalMigrationByAuthProvider = async (
1313
1319
  keyProvider: 'web3auth',
1314
1320
  sssActivationState: 'provisional',
1315
1321
  provisionalCreatedAt: { $lte: cutoff },
1322
+ $nor: [
1323
+ {
1324
+ escrowBlob: { $exists: true },
1325
+ recoveryMethods: {
1326
+ $elemMatch: { type: 'escrow', confirmationStatus: 'confirmed' },
1327
+ },
1328
+ },
1329
+ ],
1316
1330
  },
1317
1331
  {
1318
1332
  $set: {
@@ -0,0 +1,92 @@
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: {} }));
12
+ vi.mock('@helpers/bedrockGeneration', () => ({
13
+ createBedrockClient: vi.fn(() => ({})),
14
+ generateStructuredOutput: mocks.create,
15
+ }));
16
+ vi.mock('filestack-js', () => ({ init: () => ({}) }));
17
+ vi.mock('@routes', async () => {
18
+ const { initTRPC } = await import('@trpc/server');
19
+ const t = initTRPC.create();
20
+ return { t, didAndChallengeRoute: t.procedure };
21
+ });
22
+
23
+ import { aiRouter } from './ai';
24
+
25
+ const caller = aiRouter.createCaller({ user: { did: 'did:example:123' } } as never);
26
+
27
+ const respondWithIcons = (): void => {
28
+ mocks.create.mockImplementation(async (_client: unknown, { input }: { input: string }) => {
29
+ const names = [...input.matchAll(/^- (.*)$/gm)].map(match => match[1]);
30
+
31
+ return { items: names.map(name => ({ name, icon: '🎯' })) };
32
+ });
33
+ };
34
+
35
+ describe('generateSkillIcons input validation', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ respondWithIcons();
39
+ });
40
+
41
+ it('accepts a batch at both limits', async () => {
42
+ const names = Array.from({ length: SKILL_ICON_MAX_NAMES_PER_REQUEST }, (_, i) =>
43
+ `${i}`.padStart(
44
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST / SKILL_ICON_MAX_NAMES_PER_REQUEST,
45
+ 'x'
46
+ )
47
+ );
48
+
49
+ const result = await caller.generateSkillIcons({ names });
50
+
51
+ expect(Object.keys(result)).toHaveLength(SKILL_ICON_MAX_NAMES_PER_REQUEST);
52
+ expect(mocks.create).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it('rejects too many names with BAD_REQUEST before calling the model', async () => {
56
+ const names = Array.from(
57
+ { length: SKILL_ICON_MAX_NAMES_PER_REQUEST + 1 },
58
+ (_, i) => `s${i}`
59
+ );
60
+
61
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
62
+ code: 'BAD_REQUEST',
63
+ });
64
+ expect(mocks.create).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('rejects names totalling more than the character limit with BAD_REQUEST', async () => {
68
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST), 'b'];
69
+
70
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
71
+ code: 'BAD_REQUEST',
72
+ });
73
+ expect(mocks.create).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('rejects a single name over the character limit', async () => {
77
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST + 1)];
78
+
79
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
80
+ code: 'BAD_REQUEST',
81
+ });
82
+ });
83
+
84
+ it('still rejects empty lists and empty names', async () => {
85
+ await expect(caller.generateSkillIcons({ names: [] })).rejects.toMatchObject({
86
+ code: 'BAD_REQUEST',
87
+ });
88
+ await expect(caller.generateSkillIcons({ names: [''] })).rejects.toMatchObject({
89
+ code: 'BAD_REQUEST',
90
+ });
91
+ });
92
+ });