@learncard/lca-api-service 1.6.3 → 1.7.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 (79) hide show
  1. package/.env.example +2 -0
  2. package/CHANGELOG.md +114 -0
  3. package/config/config.dev.json +1 -0
  4. package/config/config.example.json +1 -0
  5. package/config/config.production.json +1 -0
  6. package/config/config.scouts.dev.json +1 -0
  7. package/config/config.scouts.production.json +1 -0
  8. package/dist/accesslayer/notifications/create.d.ts +2 -1
  9. package/dist/app.d.ts +70 -8
  10. package/dist/config/environment.d.ts +6 -0
  11. package/dist/config/stageConfig.d.ts +4 -0
  12. package/dist/helpers/analyticsEligibility.d.ts +6 -0
  13. package/dist/helpers/brainUrl.d.ts +1 -0
  14. package/dist/helpers/brainUrl.test.d.ts +1 -0
  15. package/dist/helpers/currentProfile.d.ts +6 -0
  16. package/dist/helpers/currentProfile.test.d.ts +1 -0
  17. package/dist/helpers/retryingOnce.d.ts +1 -0
  18. package/dist/helpers/retryingOnce.test.d.ts +1 -0
  19. package/dist/index.js +2 -2
  20. package/dist/models/EscrowHold.d.ts +1 -0
  21. package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
  22. package/dist/models/Notification.d.ts +114 -2
  23. package/dist/models/SigningAuthority.d.ts +1 -1
  24. package/dist/models/UserKey.d.ts +23 -0
  25. package/dist/publish-metafile.json +1 -1
  26. package/dist/routes/credentials.d.ts +1 -1
  27. package/dist/routes/index.d.ts +5 -0
  28. package/dist/routes/keys.d.ts +1 -0
  29. package/dist/routes/notifications.d.ts +65 -4
  30. package/dist/routes/preferences.test.d.ts +1 -0
  31. package/dist/routes/signing-authority.d.ts +3 -3
  32. package/dist/services/escrow-enclave/index.d.ts +2 -0
  33. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  34. package/dist/types/credentials.d.ts +1 -1
  35. package/dist/types/notifications.d.ts +118 -2
  36. package/docs/bedrock-generation.md +7 -6
  37. package/lambdaApp.test.ts +114 -0
  38. package/lambdaApp.ts +23 -11
  39. package/package.json +11 -11
  40. package/scripts/escrow-lambda-http.ts +74 -0
  41. package/scripts/verify-escrow-startup.mjs +463 -0
  42. package/serverless-local.yml +1 -1
  43. package/serverless.yml +1 -1
  44. package/src/accesslayer/notifications/create.test.ts +80 -0
  45. package/src/accesslayer/notifications/create.ts +97 -2
  46. package/src/config/environment.test.ts +27 -1
  47. package/src/config/environment.ts +16 -0
  48. package/src/config/escrowDeployment.test.ts +1 -0
  49. package/src/docker-entry.ts +1 -2
  50. package/src/helpers/analyticsEligibility.ts +15 -0
  51. package/src/helpers/brainUrl.test.ts +13 -0
  52. package/src/helpers/brainUrl.ts +3 -0
  53. package/src/helpers/currentProfile.test.ts +55 -0
  54. package/src/helpers/currentProfile.ts +58 -0
  55. package/src/helpers/locale.helpers.test.ts +4 -1
  56. package/src/helpers/locale.helpers.ts +4 -2
  57. package/src/helpers/retryingOnce.test.ts +33 -0
  58. package/src/helpers/retryingOnce.ts +18 -0
  59. package/src/models/EscrowHold.indexes.test.ts +208 -0
  60. package/src/models/EscrowHold.ts +106 -51
  61. package/src/models/UserKey.ts +69 -1
  62. package/src/routes/escrow.ts +18 -11
  63. package/src/routes/index.ts +5 -8
  64. package/src/routes/keys.ts +8 -0
  65. package/src/routes/notifications.ts +34 -1
  66. package/src/routes/preferences.test.ts +216 -0
  67. package/src/routes/preferences.ts +54 -4
  68. package/src/services/escrow-enclave/index.ts +38 -1
  69. package/src/services/escrow-enclave/logFailure.ts +5 -1
  70. package/src/services/escrow-enclave/production.test.ts +64 -0
  71. package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
  72. package/test/jobs/escrowHoldReminders.spec.ts +21 -4
  73. package/test/keys-escrow.spec.ts +131 -3
  74. package/test/keys-lifecycle-invariants.spec.ts +235 -0
  75. package/test/notifications.spec.ts +102 -0
  76. package/tsconfig.entrypoints.json +1 -1
  77. package/vitest.config.ts +1 -0
  78. package/scripts/node24-runtime.cjs +0 -9
  79. package/scripts/verify-node24-layer.cjs +0 -49
@@ -173,7 +173,8 @@ describe('authority enrollment persistence', () => {
173
173
  delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
174
174
  __setEscrowEnclaveForTests(undefined);
175
175
  });
176
- const setup = async () => {
176
+ /** `enclave`: the label the fake enclave signs; `intent`: the client's intent tenant. */
177
+ const setup = async ({ enclave = 'learncard', intent = 'learncard' } = {}) => {
177
178
  const software = getEscrowEnclave();
178
179
  const attestation = await software.getAttestation();
179
180
  const persisted = await encryptEscrowBlob(
@@ -207,7 +208,7 @@ describe('authority enrollment persistence', () => {
207
208
  nonce: string;
208
209
  }): Promise<AuthorityStateResponse> => ({
209
210
  state: {
210
- tenant: 'learncard',
211
+ tenant: enclave,
211
212
  authorityGeneration: 'fresh-g1',
212
213
  did: input.expectedDid,
213
214
  status: 'empty',
@@ -238,7 +239,7 @@ describe('authority enrollment persistence', () => {
238
239
  const signedIntent = await signEscrowEnrollmentIntent(
239
240
  {
240
241
  version: 2,
241
- tenant: 'learncard',
242
+ tenant: intent,
242
243
  did,
243
244
  authorityGeneration: 'fresh-g1',
244
245
  expectedEnrollmentVersion: 0,
@@ -409,6 +410,112 @@ describe('authority enrollment persistence', () => {
409
410
  expect((await record())?.escrowBlob?.envelope).toEqual(envelope);
410
411
  expect(apply).toHaveBeenCalledTimes(1);
411
412
  });
413
+ describe('authority tenant decoupled from the app tenant (VetPass on the learncard enclave)', () => {
414
+ const vetpassOwner = () =>
415
+ appRouter.createCaller({
416
+ domain: 'localhost%3A3000',
417
+ tenant: { id: 'vetpass', emailBranding: {}, resolvedVia: 'header' as const },
418
+ user: { did, isChallengeValid: true, authorizedDid: false },
419
+ });
420
+ afterEach(() => {
421
+ delete process.env.ESCROW_AUTHORITY_TENANT;
422
+ });
423
+
424
+ it('serves learncard authority state and applies learncard intents for X-Tenant-Id vetpass', async () => {
425
+ const { input, apply, persisted } = await setup();
426
+ const nonce = 'cd'.repeat(32);
427
+ await expect(
428
+ vetpassOwner().escrow.getAuthorityState({ ...auth, nonce })
429
+ ).resolves.toMatchObject({ state: { tenant: 'learncard' } });
430
+ await expect(vetpassOwner().escrow.enroll(input)).resolves.toMatchObject({
431
+ success: true,
432
+ });
433
+ expect(apply).toHaveBeenCalledTimes(1);
434
+ const signedIntent = await signEscrowEnrollmentIntent(
435
+ {
436
+ ...input.signedIntent.intent,
437
+ expectedEnrollmentVersion: 1,
438
+ action: 'disable',
439
+ sourceBlobIdentity: await getEscrowBlobIdentity(persisted),
440
+ targetBlobIdentity: null,
441
+ allowRewrap: false,
442
+ requestId: randomUUID(),
443
+ recoveryEmail: null,
444
+ recoveryLanguage: null,
445
+ },
446
+ pk
447
+ );
448
+ apply.mockResolvedValueOnce({
449
+ envelope: null,
450
+ receipt,
451
+ authorityGeneration: 'fresh-g1',
452
+ enrollmentVersion: 2,
453
+ enrollmentEpoch: 1,
454
+ });
455
+ await expect(
456
+ vetpassOwner().escrow.disable({
457
+ ...auth,
458
+ optOut: false,
459
+ signedIntent,
460
+ sourceEnvelope: persisted,
461
+ })
462
+ ).resolves.toMatchObject({ receipt });
463
+ expect(apply).toHaveBeenCalledTimes(2);
464
+ });
465
+
466
+ it('rejects an app-tenant (vetpass) intent before reaching the enclave', async () => {
467
+ const { input, apply } = await setup({ intent: 'vetpass' });
468
+ await expect(vetpassOwner().escrow.enroll(input)).rejects.toMatchObject({
469
+ code: 'BAD_REQUEST',
470
+ });
471
+ await expect(owner().escrow.enroll(input)).rejects.toMatchObject({
472
+ code: 'BAD_REQUEST',
473
+ });
474
+ expect(apply).not.toHaveBeenCalled();
475
+ expect((await record())?.escrowBlob).toBeUndefined();
476
+ });
477
+
478
+ it('fails closed when the enclave signs a different authority tenant than configured', async () => {
479
+ const { input, apply } = await setup({ enclave: 'scouts' });
480
+ for (const caller of [owner, vetpassOwner]) {
481
+ await expect(
482
+ caller().escrow.getAuthorityState({ ...auth, nonce: 'ef'.repeat(32) })
483
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
484
+ await expect(caller().escrow.enroll(input)).rejects.toMatchObject({
485
+ code: 'FORBIDDEN',
486
+ });
487
+ }
488
+ expect(apply).not.toHaveBeenCalled();
489
+ });
490
+
491
+ it('checks against ESCROW_AUTHORITY_TENANT, not the request tenant', async () => {
492
+ process.env.ESCROW_AUTHORITY_TENANT = 'scouts';
493
+ const { input, apply } = await setup({ enclave: 'scouts', intent: 'scouts' });
494
+ await expect(
495
+ vetpassOwner().escrow.getAuthorityState({ ...auth, nonce: 'ab'.repeat(32) })
496
+ ).resolves.toMatchObject({ state: { tenant: 'scouts' } });
497
+ await expect(vetpassOwner().escrow.enroll(input)).resolves.toMatchObject({
498
+ success: true,
499
+ });
500
+ expect(apply).toHaveBeenCalledTimes(1);
501
+ });
502
+
503
+ it('keeps the per-app tenant on holds started from VetPass in authority mode', async () => {
504
+ await enroll();
505
+ const software = getEscrowEnclave();
506
+ __setEscrowEnclaveForTests(
507
+ Object.assign(software, { confirmHold: async () => ({ success: true as const }) })
508
+ );
509
+ const started = await appRouter
510
+ .createCaller({
511
+ domain: 'localhost%3A3000',
512
+ tenant: { id: 'vetpass', emailBranding: {}, resolvedVia: 'header' as const },
513
+ })
514
+ .escrow.startRecovery({ ...auth, clientEphemeralPublicKey: recipient.publicKey });
515
+ expect((await findEscrowHoldById(started.holdId))?.tenantId).toBe('vetpass');
516
+ });
517
+ });
518
+
412
519
  it('suppresses its own started notification and marks requiresConfirmation for an authority-mode hold', async () => {
413
520
  await enroll();
414
521
  const software = getEscrowEnclave();
@@ -1380,6 +1487,27 @@ describe('A6 escrow recovery', () => {
1380
1487
  }
1381
1488
  });
1382
1489
 
1490
+ it('authenticates completion before migration and fails closed before claiming a valid hold', async () => {
1491
+ setDuration(1);
1492
+ await enroll();
1493
+ const started = await start();
1494
+ advanceToRelease(started);
1495
+ const ready = vi.spyOn(models, 'ensureEscrowHoldsIndexes').mockRejectedValue(
1496
+ new Error('Escrow hold index initialization failed', {
1497
+ cause: { code: 11000, codeName: 'DuplicateKey' },
1498
+ })
1499
+ );
1500
+ await expect(
1501
+ getClient().escrow.completeRecovery({ holdId: started.holdId, resumeToken: 'wrong' })
1502
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
1503
+ expect(ready).not.toHaveBeenCalled();
1504
+ await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
1505
+ code: 'INTERNAL_SERVER_ERROR',
1506
+ });
1507
+ expect(ready).toHaveBeenCalledOnce();
1508
+ expect((await findEscrowHoldById(started.holdId))?.status).toBe('pending');
1509
+ });
1510
+
1383
1511
  it('rejects invalid tokens and removed, unconfirmed, replaced or expired recovery material', async () => {
1384
1512
  setDuration(1);
1385
1513
  await enroll();
@@ -11,6 +11,7 @@ import {
11
11
  activateUserKeyByAuthProvider,
12
12
  createUserKeysIndexes,
13
13
  getUserKeysCollection,
14
+ linkAuthProviderKeepingShares,
14
15
  PROVISIONAL_MIGRATION_TTL_MS,
15
16
  upsertUserKeyByAuthProvider,
16
17
  UserKeyVersionConflictError,
@@ -537,6 +538,7 @@ describe('P0-1 provisional SSS activation', () => {
537
538
  expect(stored?.sssActivationState).toBe('provisional');
538
539
  expect(stored?.migratedFromWeb3Auth).toBe(false);
539
540
  expect(stored?.provisionalCreatedAt).toBeInstanceOf(Date);
541
+ expect(stored?.web3AuthLogin).toEqual({ type: 'firebase', id: uid });
540
542
 
541
543
  // Simulate abandoning recovery confirmation for more than 30 days.
542
544
  const expiredAt = new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000);
@@ -562,6 +564,8 @@ describe('P0-1 provisional SSS activation', () => {
562
564
  expect(purged?.sssActivationState).toBeUndefined();
563
565
  expect(purged?.provisionalCreatedAt).toBeUndefined();
564
566
  expect(purged?.recoveryMethods).toEqual([]);
567
+ // The migration login stays known, so the original login still signs in.
568
+ expect(purged?.web3AuthLogin).toEqual({ type: 'firebase', id: uid });
565
569
  expect(purged?.previousAuthShares).toEqual([]);
566
570
  } finally {
567
571
  await collection.deleteMany({ 'contactMethod.value': email });
@@ -859,3 +863,234 @@ describe('P0-6 fresh DID authorization', () => {
859
863
  }
860
864
  );
861
865
  });
866
+
867
+ describe('Web3Auth migration login', () => {
868
+ it('confirms only the login an account was migrated through', async () => {
869
+ const suffix = `${Date.now()}-${randomUUID()}`;
870
+ const email = `web3auth-login-${suffix}@example.com`;
871
+ const uid = `uid-${suffix}`;
872
+ const otherUid = `other-${suffix}`;
873
+ const token = makeMockToken(email, uid);
874
+ const { learnCard } = await getUser('5'.repeat(64));
875
+ const collection = getUserKeysCollection();
876
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
877
+
878
+ try {
879
+ await caller.keys.storeAuthShare({
880
+ authToken: token,
881
+ providerType: 'firebase',
882
+ primaryDid: learnCard.id.did(),
883
+ authShare: { encryptedData: 'share', encryptedDek: '', iv: '' },
884
+ });
885
+ await caller.keys.markMigrated({ authToken: token, providerType: 'firebase' });
886
+
887
+ const migrating = await collection.findOne({
888
+ authProviders: { $elemMatch: { type: 'firebase', id: uid } },
889
+ });
890
+ expect(migrating?.web3AuthLogin).toEqual({ type: 'firebase', id: uid });
891
+
892
+ // Activate through the route so activation is shown to keep the login.
893
+ await collection.updateOne(
894
+ { _id: migrating!._id },
895
+ {
896
+ $set: {
897
+ recoveryMethods: [
898
+ {
899
+ type: 'backup',
900
+ createdAt: new Date(),
901
+ confirmationStatus: 'confirmed',
902
+ confirmedAt: new Date(),
903
+ shareVersion: migrating!.shareVersion ?? 1,
904
+ },
905
+ ],
906
+ },
907
+ }
908
+ );
909
+ await caller.keys.activate({ authToken: token, providerType: 'firebase' });
910
+
911
+ const migrated = await getClient().keys.getAuthShare({
912
+ authToken: token,
913
+ providerType: 'firebase',
914
+ });
915
+ expect(migrated?.keyProvider).toBe('sss');
916
+ expect(migrated?.isWeb3AuthLogin).toBe(true);
917
+
918
+ // A second login (e.g. added by PIN sign-in) derives a different Web3Auth key.
919
+ await collection.updateOne(
920
+ { _id: migrating!._id },
921
+ { $addToSet: { authProviders: { type: 'firebase', id: otherUid } } }
922
+ );
923
+ const viaOtherLogin = await getClient().keys.getAuthShare({
924
+ authToken: makeMockToken(email, otherUid),
925
+ providerType: 'firebase',
926
+ });
927
+ expect(viaOtherLogin?.keyProvider).toBe('sss');
928
+ expect(viaOtherLogin?.isWeb3AuthLogin).toBe(false);
929
+ } finally {
930
+ await collection.deleteMany({ 'contactMethod.value': email });
931
+ }
932
+ });
933
+
934
+ it.each([
935
+ ['a second linked login', { extraLogin: true }],
936
+ ['a rotated security epoch', { securityEpoch: 1 }],
937
+ ['an already recorded login', { recorded: true }],
938
+ ])('does not record the calling login with %s', async (_label, setup) => {
939
+ const suffix = `${Date.now()}-${randomUUID()}`;
940
+ const email = `migrate-gate-${suffix}@example.com`;
941
+ const uid = `uid-${suffix}`;
942
+ const otherUid = `other-${suffix}`;
943
+ const { learnCard } = await getUser('2'.repeat(64));
944
+ const collection = getUserKeysCollection();
945
+ const key = makeUserKey(email, uid, learnCard.id.did());
946
+
947
+ key.keyProvider = 'web3auth';
948
+ if ('extraLogin' in setup) key.authProviders.push({ type: 'firebase', id: otherUid });
949
+ if ('securityEpoch' in setup) key.securityEpoch = setup.securityEpoch;
950
+ if ('recorded' in setup) key.web3AuthLogin = { type: 'firebase', id: otherUid };
951
+ await collection.insertOne(key);
952
+
953
+ try {
954
+ await expect(
955
+ getClient({ did: learnCard.id.did(), isChallengeValid: true }).keys.markMigrated({
956
+ authToken: makeMockToken(email, uid),
957
+ providerType: 'firebase',
958
+ })
959
+ ).resolves.toEqual({ success: true });
960
+
961
+ const stored = await collection.findOne({ 'contactMethod.value': email });
962
+ expect(stored?.keyProvider).toBe('web3auth');
963
+ expect(stored?.web3AuthLogin).toEqual(
964
+ 'recorded' in setup ? { type: 'firebase', id: otherUid } : undefined
965
+ );
966
+ } finally {
967
+ await collection.deleteMany({ 'contactMethod.value': email });
968
+ }
969
+ });
970
+
971
+ it('refuses a login PIN-linked to a mid-migration account whose activation failed', async () => {
972
+ const suffix = `${Date.now()}-${randomUUID()}`;
973
+ const email = `pin-linked-${suffix}@example.com`;
974
+ const uid = `uid-${suffix}`;
975
+ const linkedUid = `linked-${suffix}`;
976
+ const token = makeMockToken(email, uid);
977
+ const { learnCard } = await getUser('1'.repeat(64));
978
+ const collection = getUserKeysCollection();
979
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
980
+
981
+ try {
982
+ await caller.keys.storeAuthShare({
983
+ authToken: token,
984
+ providerType: 'firebase',
985
+ primaryDid: learnCard.id.did(),
986
+ authShare: { encryptedData: 'share', encryptedDek: '', iv: '' },
987
+ });
988
+ await caller.keys.markMigrated({ authToken: token, providerType: 'firebase' });
989
+ // PIN saved, activation never ran: the record stays provisional 'web3auth'.
990
+ const record = await collection.findOne({ 'contactMethod.value': email });
991
+ expect(record?.keyProvider).toBe('web3auth');
992
+
993
+ await expect(
994
+ linkAuthProviderKeepingShares({
995
+ oldAuthProvider: { type: 'firebase', id: uid },
996
+ newAuthProvider: { type: 'firebase', id: linkedUid },
997
+ primaryDid: learnCard.id.did(),
998
+ shareVersion: record!.shareVersion ?? 1,
999
+ securityEpoch: 0,
1000
+ })
1001
+ ).resolves.not.toBeNull();
1002
+
1003
+ const viaLinked = await getClient().keys.getAuthShare({
1004
+ authToken: makeMockToken(email, linkedUid),
1005
+ providerType: 'firebase',
1006
+ });
1007
+ expect(viaLinked?.keyProvider).toBe('web3auth');
1008
+ expect(viaLinked?.isWeb3AuthLogin).toBe(false);
1009
+
1010
+ const viaOriginal = await getClient().keys.getAuthShare({
1011
+ authToken: token,
1012
+ providerType: 'firebase',
1013
+ });
1014
+ expect(viaOriginal?.isWeb3AuthLogin).toBe(true);
1015
+ } finally {
1016
+ await collection.deleteMany({ 'contactMethod.value': email });
1017
+ }
1018
+ });
1019
+
1020
+ it.each([
1021
+ ['its sole login', {}, true],
1022
+ ['one of two logins', { extraLogin: true }, false],
1023
+ ['a rotated security epoch', { securityEpoch: 1 }, false],
1024
+ ])(
1025
+ 'confirms an unrecorded mid-migration record only through %s',
1026
+ async (_label, setup, expected) => {
1027
+ const suffix = `${Date.now()}-${randomUUID()}`;
1028
+ const email = `unrecorded-${suffix}@example.com`;
1029
+ const uid = `uid-${suffix}`;
1030
+ const { learnCard } = await getUser('e'.repeat(64));
1031
+ const collection = getUserKeysCollection();
1032
+ const key = makeUserKey(email, uid, learnCard.id.did());
1033
+
1034
+ key.keyProvider = 'web3auth';
1035
+ key.sssActivationState = 'provisional';
1036
+ key.provisionalCreatedAt = new Date();
1037
+ if ('extraLogin' in setup)
1038
+ key.authProviders.push({ type: 'firebase', id: `x-${suffix}` });
1039
+ if ('securityEpoch' in setup) key.securityEpoch = setup.securityEpoch;
1040
+ await collection.insertOne(key);
1041
+
1042
+ try {
1043
+ const result = await getClient().keys.getAuthShare({
1044
+ authToken: makeMockToken(email, uid),
1045
+ providerType: 'firebase',
1046
+ });
1047
+ expect(result?.isWeb3AuthLogin).toBe(expected);
1048
+ } finally {
1049
+ await collection.deleteMany({ 'contactMethod.value': email });
1050
+ }
1051
+ }
1052
+ );
1053
+
1054
+ it('never confirms an account created with SSS', async () => {
1055
+ const suffix = `${Date.now()}-${randomUUID()}`;
1056
+ const email = `sss-native-${suffix}@example.com`;
1057
+ const uid = `uid-${suffix}`;
1058
+ const { learnCard } = await getUser('4'.repeat(64));
1059
+ const collection = getUserKeysCollection();
1060
+
1061
+ await collection.insertOne(makeUserKey(email, uid, learnCard.id.did()));
1062
+
1063
+ try {
1064
+ const result = await getClient().keys.getAuthShare({
1065
+ authToken: makeMockToken(email, uid),
1066
+ providerType: 'firebase',
1067
+ });
1068
+ expect(result?.keyProvider).toBe('sss');
1069
+ expect(result?.isWeb3AuthLogin).toBe(false);
1070
+ } finally {
1071
+ await collection.deleteMany({ 'contactMethod.value': email });
1072
+ }
1073
+ });
1074
+
1075
+ it('requires the migration flag as well as the login', async () => {
1076
+ const suffix = `${Date.now()}-${randomUUID()}`;
1077
+ const email = `login-without-flag-${suffix}@example.com`;
1078
+ const uid = `uid-${suffix}`;
1079
+ const { learnCard } = await getUser('3'.repeat(64));
1080
+ const collection = getUserKeysCollection();
1081
+ const key = makeUserKey(email, uid, learnCard.id.did());
1082
+
1083
+ key.web3AuthLogin = { type: 'firebase', id: uid };
1084
+ await collection.insertOne(key);
1085
+
1086
+ try {
1087
+ const result = await getClient().keys.getAuthShare({
1088
+ authToken: makeMockToken(email, uid),
1089
+ providerType: 'firebase',
1090
+ });
1091
+ expect(result?.isWeb3AuthLogin).toBe(false);
1092
+ } finally {
1093
+ await collection.deleteMany({ 'contactMethod.value': email });
1094
+ }
1095
+ });
1096
+ });
@@ -46,6 +46,56 @@ describe('Notifications', () => {
46
46
  });
47
47
 
48
48
  describe('Send Notification', () => {
49
+ it('deduplicates concurrent campaign delivery and retries after a lost storage response', async () => {
50
+ const notification: LCNNotification = {
51
+ type: 'ANNOUNCEMENT',
52
+ to: { did: userA.learnCard.id.did() },
53
+ from: userB.learnCard.id.did(),
54
+ message: { title: 'Explore', body: 'Find your next step' },
55
+ data: {
56
+ metadata: {
57
+ campaignId: 'pilot-clr',
58
+ campaignCreatedAt: '2026-01-01T00:00:00Z',
59
+ },
60
+ },
61
+ };
62
+ const push = vi
63
+ .spyOn(PushNotifications, 'sendPushNotification')
64
+ .mockResolvedValue({ successCount: 1, failureCount: 0, failedTokens: [] });
65
+ try {
66
+ const send = () =>
67
+ userA.clients.authorizedDidAuth.notifications.sendNotification(notification);
68
+ const results = await Promise.all(Array.from({ length: 10 }, send));
69
+ expect(results.every(Boolean)).toBe(true);
70
+ expect(await Notifications.countDocuments({ type: 'ANNOUNCEMENT' })).toBe(1);
71
+ expect(push).toHaveBeenCalledTimes(1);
72
+ await Notifications.updateOne(
73
+ { type: 'ANNOUNCEMENT' },
74
+ { $set: { read: true, archived: true } }
75
+ );
76
+ // A retry after storage succeeded but its HTTP response was lost.
77
+ await expect(send()).resolves.toBe(true);
78
+ expect(push).toHaveBeenCalledTimes(1);
79
+ expect(await Notifications.findOne({ type: 'ANNOUNCEMENT' })).toMatchObject({
80
+ read: true,
81
+ archived: true,
82
+ });
83
+ await userA.clients.authorizedDidAuth.notifications.sendNotification({
84
+ ...notification,
85
+ data: {
86
+ metadata: {
87
+ ...notification.data!.metadata!,
88
+ campaignTestId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
89
+ },
90
+ },
91
+ });
92
+ expect(await Notifications.countDocuments({ type: 'ANNOUNCEMENT' })).toBe(2);
93
+ expect(push).toHaveBeenCalledTimes(2);
94
+ } finally {
95
+ push.mockRestore();
96
+ }
97
+ });
98
+
49
99
  it('should allow an authorized service to send a notification to a user', async () => {
50
100
  await expect(
51
101
  userA.clients.authorizedDidAuth.notifications.sendNotification(
@@ -62,6 +112,58 @@ describe('Notifications', () => {
62
112
  ],
63
113
  });
64
114
  });
115
+ it.each([
116
+ ['relaxed', true, false],
117
+ ['expanded_optional', true, false],
118
+ ['requires_consent', false, true],
119
+ ] as const)(
120
+ 'stores %s contract updates with the right feed and push policy',
121
+ async (kind, read, shouldPush) => {
122
+ const pushSpy = vi
123
+ .spyOn(PushNotifications, 'sendPushNotification')
124
+ .mockResolvedValue({ failureCount: 0, successCount: 0, failedTokens: [] });
125
+ const notification = getTestNotification(
126
+ userA.learnCard.id.did(),
127
+ userB.learnCard.id.did(),
128
+ LCNNotificationTypeEnumValidator.enum.CONSENT_FLOW_CONTRACT_UPDATED,
129
+ undefined,
130
+ {
131
+ contractUri: 'lc:network:contract:policy-test',
132
+ fromVersion: 1,
133
+ toVersion: 2,
134
+ kind,
135
+ diff: { added: [], removed: [], changed: [] },
136
+ reviewUrl:
137
+ 'https://learncard.app/consent-flow/review?uri=lc%3Anetwork%3Acontract%3Apolicy-test',
138
+ }
139
+ );
140
+
141
+ try {
142
+ await expect(
143
+ userA.clients.authorizedDidAuth.notifications.sendNotification(notification)
144
+ ).resolves.toBe(true);
145
+ await expect(
146
+ userA.clients.authorizedDidAuth.notifications.sendNotification(notification)
147
+ ).resolves.toBe(true);
148
+
149
+ expect(
150
+ await Notifications.findOne({
151
+ type: LCNNotificationTypeEnumValidator.enum
152
+ .CONSENT_FLOW_CONTRACT_UPDATED,
153
+ })
154
+ ).toMatchObject({ read });
155
+ await expect(
156
+ Notifications.countDocuments({
157
+ type: LCNNotificationTypeEnumValidator.enum
158
+ .CONSENT_FLOW_CONTRACT_UPDATED,
159
+ })
160
+ ).resolves.toBe(1);
161
+ expect(pushSpy).toHaveBeenCalledTimes(shouldPush ? 1 : 0);
162
+ } finally {
163
+ pushSpy.mockRestore();
164
+ }
165
+ }
166
+ );
65
167
  it('should prevent an unauthorized service to send a notification to a user', async () => {
66
168
  await expect(
67
169
  userA.clients.fullAuth.notifications.sendNotification(
@@ -3,6 +3,6 @@
3
3
  "compilerOptions": {
4
4
  "noEmit": true
5
5
  },
6
- "include": ["lambda.ts", "*Lambda.ts"],
6
+ "include": ["lambda.ts", "*Lambda.ts", "scripts/escrow-lambda-http.ts"],
7
7
  "exclude": ["node_modules", "dist", "build", "test", "src/**/*.test.ts"]
8
8
  }
package/vitest.config.ts CHANGED
@@ -15,6 +15,7 @@ export default createVitestConfig(nodePreset, {
15
15
  'src/**/*.test.ts',
16
16
  'scripts/**/*.test.ts',
17
17
  '*Lambda.test.ts',
18
+ 'lambdaApp.test.ts',
18
19
  'seedMigrationApp.test.ts',
19
20
  'test/keycloak-verify.spec.ts',
20
21
  'test/keycloak-verify.integration.spec.ts',
@@ -1,9 +0,0 @@
1
- /* global module */
2
- // Serverless 3.40 predates Node 24. Extend only LCA's runtime enum, keeping
3
- // all other configuration validation enabled. AWS supports nodejs24.x.
4
- module.exports = class Node24Runtime {
5
- constructor(serverless) {
6
- const runtimes = serverless.configSchemaHandler.schema.definitions.awsLambdaRuntime.enum;
7
- if (!runtimes.includes('nodejs24.x')) runtimes.push('nodejs24.x');
8
- }
9
- };
@@ -1,49 +0,0 @@
1
- /* global require, module, process, Buffer, console */
2
- /* eslint-disable @typescript-eslint/no-require-imports -- Node CommonJS deployment script. */
3
- const assert = require('node:assert/strict');
4
- const fs = require('node:fs');
5
- const path = require('node:path');
6
- const { execFileSync } = require('node:child_process');
7
-
8
- const verifyLayer = (layerRoot, nodeMajor = Number(process.versions.node.split('.')[0])) => {
9
- assert.equal(nodeMajor, 24, 'LCA deployment must run with Node 24');
10
- assert.equal(process.platform, 'linux', 'LCA native layer requires Linux');
11
- assert.equal(process.arch, 'x64', 'LCA native layer requires x86_64');
12
- const modules = path.join(layerRoot, 'nodejs/node_modules/@learncard');
13
- const native = path.join(modules, 'didkit-plugin-node');
14
- const binary = path.join(native, 'index.linux-x64-gnu.node');
15
- assert.ok(fs.existsSync(binary), 'Missing Lambda Linux x64 GNU DIDKit addon');
16
- const binaryNames = fs.readdirSync(native).filter(name => name.endsWith('.node'));
17
- assert.deepEqual(
18
- binaryNames,
19
- ['index.linux-x64-gnu.node'],
20
- 'Layer must contain only the Lambda addon'
21
- );
22
- for (const pkg of ['didkit-plugin-node', 'didkit-plugin']) {
23
- const directory = path.join(modules, pkg);
24
- const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
25
- assert.ok(
26
- fs.existsSync(path.resolve(directory, manifest.main)),
27
- `Missing ${pkg} runtime entry`
28
- );
29
- }
30
- // Node 24 Lambda uses AL2023/glibc 2.34. Reject Ubuntu-built artifacts
31
- // requiring newer libc symbols before Serverless can change the stack.
32
- const versions = execFileSync('readelf', ['--version-info', binary], { encoding: 'utf8' });
33
- for (const match of versions.matchAll(/GLIBC_(\d+)\.(\d+)/g)) {
34
- assert.ok(
35
- Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) <= 34),
36
- `Addon requires ${match[0]}, newer than Lambda AL2023`
37
- );
38
- }
39
- // Exercise stable N-API with a deterministic synthetic key; never log key material.
40
- const addon = require(binary);
41
- const key = addon.generateEd25519KeyFromBytes(Buffer.alloc(32, 1));
42
- assert.match(addon.keyToDid('key', key), /^did:key:/);
43
- };
44
-
45
- module.exports = { verifyLayer };
46
- if (require.main === module) {
47
- verifyLayer(path.resolve('didkit-native-layer'));
48
- console.log('LCA Node 24 native layer ABI, libc and package checks passed');
49
- }