@learncard/lca-api-service 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/index.js +2 -2
- package/dist/publish-metafile.json +1 -1
- package/package.json +6 -6
- package/project.json +1 -1
- package/src/models/EscrowHold.ts +6 -3
- package/src/models/UserKey.ts +14 -0
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/tsconfig.json +13 -14
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/lca-api-service",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
7
7
|
},
|
|
8
|
-
"packageManager": "bun@1.4.
|
|
8
|
+
"packageManager": "bun@1.4.4",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"types": "dist/index.d.ts",
|
|
11
11
|
"scripts": {
|
|
@@ -68,13 +68,13 @@
|
|
|
68
68
|
"@fastify/static": "^10.1.1",
|
|
69
69
|
"@learncard/core": "9.4.41",
|
|
70
70
|
"@learncard/did-web-plugin": "1.1.41",
|
|
71
|
-
"@learncard/didkit-plugin": "1.10.
|
|
72
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
71
|
+
"@learncard/didkit-plugin": "1.10.7",
|
|
72
|
+
"@learncard/didkit-plugin-node": "0.3.7",
|
|
73
73
|
"@learncard/email-templates": "1.2.1",
|
|
74
74
|
"@learncard/helpers": "1.7.1",
|
|
75
|
-
"@learncard/init": "2.5.
|
|
75
|
+
"@learncard/init": "2.5.7",
|
|
76
76
|
"@learncard/types": "5.24.0",
|
|
77
|
-
"@learncard/sss-key-manager": "0.2.
|
|
77
|
+
"@learncard/sss-key-manager": "0.2.5",
|
|
78
78
|
"@sentry/serverless": "^7.61.0",
|
|
79
79
|
"@trpc/server": "11.8.0",
|
|
80
80
|
"@types/jsonwebtoken": "^9.0.8",
|
package/project.json
CHANGED
package/src/models/EscrowHold.ts
CHANGED
|
@@ -282,7 +282,8 @@ export const findEscrowHoldsPendingEnclaveCancellation = async (
|
|
|
282
282
|
enclaveCancelPendingAt: { $exists: true },
|
|
283
283
|
enclaveCancelConfirmedAt: { $exists: false },
|
|
284
284
|
})
|
|
285
|
-
|
|
285
|
+
// Mongo sorts are unstable on ties; the unique ID makes bounded batches repeatable.
|
|
286
|
+
.sort({ enclaveCancelAttempts: 1, releaseAfter: 1, _id: 1 })
|
|
286
287
|
.limit(limit)
|
|
287
288
|
.toArray();
|
|
288
289
|
|
|
@@ -499,7 +500,8 @@ export const findEscrowHoldsPendingStartNotification = async (
|
|
|
499
500
|
): Promise<EscrowHold[]> =>
|
|
500
501
|
getEscrowHoldsCollection()
|
|
501
502
|
.find({ status: 'pending', startedNotificationPending: true })
|
|
502
|
-
|
|
503
|
+
// Keep tied start-notification batches deterministic before the limit.
|
|
504
|
+
.sort({ startedNotificationAttemptedAt: 1, requestedAt: 1, _id: 1 })
|
|
503
505
|
.limit(limit)
|
|
504
506
|
.toArray();
|
|
505
507
|
|
|
@@ -563,7 +565,8 @@ export const findEscrowHoldsDueForReminder = async (
|
|
|
563
565
|
releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
|
|
564
566
|
'notifications.kind': { $ne: 'reminder' },
|
|
565
567
|
})
|
|
566
|
-
|
|
568
|
+
// Keep tied reminder batches deterministic before the limit.
|
|
569
|
+
.sort({ reminderAttemptedAt: 1, releaseAfter: 1, _id: 1 })
|
|
567
570
|
.limit(limit)
|
|
568
571
|
.toArray();
|
|
569
572
|
|
package/src/models/UserKey.ts
CHANGED
|
@@ -1301,6 +1301,12 @@ export const activateUserKeyByAuthProvider = async (
|
|
|
1301
1301
|
/**
|
|
1302
1302
|
* Lazily discard abandoned migration shares after the P0-1 TTL. New-user
|
|
1303
1303
|
* provisional records are excluded because their keyProvider is already SSS.
|
|
1304
|
+
*
|
|
1305
|
+
* A migration with a confirmed escrow enrollment is not abandoned: the user
|
|
1306
|
+
* skipped the PIN, but escrow can recover them, and staying provisional keeps
|
|
1307
|
+
* the faster Web3Auth re-derive on new devices. Such records stay until the
|
|
1308
|
+
* user activates (e.g. sets a PIN) or operators activate them before Web3Auth
|
|
1309
|
+
* is retired (internal-docs/architecture/web3auth-sss-migration-runbook.md).
|
|
1304
1310
|
*/
|
|
1305
1311
|
export const purgeExpiredProvisionalMigrationByAuthProvider = async (
|
|
1306
1312
|
authProvider: AuthProviderMapping,
|
|
@@ -1313,6 +1319,14 @@ export const purgeExpiredProvisionalMigrationByAuthProvider = async (
|
|
|
1313
1319
|
keyProvider: 'web3auth',
|
|
1314
1320
|
sssActivationState: 'provisional',
|
|
1315
1321
|
provisionalCreatedAt: { $lte: cutoff },
|
|
1322
|
+
$nor: [
|
|
1323
|
+
{
|
|
1324
|
+
escrowBlob: { $exists: true },
|
|
1325
|
+
recoveryMethods: {
|
|
1326
|
+
$elemMatch: { type: 'escrow', confirmationStatus: 'confirmed' },
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
],
|
|
1316
1330
|
},
|
|
1317
1331
|
{
|
|
1318
1332
|
$set: {
|
|
@@ -15,6 +15,13 @@ import {
|
|
|
15
15
|
hashEscrowResumeToken,
|
|
16
16
|
recordEscrowHoldNotification,
|
|
17
17
|
computeEffectiveReleaseAfter,
|
|
18
|
+
findEscrowHoldsDueForReminder,
|
|
19
|
+
findEscrowHoldsPendingStartNotification,
|
|
20
|
+
findEscrowHoldsPendingEnclaveCancellation,
|
|
21
|
+
claimEscrowHoldForReminder,
|
|
22
|
+
releaseEscrowReminderClaim,
|
|
23
|
+
recordEscrowStartNotificationAttempt,
|
|
24
|
+
recordEscrowEnclaveCancelAttempt,
|
|
18
25
|
ESCROW_RELEASE_TIME_MARGIN_MS,
|
|
19
26
|
ESCROW_RELEASE_WARNING_GAP_MS,
|
|
20
27
|
type AuthProviderMapping,
|
|
@@ -41,7 +48,11 @@ const makeProvider = (): AuthProviderMapping => {
|
|
|
41
48
|
|
|
42
49
|
/** Every seed() call gets its own authProvider (see test/models/escrow.spec.ts
|
|
43
50
|
* for why: only one pending hold-policy hold is allowed per authProvider). */
|
|
44
|
-
const seed = async (
|
|
51
|
+
const seed = async (
|
|
52
|
+
releaseAfter: Date,
|
|
53
|
+
releasePolicy: 'hold' | 'pin' = 'hold',
|
|
54
|
+
holdId = randomUUID()
|
|
55
|
+
) => {
|
|
45
56
|
const provider = makeProvider();
|
|
46
57
|
await upsertUserKeyByAuthProvider(
|
|
47
58
|
{ type: 'email', value: `${provider.id}@example.com` },
|
|
@@ -54,7 +65,7 @@ const seed = async (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold')
|
|
|
54
65
|
return createEscrowHold({
|
|
55
66
|
holdRecord: {
|
|
56
67
|
hold: {
|
|
57
|
-
holdId
|
|
68
|
+
holdId,
|
|
58
69
|
did: 'did:key:test',
|
|
59
70
|
shareVersion: 1,
|
|
60
71
|
blobHash: 'ab'.repeat(32),
|
|
@@ -117,6 +128,75 @@ afterAll(async () => {
|
|
|
117
128
|
});
|
|
118
129
|
|
|
119
130
|
describe('runEscrowHoldReminders', () => {
|
|
131
|
+
describe.each(['reminder', 'started', 'cancelled'] as const)('%s queue ordering', queue => {
|
|
132
|
+
it.each([false, true])(
|
|
133
|
+
'keeps tied batches stable and rotates retries (previously attempted: %s)',
|
|
134
|
+
async previouslyAttempted => {
|
|
135
|
+
const now = new Date();
|
|
136
|
+
const previousAttempt = new Date(now.getTime() - HOUR);
|
|
137
|
+
// More than a default delivery batch. Insert backwards so physical
|
|
138
|
+
// insertion order cannot accidentally satisfy the expected ID order.
|
|
139
|
+
const ids = Array.from(
|
|
140
|
+
{ length: 13 },
|
|
141
|
+
(_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`
|
|
142
|
+
);
|
|
143
|
+
for (const id of [...ids].reverse()) {
|
|
144
|
+
await seed(new Date(now.getTime() + HOUR), 'hold', id);
|
|
145
|
+
}
|
|
146
|
+
await getEscrowHoldsCollection().updateMany(
|
|
147
|
+
{},
|
|
148
|
+
{
|
|
149
|
+
$set: {
|
|
150
|
+
requestedAt: previousAttempt,
|
|
151
|
+
...(queue === 'started' ? { startedNotificationPending: true } : {}),
|
|
152
|
+
...(queue === 'cancelled'
|
|
153
|
+
? {
|
|
154
|
+
status: 'cancelled' as const,
|
|
155
|
+
enclaveCancelPendingAt: previousAttempt,
|
|
156
|
+
enclaveCancelAttempts: previouslyAttempted ? 2 : 0,
|
|
157
|
+
}
|
|
158
|
+
: previouslyAttempted
|
|
159
|
+
? queue === 'reminder'
|
|
160
|
+
? { reminderAttemptedAt: previousAttempt }
|
|
161
|
+
: { startedNotificationAttemptedAt: previousAttempt }
|
|
162
|
+
: {}),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
const readBatch = () =>
|
|
167
|
+
queue === 'reminder'
|
|
168
|
+
? findEscrowHoldsDueForReminder(now, 5)
|
|
169
|
+
: queue === 'started'
|
|
170
|
+
? findEscrowHoldsPendingStartNotification(5)
|
|
171
|
+
: findEscrowHoldsPendingEnclaveCancellation(5);
|
|
172
|
+
|
|
173
|
+
for (let repeat = 0; repeat < 3; repeat += 1) {
|
|
174
|
+
expect((await readBatch()).map(hold => hold._id)).toEqual(ids.slice(0, 5));
|
|
175
|
+
}
|
|
176
|
+
// Failed deliveries remain eligible. Updating their real attempt
|
|
177
|
+
// markers must put untouched rows first, even with tied timestamps.
|
|
178
|
+
for (let offset = 0; offset < ids.length; offset += 5) {
|
|
179
|
+
const batch = await readBatch();
|
|
180
|
+
const remaining = Math.min(5, ids.length - offset);
|
|
181
|
+
expect(batch.slice(0, remaining).map(hold => hold._id)).toEqual(
|
|
182
|
+
ids.slice(offset, offset + remaining)
|
|
183
|
+
);
|
|
184
|
+
for (const hold of batch) {
|
|
185
|
+
if (queue === 'reminder') {
|
|
186
|
+
const claimed = await claimEscrowHoldForReminder(hold._id, now);
|
|
187
|
+
expect(claimed).not.toBeNull();
|
|
188
|
+
if (claimed) await releaseEscrowReminderClaim(claimed);
|
|
189
|
+
} else if (queue === 'started') {
|
|
190
|
+
await recordEscrowStartNotificationAttempt(hold._id, now);
|
|
191
|
+
} else {
|
|
192
|
+
expect(await recordEscrowEnclaveCancelAttempt(hold._id)).toBe(true);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
120
200
|
it('bounds cancellation retries and gives unattempted cancellations priority', async () => {
|
|
121
201
|
const now = new Date();
|
|
122
202
|
const holds = await Promise.all(
|
|
@@ -129,9 +209,13 @@ describe('runEscrowHoldReminders', () => {
|
|
|
129
209
|
await runEscrowHoldReminders({ now, limit: 200 });
|
|
130
210
|
const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
|
|
131
211
|
expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 0, 1, 1]);
|
|
132
|
-
|
|
212
|
+
// UUIDs are ASCII: default string ordering matches Mongo's byte ordering.
|
|
213
|
+
const unattemptedId = firstRun
|
|
214
|
+
.filter(hold => hold?.enclaveCancelAttempts === 0)
|
|
215
|
+
.map(hold => hold!._id)
|
|
216
|
+
.sort()[0]!;
|
|
133
217
|
await runEscrowHoldReminders({ now, limit: 1 });
|
|
134
|
-
expect((await findEscrowHoldById(
|
|
218
|
+
expect((await findEscrowHoldById(unattemptedId))?.enclaveCancelAttempts).toBe(1);
|
|
135
219
|
for (const hold of holds) {
|
|
136
220
|
const stored = await findEscrowHoldById(hold._id);
|
|
137
221
|
expect(stored?.enclaveCancelPendingAt).toBeInstanceOf(Date);
|
|
@@ -676,6 +676,82 @@ describe('P0-1 provisional SSS activation', () => {
|
|
|
676
676
|
});
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
describe('P0-1 provisional purge spares escrow-enrolled migrations', () => {
|
|
680
|
+
const expiredMigration = (
|
|
681
|
+
email: string,
|
|
682
|
+
uid: string,
|
|
683
|
+
did: string,
|
|
684
|
+
escrow: { blob: boolean; method?: 'confirmed' | 'pending' }
|
|
685
|
+
): MongoUserKeyType => {
|
|
686
|
+
const key = makeUserKey(email, uid, did, 'migrated-share');
|
|
687
|
+
|
|
688
|
+
Object.assign(key, {
|
|
689
|
+
keyProvider: 'web3auth' as const,
|
|
690
|
+
sssActivationState: 'provisional' as const,
|
|
691
|
+
provisionalCreatedAt: new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000),
|
|
692
|
+
recoveryMethods: escrow.method
|
|
693
|
+
? [
|
|
694
|
+
{
|
|
695
|
+
type: 'escrow' as const,
|
|
696
|
+
createdAt: new Date(),
|
|
697
|
+
confirmationStatus: escrow.method,
|
|
698
|
+
...(escrow.method === 'confirmed' ? { confirmedAt: new Date() } : {}),
|
|
699
|
+
shareVersion: 1,
|
|
700
|
+
},
|
|
701
|
+
]
|
|
702
|
+
: [],
|
|
703
|
+
// Only presence matters to the purge filter.
|
|
704
|
+
...(escrow.blob
|
|
705
|
+
? { escrowBlob: { shareVersion: 1 } as unknown as MongoUserKeyType['escrowBlob'] }
|
|
706
|
+
: {}),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return key;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
it.each([
|
|
713
|
+
['confirmed escrow enrollment', false, { blob: true, method: 'confirmed' as const }],
|
|
714
|
+
[
|
|
715
|
+
'escrow blob without a confirmed method',
|
|
716
|
+
true,
|
|
717
|
+
{ blob: true, method: 'pending' as const },
|
|
718
|
+
],
|
|
719
|
+
['confirmed method without a blob', true, { blob: false, method: 'confirmed' as const }],
|
|
720
|
+
])('%s → purged: %s', async (_name, purged, escrow) => {
|
|
721
|
+
const suffix = `${Date.now()}-${randomUUID()}`;
|
|
722
|
+
const email = `escrow-purge-${suffix}@example.com`;
|
|
723
|
+
const uid = `uid-${suffix}`;
|
|
724
|
+
const { learnCard } = await getUser('5'.repeat(64));
|
|
725
|
+
const collection = getUserKeysCollection();
|
|
726
|
+
|
|
727
|
+
await collection.insertOne(expiredMigration(email, uid, learnCard.id.did(), escrow));
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const result = await getClient().keys.getAuthShare({
|
|
731
|
+
authToken: makeMockToken(email, uid),
|
|
732
|
+
providerType: 'firebase',
|
|
733
|
+
});
|
|
734
|
+
const stored = await collection.findOne({
|
|
735
|
+
authProviders: { $elemMatch: { type: 'firebase', id: uid } },
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
if (purged) {
|
|
739
|
+
expect(result?.authShare).toBeNull();
|
|
740
|
+
expect(stored?.authShare).toBeUndefined();
|
|
741
|
+
expect(stored?.escrowBlob).toBeUndefined();
|
|
742
|
+
expect(stored?.sssActivationState).toBeUndefined();
|
|
743
|
+
} else {
|
|
744
|
+
expect(stored?.authShare?.encryptedData).toBe('migrated-share');
|
|
745
|
+
expect(stored?.escrowBlob).toBeDefined();
|
|
746
|
+
expect(stored?.sssActivationState).toBe('provisional');
|
|
747
|
+
expect(stored?.keyProvider).toBe('web3auth');
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
await collection.deleteMany({ 'contactMethod.value': email });
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
679
755
|
describe('P0-6 fresh DID authorization', () => {
|
|
680
756
|
it('issues short-lived challenges that can only be consumed once', async () => {
|
|
681
757
|
const did = `did:key:${randomUUID()}`;
|
package/tsconfig.json
CHANGED
|
@@ -17,21 +17,20 @@
|
|
|
17
17
|
"emitDeclarationOnly": true,
|
|
18
18
|
"sourceMap": true,
|
|
19
19
|
"rootDir": ".",
|
|
20
|
-
"baseUrl": ".",
|
|
21
20
|
"paths": {
|
|
22
|
-
"@accesslayer/*": ["src/accesslayer/*"],
|
|
23
|
-
"@cache": ["src/cache/index.ts"],
|
|
24
|
-
"@cache/*": ["src/cache/*"],
|
|
25
|
-
"@config/*": ["src/config/*"],
|
|
26
|
-
"@environment": ["src/config/environment.ts"],
|
|
27
|
-
"@helpers/*": ["src/helpers/*"],
|
|
28
|
-
"@instance": ["src/instance.ts"],
|
|
29
|
-
"@firebase": ["src/firebase.ts"],
|
|
30
|
-
"@mongo": ["src/mongo.ts"],
|
|
31
|
-
"@models": ["src/models/index.ts"],
|
|
32
|
-
"@routes": ["src/routes/index.ts"],
|
|
33
|
-
"@routes/*": ["src/routes/*"],
|
|
34
|
-
"types/*": ["src/types/*"]
|
|
21
|
+
"@accesslayer/*": ["./src/accesslayer/*"],
|
|
22
|
+
"@cache": ["./src/cache/index.ts"],
|
|
23
|
+
"@cache/*": ["./src/cache/*"],
|
|
24
|
+
"@config/*": ["./src/config/*"],
|
|
25
|
+
"@environment": ["./src/config/environment.ts"],
|
|
26
|
+
"@helpers/*": ["./src/helpers/*"],
|
|
27
|
+
"@instance": ["./src/instance.ts"],
|
|
28
|
+
"@firebase": ["./src/firebase.ts"],
|
|
29
|
+
"@mongo": ["./src/mongo.ts"],
|
|
30
|
+
"@models": ["./src/models/index.ts"],
|
|
31
|
+
"@routes": ["./src/routes/index.ts"],
|
|
32
|
+
"@routes/*": ["./src/routes/*"],
|
|
33
|
+
"types/*": ["./src/types/*"]
|
|
35
34
|
},
|
|
36
35
|
"allowJs": false
|
|
37
36
|
},
|