@learncard/lca-api-service 1.6.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/lca-api-service",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -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.42",
70
- "@learncard/did-web-plugin": "1.1.42",
71
- "@learncard/didkit-plugin": "1.10.8",
72
- "@learncard/didkit-plugin-node": "0.3.8",
69
+ "@learncard/core": "9.4.43",
70
+ "@learncard/did-web-plugin": "1.1.43",
71
+ "@learncard/didkit-plugin": "1.10.9",
72
+ "@learncard/didkit-plugin-node": "0.3.9",
73
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",
74
+ "@learncard/helpers": "1.7.3",
75
+ "@learncard/init": "2.5.9",
76
+ "@learncard/types": "5.24.2",
77
+ "@learncard/sss-key-manager": "0.2.7",
78
78
  "@sentry/serverless": "^7.61.0",
79
79
  "@trpc/server": "11.8.0",
80
80
  "@types/jsonwebtoken": "^9.0.8",
@@ -555,6 +555,11 @@ const removeEscrow = async (
555
555
  * convert it to the stored counters for the write's compare-and-set. Rejected with
556
556
  * CONFLICT when a wrong entry or another reservation has landed since the client's
557
557
  * check, so the refresh never wipes a recorded or in-flight guess.
558
+ *
559
+ * `wrongAttempts` may be non-zero (own-typos rule, approved 2026-11-03): the client
560
+ * sends it only when every recorded wrong entry is one it submitted itself in the same
561
+ * sign-in flow. lca-api cannot tell whose entries they are; it only guarantees that
562
+ * nothing changed since the client's check, so a foreign entry still blocks the write.
558
563
  */
559
564
  const resolveExpectedPinCharges = (
560
565
  input: {
@@ -574,8 +579,8 @@ const resolveExpectedPinCharges = (
574
579
  const pin = userKey.escrowPin;
575
580
  const failedAttempts = ESCROW_PIN_MAX_ATTEMPTS - expected.attemptsRemaining;
576
581
  if (
577
- expected.wrongAttempts !== 0 ||
578
582
  failedAttempts < 0 ||
583
+ expected.wrongAttempts > failedAttempts ||
579
584
  !pin ||
580
585
  pin.disabledAt ||
581
586
  pin.failedAttempts !== failedAttempts ||
@@ -1606,6 +1611,12 @@ export const escrowRouter = t.router({
1606
1611
  userKey,
1607
1612
  tenant: ctx.tenant,
1608
1613
  });
1614
+ // The record changed under this compare (re-enrolled, rotated or disabled
1615
+ // elsewhere), so no wrong entry was recorded for it. Never answer
1616
+ // "Incorrect PIN." here: clients count those as their own recorded entries
1617
+ // (own-typos rule), and an unrecorded one would later excuse a real guess.
1618
+ if (!failed)
1619
+ throw new TRPCError({ code: 'CONFLICT', message: escrowChangedMessage });
1609
1620
  if (locked) {
1610
1621
  await lockPin(hold, userKey, ctx.tenant);
1611
1622
  throw new TRPCError({
@@ -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 = '';
@@ -724,8 +724,40 @@ describe('same-PIN budget refresh with expected charges', () => {
724
724
  await expect(refresh({ attemptsRemaining: 9, wrongAttempts: 0 })).rejects.toMatchObject({
725
725
  code: 'CONFLICT',
726
726
  });
727
- // Even with the current counts, a PIN with a recorded wrong entry is never refreshed.
728
- await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 1 })).rejects.toMatchObject({
727
+
728
+ expect(securityState(await record())).toEqual(securityState(before));
729
+ });
730
+
731
+ it("refreshes when the expected charges include the owner's own wrong entry (own-typos rule)", async () => {
732
+ await enrollPin();
733
+ // The owner mistypes once in this sign-in, then releases with the right PIN.
734
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
735
+ code: 'FORBIDDEN',
736
+ });
737
+ await completePin(await startPin());
738
+ expect(await pinStatus()).toMatchObject({ attemptsRemaining: 8, wrongAttempts: 1 });
739
+
740
+ await refresh({ attemptsRemaining: 8, wrongAttempts: 1 });
741
+
742
+ expect((await record())?.escrowPin).toMatchObject({
743
+ salt: otherPinSalt,
744
+ failedAttempts: 0,
745
+ });
746
+ });
747
+
748
+ it('refuses a refresh whose wrong entries do not match the record, or exceed the charges', async () => {
749
+ await enrollPin();
750
+ await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
751
+ code: 'FORBIDDEN',
752
+ });
753
+ await completePin(await startPin());
754
+ const before = await record();
755
+
756
+ // A foreign wrong entry the client did not count.
757
+ await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 0 })).rejects.toMatchObject({
758
+ code: 'CONFLICT',
759
+ });
760
+ await expect(refresh({ attemptsRemaining: 8, wrongAttempts: 3 })).rejects.toMatchObject({
729
761
  code: 'CONFLICT',
730
762
  });
731
763