@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,103 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ import { client } from '@mongo';
4
+ import { createUserKeysIndexes, getUserKeysCollection, type MongoUserKeyType } from '@models';
5
+
6
+ import { getClient } from './helpers/getClient';
7
+
8
+ const makeMockToken = (email: string, uid: string): string => {
9
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
10
+
11
+ return `header.${payload}.signature`;
12
+ };
13
+
14
+ const makeUser = (
15
+ uid: string,
16
+ email: string,
17
+ recoveryMethods: MongoUserKeyType['recoveryMethods']
18
+ ): MongoUserKeyType => {
19
+ const now = new Date();
20
+
21
+ return {
22
+ contactMethod: { type: 'email', value: email },
23
+ authProviders: [{ type: 'firebase', id: uid }],
24
+ primaryDid: `did:key:z${uid}`,
25
+ linkedDids: [],
26
+ keyProvider: 'sss',
27
+ authShare: { encryptedData: 'auth-share', encryptedDek: '', iv: '' },
28
+ shareVersion: 3,
29
+ shareUpdatedAt: now,
30
+ previousAuthShares: [],
31
+ securityLevel: 'enhanced',
32
+ recoveryMethods,
33
+ migratedFromWeb3Auth: false,
34
+ sssActivationState: 'active',
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ };
38
+ };
39
+
40
+ beforeAll(async () => {
41
+ process.env.IS_E2E_TEST = 'true';
42
+ process.env.SEED ||= 'a'.repeat(64);
43
+ await client.connect();
44
+ await createUserKeysIndexes();
45
+ });
46
+
47
+ afterAll(async () => {
48
+ await client.close();
49
+ });
50
+
51
+ describe('getAuthShare priorEscrowEnrollment', () => {
52
+ const statusFor = async (recoveryMethods: MongoUserKeyType['recoveryMethods']) => {
53
+ const uid = `auth-share-escrow-${randomUUID()}`;
54
+ const email = `${uid}@example.com`;
55
+ const collection = getUserKeysCollection();
56
+ await collection.insertOne(makeUser(uid, email, recoveryMethods));
57
+ try {
58
+ return await getClient().keys.getAuthShare({
59
+ authToken: makeMockToken(email, uid),
60
+ providerType: 'firebase',
61
+ });
62
+ } finally {
63
+ await collection.deleteMany({ 'authProviders.id': uid });
64
+ }
65
+ };
66
+ const now = new Date();
67
+
68
+ it('is false for an escrow method confirmed at the current share version', async () => {
69
+ const status = await statusFor([
70
+ {
71
+ type: 'escrow',
72
+ createdAt: now,
73
+ shareVersion: 3,
74
+ confirmationStatus: 'confirmed',
75
+ confirmedAt: now,
76
+ },
77
+ ]);
78
+ expect(status?.priorEscrowEnrollment).toBe(false);
79
+ expect(status?.recoveryMethods).toHaveLength(1);
80
+ });
81
+
82
+ it('is true for an escrow method left pending by an identity-recovery rebind', async () => {
83
+ const status = await statusFor([
84
+ { type: 'escrow', createdAt: now, shareVersion: 3, confirmationStatus: 'pending' },
85
+ ]);
86
+ expect(status?.priorEscrowEnrollment).toBe(true);
87
+ // The pending method itself is still never exposed.
88
+ expect(status?.recoveryMethods).toEqual([]);
89
+ });
90
+
91
+ it('is false when the account has no escrow method', async () => {
92
+ const status = await statusFor([
93
+ {
94
+ type: 'phrase',
95
+ createdAt: now,
96
+ shareVersion: 3,
97
+ confirmationStatus: 'confirmed',
98
+ confirmedAt: now,
99
+ },
100
+ ]);
101
+ expect(status?.priorEscrowEnrollment).toBe(false);
102
+ });
103
+ });
@@ -676,6 +676,82 @@ describe('P0-1 provisional SSS activation', () => {
676
676
  });
677
677
  });
678
678
 
679
+ describe('P0-1 provisional purge spares escrow-enrolled migrations', () => {
680
+ const expiredMigration = (
681
+ email: string,
682
+ uid: string,
683
+ did: string,
684
+ escrow: { blob: boolean; method?: 'confirmed' | 'pending' }
685
+ ): MongoUserKeyType => {
686
+ const key = makeUserKey(email, uid, did, 'migrated-share');
687
+
688
+ Object.assign(key, {
689
+ keyProvider: 'web3auth' as const,
690
+ sssActivationState: 'provisional' as const,
691
+ provisionalCreatedAt: new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000),
692
+ recoveryMethods: escrow.method
693
+ ? [
694
+ {
695
+ type: 'escrow' as const,
696
+ createdAt: new Date(),
697
+ confirmationStatus: escrow.method,
698
+ ...(escrow.method === 'confirmed' ? { confirmedAt: new Date() } : {}),
699
+ shareVersion: 1,
700
+ },
701
+ ]
702
+ : [],
703
+ // Only presence matters to the purge filter.
704
+ ...(escrow.blob
705
+ ? { escrowBlob: { shareVersion: 1 } as unknown as MongoUserKeyType['escrowBlob'] }
706
+ : {}),
707
+ });
708
+
709
+ return key;
710
+ };
711
+
712
+ it.each([
713
+ ['confirmed escrow enrollment', false, { blob: true, method: 'confirmed' as const }],
714
+ [
715
+ 'escrow blob without a confirmed method',
716
+ true,
717
+ { blob: true, method: 'pending' as const },
718
+ ],
719
+ ['confirmed method without a blob', true, { blob: false, method: 'confirmed' as const }],
720
+ ])('%s → purged: %s', async (_name, purged, escrow) => {
721
+ const suffix = `${Date.now()}-${randomUUID()}`;
722
+ const email = `escrow-purge-${suffix}@example.com`;
723
+ const uid = `uid-${suffix}`;
724
+ const { learnCard } = await getUser('5'.repeat(64));
725
+ const collection = getUserKeysCollection();
726
+
727
+ await collection.insertOne(expiredMigration(email, uid, learnCard.id.did(), escrow));
728
+
729
+ try {
730
+ const result = await getClient().keys.getAuthShare({
731
+ authToken: makeMockToken(email, uid),
732
+ providerType: 'firebase',
733
+ });
734
+ const stored = await collection.findOne({
735
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
736
+ });
737
+
738
+ if (purged) {
739
+ expect(result?.authShare).toBeNull();
740
+ expect(stored?.authShare).toBeUndefined();
741
+ expect(stored?.escrowBlob).toBeUndefined();
742
+ expect(stored?.sssActivationState).toBeUndefined();
743
+ } else {
744
+ expect(stored?.authShare?.encryptedData).toBe('migrated-share');
745
+ expect(stored?.escrowBlob).toBeDefined();
746
+ expect(stored?.sssActivationState).toBe('provisional');
747
+ expect(stored?.keyProvider).toBe('web3auth');
748
+ }
749
+ } finally {
750
+ await collection.deleteMany({ 'contactMethod.value': email });
751
+ }
752
+ });
753
+ });
754
+
679
755
  describe('P0-6 fresh DID authorization', () => {
680
756
  it('issues short-lived challenges that can only be consumed once', async () => {
681
757
  const did = `did:key:${randomUUID()}`;
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
+ }
package/tsconfig.json CHANGED
@@ -17,21 +17,20 @@
17
17
  "emitDeclarationOnly": true,
18
18
  "sourceMap": true,
19
19
  "rootDir": ".",
20
- "baseUrl": ".",
21
20
  "paths": {
22
- "@accesslayer/*": ["src/accesslayer/*"],
23
- "@cache": ["src/cache/index.ts"],
24
- "@cache/*": ["src/cache/*"],
25
- "@config/*": ["src/config/*"],
26
- "@environment": ["src/config/environment.ts"],
27
- "@helpers/*": ["src/helpers/*"],
28
- "@instance": ["src/instance.ts"],
29
- "@firebase": ["src/firebase.ts"],
30
- "@mongo": ["src/mongo.ts"],
31
- "@models": ["src/models/index.ts"],
32
- "@routes": ["src/routes/index.ts"],
33
- "@routes/*": ["src/routes/*"],
34
- "types/*": ["src/types/*"]
21
+ "@accesslayer/*": ["./src/accesslayer/*"],
22
+ "@cache": ["./src/cache/index.ts"],
23
+ "@cache/*": ["./src/cache/*"],
24
+ "@config/*": ["./src/config/*"],
25
+ "@environment": ["./src/config/environment.ts"],
26
+ "@helpers/*": ["./src/helpers/*"],
27
+ "@instance": ["./src/instance.ts"],
28
+ "@firebase": ["./src/firebase.ts"],
29
+ "@mongo": ["./src/mongo.ts"],
30
+ "@models": ["./src/models/index.ts"],
31
+ "@routes": ["./src/routes/index.ts"],
32
+ "@routes/*": ["./src/routes/*"],
33
+ "types/*": ["./src/types/*"]
35
34
  },
36
35
  "allowJs": false
37
36
  },