@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
@@ -113,6 +113,10 @@ export declare const escrowRouter: import("@trpc/server").TRPCBuiltRouter<{
113
113
  iv: string;
114
114
  ciphertext: string;
115
115
  } | null | undefined;
116
+ expectedPinCharges?: {
117
+ attemptsRemaining: number;
118
+ wrongAttempts: number;
119
+ } | undefined;
116
120
  };
117
121
  output: {
118
122
  success: true;
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
+ import { type Context } from '@routes';
2
3
  import { AuthProviderType } from '@helpers/auth.helpers';
4
+ import { type RecoverySessionRecord } from '@cache/recoverySessions';
3
5
  import { type ContactMethod, type AuthProviderMapping, type MongoUserKeyType } from '@models';
4
6
  export declare const AuthProviderTypeValidator: z.ZodEnum<{
5
7
  firebase: "firebase";
@@ -32,9 +34,16 @@ export declare function verifyAndGetContactMethod(input: {
32
34
  }>;
33
35
  export declare const requireUserKey: (authProvider: AuthProviderMapping, message?: string) => Promise<MongoUserKeyType>;
34
36
  export declare const assertDidOwner: (userKey: MongoUserKeyType, authenticatedDid: string) => void;
37
+ export declare const requireRecoverySessionRecord: (token: string, scope: "recover" | "rebind") => Promise<RecoverySessionRecord & {
38
+ authProvider: AuthProviderMapping;
39
+ }>;
35
40
  export declare const requireRecoverySession: (token: string, scope: "recover" | "rebind") => Promise<AuthProviderMapping>;
41
+ export declare const getRecoverySessionEpoch: (session: Pick<RecoverySessionRecord, "securityEpoch">) => number;
42
+ export declare const STALE_RECOVERY_SESSION_MESSAGE = "Your account security changed during recovery. Start again.";
43
+ export declare const assertRecoverySessionEpoch: (session: Pick<RecoverySessionRecord, "securityEpoch">, userKey: MongoUserKeyType) => void;
44
+ export declare const ACCOUNT_SECURED_NOTICE_TIMEOUT_MS = 3000;
36
45
  export declare const keysRouter: import("@trpc/server").TRPCBuiltRouter<{
37
- ctx: import("@routes").Context;
46
+ ctx: Context;
38
47
  meta: import("trpc-to-openapi").OpenApiMeta;
39
48
  errorShape: import("@trpc/server").TRPCDefaultErrorShape;
40
49
  transformer: false;
@@ -65,6 +74,7 @@ export declare const keysRouter: import("@trpc/server").TRPCBuiltRouter<{
65
74
  state: "enabled" | "none" | "locked" | "stale";
66
75
  enabled: boolean;
67
76
  attemptsRemaining: number;
77
+ wrongAttempts?: number | undefined;
68
78
  salt?: string | undefined;
69
79
  };
70
80
  primaryDid: string | null;
@@ -113,6 +123,39 @@ export declare const keysRouter: import("@trpc/server").TRPCBuiltRouter<{
113
123
  };
114
124
  meta: import("trpc-to-openapi").OpenApiMeta;
115
125
  }>;
126
+ completePinSignIn: import("@trpc/server").TRPCMutationProcedure<{
127
+ input: {
128
+ recoverySessionToken: string;
129
+ providerType: "firebase" | "supertokens" | "keycloak" | "oidc";
130
+ primaryDid: string;
131
+ newAuthToken?: string | undefined;
132
+ };
133
+ output: {
134
+ success: true;
135
+ shareVersion: number;
136
+ linked: boolean;
137
+ };
138
+ meta: import("trpc-to-openapi").OpenApiMeta;
139
+ }>;
140
+ revokeOtherDevices: import("@trpc/server").TRPCMutationProcedure<{
141
+ input: {
142
+ providerType: "firebase" | "supertokens" | "keycloak" | "oidc";
143
+ primaryDid: string;
144
+ authShare: {
145
+ encryptedData: string;
146
+ encryptedDek: string;
147
+ iv: string;
148
+ };
149
+ expectedShareVersion: number;
150
+ authToken?: string | undefined;
151
+ };
152
+ output: {
153
+ success: true;
154
+ shareVersion: number;
155
+ recoveryMethodsRequireConfirmation: ("email" | "passkey" | "backup" | "phrase" | "escrow")[];
156
+ };
157
+ meta: import("trpc-to-openapi").OpenApiMeta;
158
+ }>;
116
159
  issueDidChallenge: import("@trpc/server").TRPCMutationProcedure<{
117
160
  input: {
118
161
  did: string;
@@ -151,11 +194,14 @@ export declare const keysRouter: import("@trpc/server").TRPCBuiltRouter<{
151
194
  state: "enabled" | "none" | "locked" | "stale";
152
195
  enabled: boolean;
153
196
  attemptsRemaining: number;
197
+ wrongAttempts?: number | undefined;
154
198
  salt?: string | undefined;
155
199
  };
156
200
  priorEscrowEnrollment: boolean;
157
201
  sssActivationState: "active" | "provisional";
158
202
  escrowStale?: "mode-mismatch" | "key-rotated" | undefined;
203
+ escrowBlobIdentity?: string | undefined;
204
+ escrowUpdating?: boolean | undefined;
159
205
  } | null;
160
206
  meta: import("trpc-to-openapi").OpenApiMeta;
161
207
  }>;
@@ -0,0 +1,8 @@
1
+ import { type AuthProviderMapping, type EscrowEnrollmentLease } from '@models';
2
+ import type { AuthorityMutationResult, AuthorityStateResponse } from './authorityContract';
3
+ export declare const isDefiniteAuthorityRefusal: (error: unknown) => boolean;
4
+ export declare const isLeaseCommitted: (lease: EscrowEnrollmentLease, { state }: AuthorityStateResponse, received?: AuthorityMutationResult) => boolean;
5
+ export declare const settleEscrowEnrollmentLease: (authProvider: AuthProviderMapping, primaryDid: string, lease: EscrowEnrollmentLease, { received, releaseIfUncommitted, }: {
6
+ received?: AuthorityMutationResult;
7
+ releaseIfUncommitted: boolean;
8
+ }) => Promise<"adopted" | "released" | "kept">;
@@ -1,7 +1,7 @@
1
1
  import type { ResolvedTenant } from '@learncard/email-templates';
2
2
  import type { EscrowHold, MongoUserKeyType } from '@models';
3
3
  import { type DeliveryService } from '../delivery';
4
- export type EscrowHoldEventKind = 'started' | 'reminder' | 'completed' | 'cancelled' | 'pin-locked';
4
+ export type EscrowHoldEventKind = 'started' | 'reminder' | 'completed' | 'cancelled' | 'pin-locked' | 'pin-sign-in';
5
5
  export interface EscrowHoldEvent {
6
6
  kind: EscrowHoldEventKind;
7
7
  hold: EscrowHold;
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.2",
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": {
@@ -66,15 +66,15 @@
66
66
  "@fastify/formbody": "^8.0.2",
67
67
  "@fastify/rate-limit": "^10.2.0",
68
68
  "@fastify/static": "^10.1.1",
69
- "@learncard/core": "9.4.41",
70
- "@learncard/did-web-plugin": "1.1.41",
71
- "@learncard/didkit-plugin": "1.10.6",
72
- "@learncard/didkit-plugin-node": "0.3.6",
73
- "@learncard/email-templates": "1.2.1",
74
- "@learncard/helpers": "1.7.1",
75
- "@learncard/init": "2.5.6",
76
- "@learncard/types": "5.24.0",
77
- "@learncard/sss-key-manager": "0.2.4",
69
+ "@learncard/core": "9.4.42",
70
+ "@learncard/did-web-plugin": "1.1.42",
71
+ "@learncard/didkit-plugin": "1.10.8",
72
+ "@learncard/didkit-plugin-node": "0.3.8",
73
+ "@learncard/email-templates": "1.2.2",
74
+ "@learncard/helpers": "1.7.2",
75
+ "@learncard/init": "2.5.8",
76
+ "@learncard/types": "5.24.1",
77
+ "@learncard/sss-key-manager": "0.2.6",
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
  }
@@ -13,6 +13,22 @@ export type RecoverySessionScope = 'recover' | 'rebind';
13
13
  export interface RecoverySessionRecord {
14
14
  scope: RecoverySessionScope;
15
15
  authProvider: { type: AuthProviderType; id: string };
16
+ /**
17
+ * Set only by `/keys/escrow/complete` after a successful PIN release. Keep-mode
18
+ * PIN sign-in (`/keys/recovery-session/pin-sign-in`) requires it, so tokens from
19
+ * the waiting-period hold or from `useRecoverySession` can never skip the rotation.
20
+ */
21
+ origin?: 'pin-release';
22
+ /** Account DID and share version the PIN release unlocked (server-side, never client input). */
23
+ primaryDid?: string;
24
+ shareVersion?: number;
25
+ /**
26
+ * The account's security epoch when the token (or the recovery session it came
27
+ * from) was minted. `/rebind` and `/pin-sign-in` only write while the account is
28
+ * still at this epoch, so a revoke or wiping rebind retires every older token.
29
+ * Read as 0 when absent (tokens minted before the field existed).
30
+ */
31
+ securityEpoch?: number;
16
32
  }
17
33
 
18
34
  export interface RecoveryOtpRecord {
@@ -56,6 +72,28 @@ export const createRecoverySession = async (
56
72
  return token;
57
73
  };
58
74
 
75
+ /**
76
+ * Read a recovery token without consuming it, so a route can reject a token meant for
77
+ * another route without burning it. Always follow with `consumeRecoverySession`.
78
+ */
79
+ export const peekRecoverySession = async (
80
+ token: string,
81
+ expectedScope: RecoverySessionScope
82
+ ): Promise<RecoverySessionRecord | null> => {
83
+ const redis = cache.redis ?? cache.node;
84
+ const raw = await redis.get(getRecoverySessionCacheKey(token));
85
+
86
+ if (!raw) return null;
87
+
88
+ try {
89
+ const record = JSON.parse(raw) as RecoverySessionRecord;
90
+
91
+ return record.scope === expectedScope ? record : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ };
96
+
59
97
  /** Atomically consume a recovery token. Every token authorizes exactly one route call. */
60
98
  export const consumeRecoverySession = async (
61
99
  token: string,
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildAccountSecureUrl } from './accountSecureLink';
4
+
5
+ describe('buildAccountSecureUrl', () => {
6
+ it('builds the secure-account path on the tenant app URL', () => {
7
+ expect(buildAccountSecureUrl({ baseUrl: 'https://learncard.app' })).toBe(
8
+ 'https://learncard.app/account/secure'
9
+ );
10
+ expect(buildAccountSecureUrl({ baseUrl: 'https://vetpass.app' })).toBe(
11
+ 'https://vetpass.app/account/secure'
12
+ );
13
+ });
14
+
15
+ it('never doubles the slash when baseUrl has a trailing slash', () => {
16
+ expect(buildAccountSecureUrl({ baseUrl: 'https://learncard.app/' })).toBe(
17
+ 'https://learncard.app/account/secure'
18
+ );
19
+ });
20
+
21
+ it('carries no query string (no token in the link)', () => {
22
+ expect(new URL(buildAccountSecureUrl({ baseUrl: 'https://learncard.app' })).search).toBe(
23
+ ''
24
+ );
25
+ });
26
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * "Secure my account" deep-link builder for the PIN sign-in notice.
3
+ *
4
+ * Same tenant-branded base URL as the escrow cancel link (`escrowCancelLink.ts`):
5
+ * the resolved `TenantBranding.appUrl`, falling back to LearnCard. The link
6
+ * carries no token. Under PIN sign-in the owner's other devices stay signed in,
7
+ * and the secure action itself needs a signed-in wallet (DID auth + fresh
8
+ * challenge), so the URL only has to open the right screen.
9
+ */
10
+
11
+ /** Path the app routes to its "Secure my account" flow. */
12
+ export const ACCOUNT_SECURE_PATH = '/account/secure';
13
+
14
+ /** `URL` rather than concatenation so a trailing slash on `baseUrl` never doubles. */
15
+ export const buildAccountSecureUrl = ({ baseUrl }: { baseUrl: string }): string =>
16
+ new URL(ACCOUNT_SECURE_PATH, baseUrl).toString();
@@ -1,7 +1,13 @@
1
1
  import {
2
+ claimEscrowEnrollmentLease,
3
+ commitEscrowEnrollmentLease,
2
4
  findPendingEscrowHoldByAuthProvider,
5
+ findUserKeyByAuthProvider,
3
6
  findUserKeysWithEscrowBlobKeyId,
7
+ releaseEscrowEnrollmentLease,
4
8
  rewrapEscrowBlobByAuthProvider,
9
+ ESCROW_ENROLLMENT_LEASE_TTL_MS,
10
+ type EscrowEnrollmentLease,
5
11
  } from '@models';
6
12
  import {
7
13
  getEnclaveAttestationIdentity,
@@ -9,8 +15,13 @@ import {
9
15
  isEscrowEnabled,
10
16
  isEscrowRemoteMode,
11
17
  } from '../services/escrow-enclave';
12
- import { randomBytes } from 'crypto';
18
+ import { randomBytes, randomUUID } from 'crypto';
13
19
  import { EscrowUnavailableError } from '../services/escrow-enclave/types';
20
+ import type { AuthorityMutationResult } from '../services/escrow-enclave/authorityContract';
21
+ import {
22
+ isDefiniteAuthorityRefusal,
23
+ settleEscrowEnrollmentLease,
24
+ } from '../services/escrow-enclave/enrollmentLease';
14
25
 
15
26
  const DEFAULT_REWRAP_BATCH_LIMIT = 200;
16
27
 
@@ -82,8 +93,9 @@ export const runEscrowBlobRewrap = async (
82
93
  let skippedPendingHold = 0;
83
94
  let failed = 0;
84
95
 
85
- for (const userKey of candidates) {
86
- const blob = userKey.escrowBlob;
96
+ for (const candidate of candidates) {
97
+ let userKey = candidate;
98
+ let blob = userKey.escrowBlob;
87
99
  const authProvider = userKey.authProviders[0];
88
100
  // Already migrated (by this loop's own attestation refresh landing
89
101
  // mid-batch, or a concurrent run) since the candidate query ran.
@@ -95,6 +107,27 @@ export const runEscrowBlobRewrap = async (
95
107
  }
96
108
  const enclave = getEscrowEnclave();
97
109
  if (isEscrowRemoteMode()) {
110
+ // A PIN operation (or an earlier run) left a lease behind: settle it
111
+ // against the authority first, then work from the converged copy.
112
+ const stuck = userKey.escrowEnrollmentLease;
113
+ if (stuck && stuck.expiresAt <= new Date()) {
114
+ await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, stuck, {
115
+ releaseIfUncommitted: true,
116
+ });
117
+ const settled = await findUserKeyByAuthProvider(
118
+ authProvider.type,
119
+ authProvider.id
120
+ );
121
+ if (
122
+ !settled?.escrowBlob ||
123
+ settled.escrowBlob.enclaveKeyId === attestation.keyId
124
+ )
125
+ continue;
126
+ userKey = settled;
127
+ blob = settled.escrowBlob;
128
+ }
129
+ // A live lease: its operation replaces this blob. Re-tried on a later run.
130
+ if (userKey.escrowEnrollmentLease) continue;
98
131
  if (!enclave.getEnrollmentState || !blob.authorityGeneration)
99
132
  throw new EscrowUnavailableError();
100
133
  const snapshot = await enclave.getEnrollmentState({
@@ -131,6 +164,65 @@ export const runEscrowBlobRewrap = async (
131
164
  if (stored) rewrapped += 1;
132
165
  continue;
133
166
  }
167
+ // The authority call itself runs under the account's PIN-operation
168
+ // lease, like an owner's intent: otherwise a lease claimed between this
169
+ // commit and its Mongo write would leave the authority re-sealed and
170
+ // Mongo on the old copy.
171
+ const lease: EscrowEnrollmentLease = {
172
+ id: randomUUID(),
173
+ expiresAt: new Date(Date.now() + ESCROW_ENROLLMENT_LEASE_TTL_MS),
174
+ action: 'rewrap',
175
+ pinAction: 'keep',
176
+ pin: 'keep',
177
+ shareVersion: blob.shareVersion,
178
+ authorityGeneration: current.authorityGeneration,
179
+ expectedEnrollmentVersion: current.enrollmentVersion,
180
+ expectedPinGeneration: current.pinGeneration,
181
+ enrollmentEpoch: blob.enrollmentEpoch,
182
+ sourceBlobHash: blob.blobHash,
183
+ measurements: attestation.measurements,
184
+ };
185
+ const claimed = await claimEscrowEnrollmentLease(authProvider, {
186
+ lease,
187
+ expectedBlob: blob,
188
+ expectedPin: userKey.escrowPin,
189
+ requireOptedIn: true,
190
+ });
191
+ // Changed since read, a compare in flight, or another lease: next run.
192
+ if (!claimed) continue;
193
+ let authority: AuthorityMutationResult | undefined;
194
+ try {
195
+ ({ authority } = await enclave.rewrapEscrowBlob({
196
+ envelope: blob.envelope,
197
+ expectedDid: userKey.primaryDid,
198
+ expectedShareVersion: blob.shareVersion,
199
+ sourceEnrollmentEpoch: blob.enrollmentEpoch,
200
+ }));
201
+ if (!authority?.envelope) throw new EscrowUnavailableError('invalid_response');
202
+ } catch (error) {
203
+ if (isDefiniteAuthorityRefusal(error))
204
+ await releaseEscrowEnrollmentLease(authProvider, lease.id);
205
+ else
206
+ await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
207
+ releaseIfUncommitted: false,
208
+ }).catch(() => undefined);
209
+ throw error;
210
+ }
211
+ const committed = await commitEscrowEnrollmentLease(authProvider, lease, {
212
+ envelope: authority.envelope,
213
+ authorityGeneration: authority.authorityGeneration,
214
+ enrollmentVersion: authority.enrollmentVersion,
215
+ enrollmentEpoch: authority.enrollmentEpoch,
216
+ receipt: authority.receipt,
217
+ }).catch(() => null);
218
+ const settled = committed
219
+ ? 'adopted'
220
+ : await settleEscrowEnrollmentLease(authProvider, userKey.primaryDid, lease, {
221
+ received: authority,
222
+ releaseIfUncommitted: false,
223
+ });
224
+ if (settled === 'adopted') rewrapped += 1;
225
+ continue;
134
226
  }
135
227
  const { envelope, authority } = await enclave.rewrapEscrowBlob({
136
228
  envelope: blob.envelope,
@@ -123,7 +123,14 @@ export const EscrowHoldValidator = z.object({
123
123
  notifications: z
124
124
  .array(
125
125
  z.object({
126
- kind: z.enum(['started', 'reminder', 'completed', 'cancelled', 'pin-locked']),
126
+ kind: z.enum([
127
+ 'started',
128
+ 'reminder',
129
+ 'completed',
130
+ 'cancelled',
131
+ 'pin-locked',
132
+ 'pin-sign-in',
133
+ ]),
127
134
  sentAt: z.date(),
128
135
  })
129
136
  )
@@ -282,7 +289,8 @@ export const findEscrowHoldsPendingEnclaveCancellation = async (
282
289
  enclaveCancelPendingAt: { $exists: true },
283
290
  enclaveCancelConfirmedAt: { $exists: false },
284
291
  })
285
- .sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
292
+ // Mongo sorts are unstable on ties; the unique ID makes bounded batches repeatable.
293
+ .sort({ enclaveCancelAttempts: 1, releaseAfter: 1, _id: 1 })
286
294
  .limit(limit)
287
295
  .toArray();
288
296
 
@@ -427,10 +435,16 @@ export const completeEscrowHold = async (id: string): Promise<EscrowHold | null>
427
435
  { returnDocument: 'after' }
428
436
  );
429
437
  };
430
- /** Whether escrow material at this share version has already been released. */
438
+ /**
439
+ * Whether escrow material at this share version has already been released.
440
+ * `completedAfter` ignores releases at or before that time: a PIN re-set at the
441
+ * same version after a PIN sign-in (keep mode) is a fresh PIN, so the release
442
+ * that preceded it must not retire it at the next rotation.
443
+ */
431
444
  export const hasCompletedEscrowHoldForVersion = async (
432
445
  authProvider: AuthProviderMapping,
433
- shareVersion: number
446
+ shareVersion: number,
447
+ options: { completedAfter?: Date } = {}
434
448
  ): Promise<boolean> =>
435
449
  (await getEscrowHoldsCollection().countDocuments(
436
450
  {
@@ -438,6 +452,7 @@ export const hasCompletedEscrowHoldForVersion = async (
438
452
  'authProvider.id': authProvider.id,
439
453
  shareVersion,
440
454
  status: 'completed',
455
+ ...(options.completedAfter ? { completedAt: { $gt: options.completedAfter } } : {}),
441
456
  },
442
457
  { limit: 1 }
443
458
  )) > 0;
@@ -499,7 +514,8 @@ export const findEscrowHoldsPendingStartNotification = async (
499
514
  ): Promise<EscrowHold[]> =>
500
515
  getEscrowHoldsCollection()
501
516
  .find({ status: 'pending', startedNotificationPending: true })
502
- .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
517
+ // Keep tied start-notification batches deterministic before the limit.
518
+ .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1, _id: 1 })
503
519
  .limit(limit)
504
520
  .toArray();
505
521
 
@@ -563,7 +579,8 @@ export const findEscrowHoldsDueForReminder = async (
563
579
  releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
564
580
  'notifications.kind': { $ne: 'reminder' },
565
581
  })
566
- .sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
582
+ // Keep tied reminder batches deterministic before the limit.
583
+ .sort({ reminderAttemptedAt: 1, releaseAfter: 1, _id: 1 })
567
584
  .limit(limit)
568
585
  .toArray();
569
586
 
@@ -28,6 +28,7 @@ describe('getEscrowPinStatus', () => {
28
28
  state,
29
29
  enabled: false,
30
30
  attemptsRemaining: 10,
31
+ wrongAttempts: 0,
31
32
  });
32
33
  });
33
34
 
@@ -43,7 +44,27 @@ describe('getEscrowPinStatus', () => {
43
44
  enabled: true,
44
45
  attemptsRemaining: Math.max(0, 10 - failedAttempts),
45
46
  salt: 'salt',
47
+ // Charged budget alone never proves a wrong entry (legacy records).
48
+ ...(failedAttempts === 0 ? { wrongAttempts: 0 } : {}),
46
49
  });
47
50
  }
48
51
  );
52
+
53
+ it('reports verified wrong entries, not the charged budget', () => {
54
+ const userKey = {
55
+ shareVersion: 1,
56
+ escrowPin: {
57
+ salt: 'salt',
58
+ shareVersion: 1,
59
+ // 3 verified mismatches + 1 successful release + 1 open reservation.
60
+ failedAttempts: 5,
61
+ verifiedFailedAttempts: 3,
62
+ enabledAt: new Date(),
63
+ },
64
+ } as MongoUserKeyType;
65
+ expect(getEscrowPinStatus(userKey)).toMatchObject({
66
+ attemptsRemaining: 5,
67
+ wrongAttempts: 3,
68
+ });
69
+ });
49
70
  });