@learncard/lca-api-service 1.6.2 → 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/AGENTS.md +1 -1
- package/CHANGELOG.md +132 -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 +30 -12
- 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 +149 -3
- package/test/keys-lifecycle-invariants.spec.ts +235 -0
- package/test/keys-pin-sign-in.spec.ts +34 -2
- 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();
|
|
@@ -1971,6 +2099,24 @@ describe('escrow PIN release', () => {
|
|
|
1971
2099
|
expect((await record())?.escrowPin?.failedAttempts).toBe(2);
|
|
1972
2100
|
});
|
|
1973
2101
|
|
|
2102
|
+
it('never reports "Incorrect PIN." for a mismatch whose wrong entry was not recorded', async () => {
|
|
2103
|
+
// The record changed under the compare (re-enrolled/rotated elsewhere): clients
|
|
2104
|
+
// count every "Incorrect PIN." as their own recorded entry (own-typos rule).
|
|
2105
|
+
await enrollPin();
|
|
2106
|
+
const hold = await startPin();
|
|
2107
|
+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
2108
|
+
vi.spyOn(models, 'recordEscrowPinFailure').mockResolvedValueOnce(null);
|
|
2109
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
2110
|
+
code: 'CONFLICT',
|
|
2111
|
+
message: 'Automatic recovery changed; please refresh.',
|
|
2112
|
+
});
|
|
2113
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
2114
|
+
status: 'cancelled',
|
|
2115
|
+
cancelReason: 'pin-mismatch',
|
|
2116
|
+
});
|
|
2117
|
+
expect((await record())?.escrowPin?.verifiedFailedAttempts ?? 0).toBe(0);
|
|
2118
|
+
});
|
|
2119
|
+
|
|
1974
2120
|
it('locks after ten mismatches and still permits delayed recovery', async () => {
|
|
1975
2121
|
await enrollPin();
|
|
1976
2122
|
let lockingHoldId = '';
|
|
@@ -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
|
+
});
|
|
@@ -724,8 +724,40 @@ describe('same-PIN budget refresh with expected charges', () => {
|
|
|
724
724
|
await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
725
725
|
code: 'CONFLICT',
|
|
726
726
|
});
|
|
727
|
-
|
|
728
|
-
|
|
727
|
+
|
|
728
|
+
expect(securityState(await record())).toEqual(securityState(before));
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("refreshes when the expected charges include the owner's own wrong entry (own-typos rule)", async () => {
|
|
732
|
+
await enrollPin();
|
|
733
|
+
// The owner mistypes once in this sign-in, then releases with the right PIN.
|
|
734
|
+
await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
|
|
735
|
+
code: 'FORBIDDEN',
|
|
736
|
+
});
|
|
737
|
+
await completePin(await startPin());
|
|
738
|
+
expect(await pinStatus()).toMatchObject({ attemptsRemaining: 8, wrongAttempts: 1 });
|
|
739
|
+
|
|
740
|
+
await refresh({ attemptsRemaining: 8, wrongAttempts: 1 });
|
|
741
|
+
|
|
742
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
743
|
+
salt: otherPinSalt,
|
|
744
|
+
failedAttempts: 0,
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('refuses a refresh whose wrong entries do not match the record, or exceed the charges', async () => {
|
|
749
|
+
await enrollPin();
|
|
750
|
+
await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
|
|
751
|
+
code: 'FORBIDDEN',
|
|
752
|
+
});
|
|
753
|
+
await completePin(await startPin());
|
|
754
|
+
const before = await record();
|
|
755
|
+
|
|
756
|
+
// A foreign wrong entry the client did not count.
|
|
757
|
+
await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 0 })).rejects.toMatchObject({
|
|
758
|
+
code: 'CONFLICT',
|
|
759
|
+
});
|
|
760
|
+
await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 3 })).rejects.toMatchObject({
|
|
729
761
|
code: 'CONFLICT',
|
|
730
762
|
});
|
|
731
763
|
|
|
@@ -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
|
-
};
|