@learncard/lca-api-service 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +44 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +23 -1
  5. package/dist/config/cacheEnvironment.d.ts +4 -4
  6. package/dist/config/environment.d.ts +145 -114
  7. package/dist/config/oidcEnvironment.d.ts +22 -22
  8. package/dist/didkit_wasm_bg.wasm +0 -0
  9. package/dist/helpers/bedrockGeneration.d.ts +17 -0
  10. package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
  11. package/dist/helpers/bedrockSchema.d.ts +10 -0
  12. package/dist/helpers/imageGeneration.d.ts +42 -0
  13. package/dist/helpers/imageGeneration.test.d.ts +1 -0
  14. package/dist/index.js +2 -2
  15. package/dist/models/Notification.d.ts +3 -1
  16. package/dist/publish-metafile.json +1 -1
  17. package/dist/routes/ai.skill-icons.test.d.ts +1 -0
  18. package/dist/routes/ai.test.d.ts +1 -0
  19. package/dist/routes/escrow.d.ts +19 -0
  20. package/dist/routes/keys.d.ts +1 -0
  21. package/dist/routes/notifications.d.ts +3 -1
  22. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  23. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  24. package/dist/services/escrow-enclave/types.d.ts +17 -1
  25. package/dist/types/notifications.d.ts +3 -1
  26. package/docs/bedrock-generation.md +119 -0
  27. package/docs/image-generation.md +120 -0
  28. package/esbuildPlugins.cjs +9 -6
  29. package/package.json +21 -16
  30. package/scripts/node24-runtime.cjs +9 -0
  31. package/scripts/verify-node24-layer.cjs +49 -0
  32. package/serverless-local.yml +3 -1
  33. package/serverless.function-env.cjs +2 -4
  34. package/serverless.yml +76 -3
  35. package/src/config/environment.ts +14 -0
  36. package/src/helpers/bedrockGeneration.test.ts +222 -0
  37. package/src/helpers/bedrockGeneration.ts +110 -0
  38. package/src/helpers/bedrockSchema.ts +352 -0
  39. package/src/helpers/imageGeneration.test.ts +456 -0
  40. package/src/helpers/imageGeneration.ts +432 -0
  41. package/src/routes/ai.skill-icons.test.ts +92 -0
  42. package/src/routes/ai.test.ts +80 -0
  43. package/src/routes/ai.ts +33 -144
  44. package/src/routes/escrow.ts +15 -2
  45. package/src/routes/keys.ts +18 -0
  46. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  47. package/src/services/escrow-enclave/logFailure.test.ts +72 -0
  48. package/src/services/escrow-enclave/logFailure.ts +46 -0
  49. package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
  50. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
  51. package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
  52. package/src/services/escrow-enclave/types.ts +26 -2
  53. package/test/keys-auth-share-escrow.spec.ts +103 -0
  54. package/test/keys-escrow.spec.ts +65 -0
  55. package/test/oidc.spec.ts +41 -2
  56. package/tsconfig.bedrock.json +10 -0
@@ -48,6 +48,7 @@ import {
48
48
  getEscrowEnclave,
49
49
  getEscrowBlobStaleReason,
50
50
  EscrowPolicyError,
51
+ EscrowCodeError,
51
52
  EscrowBlobError,
52
53
  EscrowPinMismatchError,
53
54
  EscrowUnavailableError,
@@ -419,6 +420,26 @@ describe('authority enrollment persistence', () => {
419
420
  expect(stored?.requiresConfirmation).toBe(true);
420
421
  expect(stored?.startedNotificationPending).toBe(false);
421
422
  });
423
+ it('persists an awaiting-confirmation hold even when code delivery failed', async () => {
424
+ await enroll();
425
+ const enclave = getEscrowEnclave();
426
+ const create = enclave.createHold.bind(enclave);
427
+ const delivery = { codeSent: false, proof: { ...receipt, kind: 'snapshot' as const } };
428
+ vi.spyOn(enclave, 'createHold').mockImplementation(async input => ({
429
+ ...(await create(input)),
430
+ delivery,
431
+ }));
432
+ __setEscrowEnclaveForTests(
433
+ Object.assign(enclave, { confirmHold: async () => ({ success: true as const }) })
434
+ );
435
+ const started = await start();
436
+ expect(started.codeSent).toBe(false);
437
+ expect(started.delivery).toEqual(delivery);
438
+ const stored = await findEscrowHoldById(started.holdId);
439
+ expect(stored?.holdRecord).toEqual(started.holdRecord);
440
+ expect(stored?.requiresConfirmation).toBe(true);
441
+ expect(stored?.confirmedAt).toBeUndefined();
442
+ });
422
443
  it('persists a cancellation commit receipt before clearing the durable pending marker', async () => {
423
444
  await enroll();
424
445
  const started = await start();
@@ -1464,6 +1485,27 @@ describe('P2-c confirmRecovery reconciliation', () => {
1464
1485
  }));
1465
1486
  beforeEach(() => idempotentConfirmHold.mockClear());
1466
1487
 
1488
+ it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
1489
+ 'returns sanitized confirmation reason %s without confirming',
1490
+ async reason => {
1491
+ const { hold, resumeToken } = await createConfirmableHold();
1492
+ const enclave = getEscrowEnclave();
1493
+ enclave.confirmHold = vi.fn().mockRejectedValue(new EscrowCodeError(reason));
1494
+ try {
1495
+ await expect(
1496
+ getClient().escrow.confirmRecovery({
1497
+ holdId: hold._id,
1498
+ resumeToken,
1499
+ code: '12345678',
1500
+ })
1501
+ ).rejects.toMatchObject({ code: 'FORBIDDEN', message: reason });
1502
+ expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
1503
+ } finally {
1504
+ delete enclave.confirmHold;
1505
+ }
1506
+ }
1507
+ );
1508
+
1467
1509
  it('allows the SDK commit-to-v2-snapshot readback without a second persistence or deadline shift', async () => {
1468
1510
  const { hold, resumeToken } = await createConfirmableHold();
1469
1511
  const snapshotReceipt = {
@@ -1908,10 +1950,14 @@ describe('escrow PIN release', () => {
1908
1950
  it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
1909
1951
  await enrollPin();
1910
1952
  const first = await startPin();
1953
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
1911
1954
  await expect(completePin(first, wrongProof)).rejects.toMatchObject({
1912
1955
  code: 'FORBIDDEN',
1913
1956
  message: 'Incorrect PIN. 9 attempts left.',
1914
1957
  });
1958
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
1959
+ reason: 'pin_mismatch',
1960
+ });
1915
1961
  expect(await findEscrowHoldById(first.holdId)).toMatchObject({
1916
1962
  status: 'cancelled',
1917
1963
  cancelledBy: 'system',
@@ -2259,6 +2305,25 @@ describe('escrow PIN release', () => {
2259
2305
  expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
2260
2306
  });
2261
2307
 
2308
+ it('logs an unexpected non-PIN mismatch once without refunding a PIN attempt', async () => {
2309
+ await enroll();
2310
+ const hold = await start();
2311
+ advanceToRelease(hold);
2312
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
2313
+ const refund = vi.spyOn(models, 'refundEscrowPinAttempt');
2314
+ vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(
2315
+ new EscrowPinMismatchError()
2316
+ );
2317
+ await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
2318
+ code: 'INTERNAL_SERVER_ERROR',
2319
+ });
2320
+ expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
2321
+ reason: 'pin_mismatch',
2322
+ });
2323
+ expect(refund).not.toHaveBeenCalled();
2324
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({ status: 'pending' });
2325
+ });
2326
+
2262
2327
  it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
2263
2328
  'refunds non-mismatch enclave failures: %s',
2264
2329
  async error => {
package/test/oidc.spec.ts CHANGED
@@ -6,6 +6,7 @@ import { issueLoginTicket, redeemLoginTicket } from '../src/cache/login-tickets'
6
6
  import cache from '@cache';
7
7
  import { GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
8
8
  import { getOidcSigningKey, resetOidcSigningKeyForTests } from '../src/helpers/oidc.helpers';
9
+ import { ACCESS_TOKEN_PREFIX } from '../src/helpers/login-ticket.helpers';
9
10
 
10
11
  const { env, entries, sendSecret } = vi.hoisted(() => ({
11
12
  sendSecret: vi.fn(),
@@ -188,8 +189,12 @@ beforeEach(async () => {
188
189
  });
189
190
  });
190
191
  afterEach(async () => {
191
- await app.close();
192
- vi.restoreAllMocks();
192
+ try {
193
+ await app.close();
194
+ } finally {
195
+ vi.useRealTimers();
196
+ vi.restoreAllMocks();
197
+ }
193
198
  });
194
199
 
195
200
  describe('OIDC provider', () => {
@@ -397,6 +402,11 @@ describe('OIDC provider', () => {
397
402
  it.each<BasicMode>(['encoded', 'raw', false])(
398
403
  'exchanges a code using Basic=%s and verifies RS256 through served JWKS',
399
404
  async basic => {
405
+ // Freeze wall-clock time only; Fastify's I/O timers keep running normally.
406
+ // expires_in is remaining time, so an uncontrolled second boundary can
407
+ // make it 299 even though the signed JWT lifetime is exactly 300 seconds.
408
+ vi.useFakeTimers({ toFake: ['Date'] });
409
+ vi.setSystemTime(new Date('2026-10-14T12:00:00.999Z'));
400
410
  const response = await exchange(await code(), {}, basic);
401
411
  expect(response.statusCode).toBe(200);
402
412
  const tokens = response.json();
@@ -427,6 +437,35 @@ describe('OIDC provider', () => {
427
437
  expect(response.headers.pragma).toBe('no-cache');
428
438
  }
429
439
  );
440
+ it('reports 299 seconds remaining when access-token persistence crosses a second', async () => {
441
+ vi.useFakeTimers({ toFake: ['Date'] });
442
+ const start = new Date('2026-10-14T12:00:00.999Z');
443
+ vi.setSystemTime(start);
444
+ const set = cache.set.bind(cache);
445
+ vi.spyOn(cache, 'set').mockImplementation(async (...args) => {
446
+ const result = await set(...args);
447
+ if (args[0].startsWith(ACCESS_TOKEN_PREFIX)) {
448
+ // Advance after JWT signing, before expires_in is calculated.
449
+ vi.setSystemTime(start.getTime() + 1);
450
+ }
451
+ return result;
452
+ });
453
+
454
+ const response = await exchange(await code());
455
+ expect(response.statusCode).toBe(200);
456
+ const tokens = response.json();
457
+ const jwks = createLocalJWKSet((await app.inject('/oidc/jwks')).json());
458
+ const options = { issuer: env.OIDC_ISSUER, audience: env.OIDC_CLIENT_ID };
459
+ const { payload } = await jwtVerify(tokens.id_token, jwks, options);
460
+ expect(payload.exp! - payload.iat!).toBe(300);
461
+ expect(tokens.expires_in).toBe(299);
462
+
463
+ // The clock control must not hide an extended JWT lifetime.
464
+ vi.setSystemTime(payload.exp! * 1000);
465
+ await expect(jwtVerify(tokens.id_token, jwks, options)).rejects.toMatchObject({
466
+ code: 'ERR_JWT_EXPIRED',
467
+ });
468
+ });
430
469
  it('URL-decodes Basic client credentials', async () => {
431
470
  env.OIDC_CLIENT_SECRET = 'a+b:c %';
432
471
  expect((await exchange(await code(), {}, 'encoded')).statusCode).toBe(200);
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": { "noEmit": true },
4
+ "include": [
5
+ "src/helpers/bedrockGeneration.ts",
6
+ "src/helpers/bedrockGeneration.test.ts",
7
+ "src/helpers/bedrockSchema.ts",
8
+ "src/types/skills.ts"
9
+ ]
10
+ }