@learncard/lca-api-service 1.6.1 → 1.6.3

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.
@@ -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(),
@@ -1971,6 +1971,24 @@ describe('escrow PIN release', () => {
1971
1971
  expect((await record())?.escrowPin?.failedAttempts).toBe(2);
1972
1972
  });
1973
1973
 
1974
+ it('never reports "Incorrect PIN." for a mismatch whose wrong entry was not recorded', async () => {
1975
+ // The record changed under the compare (re-enrolled/rotated elsewhere): clients
1976
+ // count every "Incorrect PIN." as their own recorded entry (own-typos rule).
1977
+ await enrollPin();
1978
+ const hold = await startPin();
1979
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
1980
+ vi.spyOn(models, 'recordEscrowPinFailure').mockResolvedValueOnce(null);
1981
+ await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
1982
+ code: 'CONFLICT',
1983
+ message: 'Automatic recovery changed; please refresh.',
1984
+ });
1985
+ expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
1986
+ status: 'cancelled',
1987
+ cancelReason: 'pin-mismatch',
1988
+ });
1989
+ expect((await record())?.escrowPin?.verifiedFailedAttempts ?? 0).toBe(0);
1990
+ });
1991
+
1974
1992
  it('locks after ten mismatches and still permits delayed recovery', async () => {
1975
1993
  await enrollPin();
1976
1994
  let lockingHoldId = '';
@@ -2499,11 +2517,13 @@ describe('escrow PIN release', () => {
2499
2517
  enabled: true,
2500
2518
  attemptsRemaining: 10,
2501
2519
  salt: pinSalt,
2520
+ wrongAttempts: 0,
2502
2521
  });
2503
2522
  await getUserKeysCollection().updateOne(
2504
2523
  { 'authProviders.id': authProvider.id },
2505
2524
  { $set: { 'escrowPin.failedAttempts': 10, 'escrowPin.disabledAt': new Date() } }
2506
2525
  );
2526
+ // Legacy charged history without the verified counter: no wrong-entry count.
2507
2527
  expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
2508
2528
  state: 'locked',
2509
2529
  enabled: false,
@@ -2518,6 +2538,7 @@ describe('escrow PIN release', () => {
2518
2538
  state: 'stale',
2519
2539
  enabled: false,
2520
2540
  attemptsRemaining: 10,
2541
+ wrongAttempts: 0,
2521
2542
  });
2522
2543
  await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
2523
2544
  });
@@ -2580,6 +2601,7 @@ describe('escrow PIN release', () => {
2580
2601
  enabled: true,
2581
2602
  attemptsRemaining: 10,
2582
2603
  salt: pinSalt,
2604
+ wrongAttempts: 0,
2583
2605
  });
2584
2606
  // Required for a no-provider Nitro escrow start/restart to bind to this account.
2585
2607
  expect(result.primaryDid).toBe(did);