@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/AGENTS.md +1 -1
- package/CHANGELOG.md +18 -0
- package/dist/index.js +2 -2
- package/dist/publish-metafile.json +1 -1
- package/package.json +9 -9
- package/src/routes/escrow.ts +12 -1
- package/test/keys-escrow.spec.ts +18 -0
- package/test/keys-pin-sign-in.spec.ts +34 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/lca-api-service",
|
|
3
|
-
"version": "1.6.
|
|
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.
|
|
70
|
-
"@learncard/did-web-plugin": "1.1.
|
|
71
|
-
"@learncard/didkit-plugin": "1.10.
|
|
72
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
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.
|
|
75
|
-
"@learncard/init": "2.5.
|
|
76
|
-
"@learncard/types": "5.24.
|
|
77
|
-
"@learncard/sss-key-manager": "0.2.
|
|
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",
|
package/src/routes/escrow.ts
CHANGED
|
@@ -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({
|
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -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
|
-
|
|
728
|
-
|
|
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
|
|