@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.
- package/.env.example +2 -0
- package/CHANGELOG.md +114 -0
- package/config/config.dev.json +1 -0
- package/config/config.example.json +1 -0
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/dist/accesslayer/notifications/create.d.ts +2 -1
- package/dist/app.d.ts +70 -8
- package/dist/config/environment.d.ts +6 -0
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/helpers/analyticsEligibility.d.ts +6 -0
- package/dist/helpers/brainUrl.d.ts +1 -0
- package/dist/helpers/brainUrl.test.d.ts +1 -0
- package/dist/helpers/currentProfile.d.ts +6 -0
- package/dist/helpers/currentProfile.test.d.ts +1 -0
- package/dist/helpers/retryingOnce.d.ts +1 -0
- package/dist/helpers/retryingOnce.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +1 -0
- package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
- package/dist/models/Notification.d.ts +114 -2
- package/dist/models/SigningAuthority.d.ts +1 -1
- package/dist/models/UserKey.d.ts +23 -0
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/index.d.ts +5 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +65 -4
- package/dist/routes/preferences.test.d.ts +1 -0
- package/dist/routes/signing-authority.d.ts +3 -3
- package/dist/services/escrow-enclave/index.d.ts +2 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/types/credentials.d.ts +1 -1
- package/dist/types/notifications.d.ts +118 -2
- package/docs/bedrock-generation.md +7 -6
- package/lambdaApp.test.ts +114 -0
- package/lambdaApp.ts +23 -11
- package/package.json +11 -11
- package/scripts/escrow-lambda-http.ts +74 -0
- package/scripts/verify-escrow-startup.mjs +463 -0
- package/serverless-local.yml +1 -1
- package/serverless.yml +1 -1
- package/src/accesslayer/notifications/create.test.ts +80 -0
- package/src/accesslayer/notifications/create.ts +97 -2
- package/src/config/environment.test.ts +27 -1
- package/src/config/environment.ts +16 -0
- package/src/config/escrowDeployment.test.ts +1 -0
- package/src/docker-entry.ts +1 -2
- package/src/helpers/analyticsEligibility.ts +15 -0
- package/src/helpers/brainUrl.test.ts +13 -0
- package/src/helpers/brainUrl.ts +3 -0
- package/src/helpers/currentProfile.test.ts +55 -0
- package/src/helpers/currentProfile.ts +58 -0
- package/src/helpers/locale.helpers.test.ts +4 -1
- package/src/helpers/locale.helpers.ts +4 -2
- package/src/helpers/retryingOnce.test.ts +33 -0
- package/src/helpers/retryingOnce.ts +18 -0
- package/src/models/EscrowHold.indexes.test.ts +208 -0
- package/src/models/EscrowHold.ts +106 -51
- package/src/models/UserKey.ts +69 -1
- package/src/routes/escrow.ts +18 -11
- package/src/routes/index.ts +5 -8
- package/src/routes/keys.ts +8 -0
- package/src/routes/notifications.ts +34 -1
- package/src/routes/preferences.test.ts +216 -0
- package/src/routes/preferences.ts +54 -4
- package/src/services/escrow-enclave/index.ts +38 -1
- package/src/services/escrow-enclave/logFailure.ts +5 -1
- package/src/services/escrow-enclave/production.test.ts +64 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
- package/test/jobs/escrowHoldReminders.spec.ts +21 -4
- package/test/keys-escrow.spec.ts +131 -3
- package/test/keys-lifecycle-invariants.spec.ts +235 -0
- package/test/notifications.spec.ts +102 -0
- package/tsconfig.entrypoints.json +1 -1
- package/vitest.config.ts +1 -0
- package/scripts/node24-runtime.cjs +0 -9
- package/scripts/verify-node24-layer.cjs +0 -49
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -173,7 +173,8 @@ describe('authority enrollment persistence', () => {
|
|
|
173
173
|
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
174
174
|
__setEscrowEnclaveForTests(undefined);
|
|
175
175
|
});
|
|
176
|
-
|
|
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:
|
|
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:
|
|
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(
|
package/vitest.config.ts
CHANGED
|
@@ -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
|
-
}
|