@learncard/lca-api-service 1.6.1 → 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.
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +17 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -0
- package/dist/helpers/accountSecureLink.d.ts +4 -0
- package/dist/helpers/accountSecureLink.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +4 -1
- package/dist/models/UserKey.d.ts +199 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/escrow.d.ts +4 -0
- package/dist/routes/keys.d.ts +47 -1
- package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
- package/dist/services/escrow-enclave/notifications.d.ts +1 -1
- package/package.json +10 -10
- package/src/cache/recoverySessions.ts +38 -0
- package/src/helpers/accountSecureLink.test.ts +26 -0
- package/src/helpers/accountSecureLink.ts +16 -0
- package/src/jobs/escrowBlobRewrap.ts +95 -3
- package/src/models/EscrowHold.ts +17 -3
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +540 -48
- package/src/routes/escrow.ts +326 -67
- package/src/routes/keys.ts +416 -7
- package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
- package/src/services/escrow-enclave/README.md +8 -3
- package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
- package/src/services/escrow-enclave/notifications.test.ts +69 -0
- package/src/services/escrow-enclave/notifications.ts +19 -1
- package/test/keys-escrow.spec.ts +4 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1152 -0
|
@@ -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
|
-
|
|
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(),
|
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -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);
|