@learncard/lca-api-service 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/AGENTS.md +21 -0
  2. package/CHANGELOG.md +31 -0
  3. package/dist/app.d.ts +41 -0
  4. package/dist/cache/recoverySessions.d.ts +5 -0
  5. package/dist/didkit_wasm_bg.wasm +0 -0
  6. package/dist/helpers/accountSecureLink.d.ts +4 -0
  7. package/dist/helpers/accountSecureLink.test.d.ts +1 -0
  8. package/dist/index.js +2 -2
  9. package/dist/models/EscrowHold.d.ts +4 -1
  10. package/dist/models/UserKey.d.ts +199 -1
  11. package/dist/publish-metafile.json +1 -1
  12. package/dist/routes/escrow.d.ts +4 -0
  13. package/dist/routes/keys.d.ts +47 -1
  14. package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
  15. package/dist/services/escrow-enclave/notifications.d.ts +1 -1
  16. package/package.json +11 -11
  17. package/project.json +1 -1
  18. package/src/cache/recoverySessions.ts +38 -0
  19. package/src/helpers/accountSecureLink.test.ts +26 -0
  20. package/src/helpers/accountSecureLink.ts +16 -0
  21. package/src/jobs/escrowBlobRewrap.ts +95 -3
  22. package/src/models/EscrowHold.ts +23 -6
  23. package/src/models/UserKey.escrowPin.test.ts +21 -0
  24. package/src/models/UserKey.ts +553 -47
  25. package/src/routes/escrow.ts +326 -67
  26. package/src/routes/keys.ts +416 -7
  27. package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
  28. package/src/services/escrow-enclave/README.md +8 -3
  29. package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
  30. package/src/services/escrow-enclave/notifications.test.ts +69 -0
  31. package/src/services/escrow-enclave/notifications.ts +19 -1
  32. package/test/jobs/escrowHoldReminders.spec.ts +88 -4
  33. package/test/keys-escrow.spec.ts +4 -0
  34. package/test/keys-lifecycle-invariants.spec.ts +76 -0
  35. package/test/keys-pin-lease.spec.ts +1132 -0
  36. package/test/keys-pin-sign-in.spec.ts +1152 -0
  37. package/tsconfig.json +13 -14
@@ -0,0 +1,124 @@
1
+ import { randomBytes } from 'crypto';
2
+ import {
3
+ commitEscrowEnrollmentLease,
4
+ findUserKeyByAuthProvider,
5
+ releaseEscrowEnrollmentLease,
6
+ type AuthProviderMapping,
7
+ type EscrowEnrollmentLease,
8
+ } from '@models';
9
+ import { getEscrowEnclave, isEscrowRemoteMode } from './index';
10
+ import {
11
+ EscrowBlobError,
12
+ EscrowCodeError,
13
+ EscrowPolicyError,
14
+ EscrowUnavailableError,
15
+ } from './types';
16
+ import { escrowBlobIdentity } from './blobIdentity';
17
+ import type { AuthorityMutationResult, AuthorityStateResponse } from './authorityContract';
18
+
19
+ /**
20
+ * A definite authority refusal: the operation never committed, so its lease can be
21
+ * released at once. Anything else (timeout, lost or malformed response) may have.
22
+ */
23
+ export const isDefiniteAuthorityRefusal = (error: unknown): boolean =>
24
+ error instanceof EscrowPolicyError ||
25
+ error instanceof EscrowBlobError ||
26
+ error instanceof EscrowCodeError;
27
+
28
+ /**
29
+ * True when the authority's current state is exactly the commit `lease` was claimed
30
+ * for: one enrollment version past the read, the signed epoch and share version, the
31
+ * PIN generation the intent's action implies and, when the caller holds the returned
32
+ * mutation, the same envelope identity.
33
+ */
34
+ export const isLeaseCommitted = (
35
+ lease: EscrowEnrollmentLease,
36
+ { state }: AuthorityStateResponse,
37
+ received?: AuthorityMutationResult
38
+ ): boolean => {
39
+ const pinChanged = lease.pinAction !== 'keep';
40
+ if (
41
+ state.authorityGeneration !== lease.authorityGeneration ||
42
+ state.enrollmentVersion !== lease.expectedEnrollmentVersion + 1 ||
43
+ state.enrollmentEpoch !== lease.enrollmentEpoch ||
44
+ (lease.expectedPinGeneration !== undefined &&
45
+ state.pinGeneration !== lease.expectedPinGeneration + (pinChanged ? 1 : 0)) ||
46
+ (pinChanged && state.hasPin !== (lease.pinAction !== 'clear'))
47
+ )
48
+ return false;
49
+ if (received && received.enrollmentVersion !== state.enrollmentVersion) return false;
50
+ if (lease.action === 'disable') return state.status === 'disabled' && !state.envelope;
51
+ return (
52
+ state.status === 'active' &&
53
+ !!state.envelope &&
54
+ state.shareVersion === lease.shareVersion &&
55
+ (!received?.envelope ||
56
+ escrowBlobIdentity(received.envelope) === escrowBlobIdentity(state.envelope))
57
+ );
58
+ };
59
+
60
+ /**
61
+ * Converge Mongo to the authority after a PIN-operation lease did not complete
62
+ * normally (lost response, crash, or the lease's own write missed). Reads the
63
+ * authority's state: if it is the lease's commit, persists the authority's envelope,
64
+ * epoch and version with the PIN record the lease recorded (`adopted`, also when
65
+ * another request already persisted that same commit); otherwise drops the lease
66
+ * when `releaseIfUncommitted` (only safe once no call under it can still land: an
67
+ * expired lease, or a definite refusal), else leaves it to expire (`kept`).
68
+ * Idempotent: the adopt write is conditioned on the lease id (or, with the returned
69
+ * mutation in hand, on the lease being gone) and on the blob it replaced.
70
+ */
71
+ export const settleEscrowEnrollmentLease = async (
72
+ authProvider: AuthProviderMapping,
73
+ primaryDid: string,
74
+ lease: EscrowEnrollmentLease,
75
+ {
76
+ received,
77
+ releaseIfUncommitted,
78
+ }: { received?: AuthorityMutationResult; releaseIfUncommitted: boolean }
79
+ ): Promise<'adopted' | 'released' | 'kept'> => {
80
+ const enclave = getEscrowEnclave();
81
+ if (!isEscrowRemoteMode() || !enclave.getEnrollmentState) {
82
+ await releaseEscrowEnrollmentLease(authProvider, lease.id);
83
+ return 'released';
84
+ }
85
+ const current = await enclave.getEnrollmentState({
86
+ expectedDid: primaryDid,
87
+ nonce: randomBytes(32).toString('hex'),
88
+ });
89
+ if (current.state.did !== primaryDid) throw new EscrowUnavailableError('invalid_response');
90
+ if (isLeaseCommitted(lease, current, received)) {
91
+ const adopted = await commitEscrowEnrollmentLease(
92
+ authProvider,
93
+ lease,
94
+ {
95
+ envelope: current.state.envelope,
96
+ authorityGeneration: current.state.authorityGeneration,
97
+ enrollmentVersion: current.state.enrollmentVersion,
98
+ enrollmentEpoch: current.state.enrollmentEpoch,
99
+ ...(received ? { receipt: received.receipt } : {}),
100
+ stateProof: current.proof,
101
+ },
102
+ { adoptWithoutLease: received !== undefined }
103
+ );
104
+ if (adopted) return 'adopted';
105
+ // Another request (an expired-lease settle) may already have persisted this
106
+ // exact commit: Mongo then serves the authority's copy, nothing is left to do.
107
+ // Only on the lease's own split: a security rotation also drops the lease and
108
+ // the blob, but never wrote this commit (or a disable's opt-out) into Mongo.
109
+ const stored = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
110
+ const persisted =
111
+ stored !== null &&
112
+ stored.shareVersion === lease.shareVersion &&
113
+ stored.escrowEnrollmentLease?.id !== lease.id &&
114
+ (current.state.envelope
115
+ ? stored.escrowBlob?.blobHash === escrowBlobIdentity(current.state.envelope)
116
+ : !stored.escrowBlob && (!lease.optOut || stored.escrowOptedOutAt !== undefined));
117
+ await releaseEscrowEnrollmentLease(authProvider, lease.id);
118
+ // Otherwise Mongo moved past the blob this lease replaced (a rotation).
119
+ return persisted ? 'adopted' : 'released';
120
+ }
121
+ if (!releaseIfUncommitted) return 'kept';
122
+ await releaseEscrowEnrollmentLease(authProvider, lease.id);
123
+ return 'released';
124
+ };
@@ -238,6 +238,75 @@ describe('notifyEscrowHoldEvent', () => {
238
238
  expect(sent).toHaveLength(0);
239
239
  });
240
240
 
241
+ it('pin-sign-in emails a secure-account link (no token) and sends its own push copy', async () => {
242
+ const userKey = baseUserKey();
243
+ const completedAt = new Date('2026-03-04T05:06:07.000Z');
244
+ const hold = baseHold({ releasePolicy: 'pin', status: 'completed', completedAt });
245
+ const rotateCancelToken = vi.fn(async () => 'e'.repeat(64));
246
+ const { deps, sent, pushCalls, recorded } = makeDeps({ rotateCancelToken });
247
+
248
+ await expect(
249
+ notifyEscrowHoldEvent({ kind: 'pin-sign-in', hold, userKey }, deps)
250
+ ).resolves.toBe(true);
251
+
252
+ expect(rotateCancelToken).not.toHaveBeenCalled();
253
+ expect(sent).toHaveLength(1);
254
+ expect(sent[0]!.templateAlias).toBe('escrow-pin-sign-in');
255
+ expect(sent[0]!.templateModel).toEqual({
256
+ signedInAt: completedAt.toISOString(),
257
+ secureUrl: 'https://learncard.app/account/secure',
258
+ });
259
+ expect(pushCalls).toEqual([
260
+ expect.objectContaining({
261
+ message: {
262
+ title: 'New device signed in with your PIN',
263
+ body: 'Not you? Open the app to secure your account.',
264
+ },
265
+ }),
266
+ ]);
267
+ expect(recorded).toEqual([{ holdId: hold._id, kind: 'pin-sign-in' }]);
268
+ });
269
+
270
+ it('pin-sign-in links to the tenant app and sends tenant branding', async () => {
271
+ const userKey = baseUserKey();
272
+ const hold = baseHold({ releasePolicy: 'pin', status: 'completed' });
273
+ const { deps, sent } = makeDeps();
274
+ const emailBranding = { brandName: 'VetPass', appUrl: 'https://vetpass.app' };
275
+
276
+ await notifyEscrowHoldEvent(
277
+ {
278
+ kind: 'pin-sign-in',
279
+ hold,
280
+ userKey,
281
+ tenant: { id: 'vetpass', emailBranding, resolvedVia: 'header' },
282
+ },
283
+ deps
284
+ );
285
+
286
+ expect(sent[0]!.templateModel.secureUrl).toBe('https://vetpass.app/account/secure');
287
+ expect(sent[0]!.branding).toEqual(emailBranding);
288
+ });
289
+
290
+ it('pin-sign-in never throws when delivery fails', async () => {
291
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
292
+ const userKey = baseUserKey();
293
+ const hold = baseHold({ releasePolicy: 'pin', status: 'completed' });
294
+ const { deps } = makeDeps({
295
+ send: async () => {
296
+ throw new Error('postmark down owner@example.com');
297
+ },
298
+ sendPush: async () => {
299
+ throw new Error('fcm down');
300
+ },
301
+ });
302
+
303
+ await expect(
304
+ notifyEscrowHoldEvent({ kind: 'pin-sign-in', hold, userKey }, deps)
305
+ ).resolves.toBe(false);
306
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('owner@example.com');
307
+ errorSpy.mockRestore();
308
+ });
309
+
241
310
  it('sends a push notification to the account DID for every kind', async () => {
242
311
  const userKey = baseUserKey({ contactMethod: { type: 'phone', value: '+15555550100' } });
243
312
  const hold = baseHold();
@@ -10,8 +10,15 @@ import { resolveBranding } from '@learncard/email-templates';
10
10
  import type { EscrowHold, MongoUserKeyType } from '@models';
11
11
  import { getDeliveryService, getFrom, type DeliveryService } from '../delivery';
12
12
  import { buildEscrowCancelUrl } from '@helpers/escrowCancelLink';
13
+ import { buildAccountSecureUrl } from '@helpers/accountSecureLink';
13
14
 
14
- export type EscrowHoldEventKind = 'started' | 'reminder' | 'completed' | 'cancelled' | 'pin-locked';
15
+ /**
16
+ * `'pin-sign-in'` replaces `'completed'` for PIN releases: a PIN release now
17
+ * signs a new device in without signing out other devices, so the notice links
18
+ * to "Secure my account" instead of only confirming a recovery.
19
+ */
20
+ export type EscrowHoldEventKind =
21
+ 'started' | 'reminder' | 'completed' | 'cancelled' | 'pin-locked' | 'pin-sign-in';
15
22
 
16
23
  export interface EscrowHoldEvent {
17
24
  kind: EscrowHoldEventKind;
@@ -79,6 +86,7 @@ const EMAIL_TEMPLATE_ALIAS: Record<EscrowHoldEventKind, TemplateId> = {
79
86
  completed: 'escrow-hold-released',
80
87
  cancelled: 'escrow-hold-cancelled',
81
88
  'pin-locked': 'escrow-pin-locked',
89
+ 'pin-sign-in': 'escrow-pin-sign-in',
82
90
  };
83
91
 
84
92
  const PUSH_MESSAGES: Record<EscrowHoldEventKind, { title: string; body: string }> = {
@@ -102,6 +110,10 @@ const PUSH_MESSAGES: Record<EscrowHoldEventKind, { title: string; body: string }
102
110
  title: 'Recovery PIN locked',
103
111
  body: 'Your recovery PIN was locked after too many attempts.',
104
112
  },
113
+ 'pin-sign-in': {
114
+ title: 'New device signed in with your PIN',
115
+ body: 'Not you? Open the app to secure your account.',
116
+ },
105
117
  };
106
118
 
107
119
  /**
@@ -207,6 +219,12 @@ const buildEmailTemplateModel = async (
207
219
  case 'completed':
208
220
  return { completedAt: (hold.completedAt ?? deps.now()).toISOString() };
209
221
 
222
+ case 'pin-sign-in':
223
+ return {
224
+ signedInAt: (hold.completedAt ?? deps.now()).toISOString(),
225
+ secureUrl: buildAccountSecureUrl({ baseUrl }),
226
+ };
227
+
210
228
  case 'cancelled':
211
229
  return {
212
230
  cancelledAt: (hold.cancelledAt ?? deps.now()).toISOString(),
@@ -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);
@@ -2499,11 +2499,13 @@ describe('escrow PIN release', () => {
2499
2499
  enabled: true,
2500
2500
  attemptsRemaining: 10,
2501
2501
  salt: pinSalt,
2502
+ wrongAttempts: 0,
2502
2503
  });
2503
2504
  await getUserKeysCollection().updateOne(
2504
2505
  { 'authProviders.id': authProvider.id },
2505
2506
  { $set: { 'escrowPin.failedAttempts': 10, 'escrowPin.disabledAt': new Date() } }
2506
2507
  );
2508
+ // Legacy charged history without the verified counter: no wrong-entry count.
2507
2509
  expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
2508
2510
  state: 'locked',
2509
2511
  enabled: false,
@@ -2518,6 +2520,7 @@ describe('escrow PIN release', () => {
2518
2520
  state: 'stale',
2519
2521
  enabled: false,
2520
2522
  attemptsRemaining: 10,
2523
+ wrongAttempts: 0,
2521
2524
  });
2522
2525
  await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
2523
2526
  });
@@ -2580,6 +2583,7 @@ describe('escrow PIN release', () => {
2580
2583
  enabled: true,
2581
2584
  attemptsRemaining: 10,
2582
2585
  salt: pinSalt,
2586
+ wrongAttempts: 0,
2583
2587
  });
2584
2588
  // Required for a no-provider Nitro escrow start/restart to bind to this account.
2585
2589
  expect(result.primaryDid).toBe(did);
@@ -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()}`;