@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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@learncard/lca-api-service",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
7
7
  },
8
- "packageManager": "bun@1.4.2",
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.6",
72
- "@learncard/didkit-plugin-node": "0.3.6",
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.6",
75
+ "@learncard/init": "2.5.7",
76
76
  "@learncard/types": "5.24.0",
77
- "@learncard/sss-key-manager": "0.2.4",
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
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "test": {
61
61
  "executor": "nx:run-script",
62
- "inputs": ["source", "tests"],
62
+ "inputs": ["source", "tests", "ciTestConfig"],
63
63
  "options": {
64
64
  "script": "test"
65
65
  }
@@ -282,7 +282,8 @@ export const findEscrowHoldsPendingEnclaveCancellation = async (
282
282
  enclaveCancelPendingAt: { $exists: true },
283
283
  enclaveCancelConfirmedAt: { $exists: false },
284
284
  })
285
- .sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
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
- .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
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
- .sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
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
 
@@ -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 (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold') => {
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: randomUUID(),
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
- const unattempted = firstRun.find(hold => hold?.enclaveCancelAttempts === 0)!;
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(unattempted._id))?.enclaveCancelAttempts).toBe(1);
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
  },