@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.287.0
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 +13 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +14 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Row-bound grants end-to-end over real /api/write calls without a session:
|
|
2
|
+
// the shape a consumer builds on (anonymous caller created a row, now performs
|
|
3
|
+
// exactly one narrow further write on it). The unit tests fake the anchor
|
|
4
|
+
// store; this one uses a real conditional UPDATE against Postgres, which is
|
|
5
|
+
// the only way to show that `commitAnchor` can actually be atomic and that two
|
|
6
|
+
// simultaneous redemptions of the same grant leave exactly one winner.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { executeRawQuery } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { redeemRowBoundGrant, signRowBoundGrant } from "../row-bound-grant";
|
|
15
|
+
|
|
16
|
+
const TABLE = "row_bound_grant_demo";
|
|
17
|
+
const SECRET = "row-bound-grant-integration-secret";
|
|
18
|
+
const PURPOSE = "demo-enrich";
|
|
19
|
+
const ENRICH = "grantdemo:write:enrich";
|
|
20
|
+
const ROW_ID = "11111111-1111-4111-8111-111111111111";
|
|
21
|
+
const ANCHOR = "22222222-2222-4222-8222-222222222222";
|
|
22
|
+
|
|
23
|
+
// Two /api/write calls fired together still run to completion one after the
|
|
24
|
+
// other, so a plain Promise.all would never open the window a conditional
|
|
25
|
+
// UPDATE exists for. This holds every redeemer between reading the anchor and
|
|
26
|
+
// spending it until `expected` of them have read it — the interleaving itself,
|
|
27
|
+
// deterministic instead of a sleep. The timeout keeps a serialising stack from
|
|
28
|
+
// hanging the suite: it fails the assertion instead.
|
|
29
|
+
function createRaceGate(expected: number, timeoutMs = 2_000) {
|
|
30
|
+
let arrived = 0;
|
|
31
|
+
let open: () => void = () => {};
|
|
32
|
+
const opened = new Promise<void>((resolve) => {
|
|
33
|
+
open = resolve;
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
async wait(): Promise<void> {
|
|
37
|
+
arrived += 1;
|
|
38
|
+
if (arrived >= expected) open();
|
|
39
|
+
await Promise.race([opened, Bun.sleep(timeoutMs)]);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let raceGate: { wait: () => Promise<void> } | null = null;
|
|
45
|
+
|
|
46
|
+
const RAW_REASON =
|
|
47
|
+
"the grant holder has no session, so the anchor spend is a conditional UPDATE " +
|
|
48
|
+
"outside the entity write map";
|
|
49
|
+
|
|
50
|
+
const grantDemoFeature = defineFeature("grantdemo", (r) => {
|
|
51
|
+
r.writeHandler(
|
|
52
|
+
"enrich",
|
|
53
|
+
z.object({ token: z.string().min(1), note: z.string().min(1) }),
|
|
54
|
+
async (event, ctx) => {
|
|
55
|
+
const db = ctx.db.unsafeRaw(RAW_REASON);
|
|
56
|
+
const redeemed = await redeemRowBoundGrant({
|
|
57
|
+
token: event.payload.token,
|
|
58
|
+
purpose: PURPOSE,
|
|
59
|
+
secret: SECRET,
|
|
60
|
+
loadAnchor: async (subject) => {
|
|
61
|
+
const rows = await executeRawQuery<{ anchor: string | null }>(
|
|
62
|
+
db,
|
|
63
|
+
`SELECT anchor FROM ${TABLE} WHERE id = $1`,
|
|
64
|
+
[subject],
|
|
65
|
+
);
|
|
66
|
+
await raceGate?.wait();
|
|
67
|
+
return rows[0]?.anchor ?? null;
|
|
68
|
+
},
|
|
69
|
+
commitAnchor: async (subject, expected) => {
|
|
70
|
+
const rows = await executeRawQuery<{ id: string }>(
|
|
71
|
+
db,
|
|
72
|
+
`UPDATE ${TABLE} SET anchor = NULL, note = $3 WHERE id = $1 AND anchor = $2 RETURNING id`,
|
|
73
|
+
[subject, expected, event.payload.note],
|
|
74
|
+
);
|
|
75
|
+
return rows.length === 1;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (!redeemed.ok) return writeFailure(new UnprocessableError("invalid_or_expired_grant"));
|
|
79
|
+
return { isSuccess: true as const, data: { id: redeemed.subject } };
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
access: { roles: ["anonymous"] },
|
|
83
|
+
escapeHatch: {
|
|
84
|
+
reason:
|
|
85
|
+
"the row is written by an anonymous grant holder through raw SQL, so the write " +
|
|
86
|
+
"cannot go through the entity write map of a tenant-scoped user",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stack: TestStack;
|
|
93
|
+
|
|
94
|
+
function grantFor(anchor: string): string {
|
|
95
|
+
return signRowBoundGrant({
|
|
96
|
+
subject: ROW_ID,
|
|
97
|
+
purpose: PURPOSE,
|
|
98
|
+
anchor,
|
|
99
|
+
ttlMinutes: 30,
|
|
100
|
+
secret: SECRET,
|
|
101
|
+
}).token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function enrich(token: string, note: string) {
|
|
105
|
+
return stack.http.raw("POST", "/api/write", { type: ENRICH, payload: { token, note } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readRow(): Promise<{ anchor: string | null; note: string | null } | undefined> {
|
|
109
|
+
const rows = await executeRawQuery<{ anchor: string | null; note: string | null }>(
|
|
110
|
+
stack.db,
|
|
111
|
+
`SELECT anchor, note FROM ${TABLE} WHERE id = $1`,
|
|
112
|
+
[ROW_ID],
|
|
113
|
+
);
|
|
114
|
+
return rows[0];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
stack = await setupTestStack({
|
|
119
|
+
features: [grantDemoFeature],
|
|
120
|
+
anonymousAccess: { defaultTenantId: testTenantId(1) },
|
|
121
|
+
});
|
|
122
|
+
await executeRawQuery(
|
|
123
|
+
stack.db,
|
|
124
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (id uuid PRIMARY KEY, anchor uuid, note text)`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(async () => {
|
|
129
|
+
await stack.cleanup();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
raceGate = null;
|
|
134
|
+
await executeRawQuery(stack.db, `DELETE FROM ${TABLE}`);
|
|
135
|
+
await executeRawQuery(stack.db, `INSERT INTO ${TABLE} (id, anchor, note) VALUES ($1, $2, NULL)`, [
|
|
136
|
+
ROW_ID,
|
|
137
|
+
ANCHOR,
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("row-bound grant over anonymous HTTP", () => {
|
|
142
|
+
test("a valid grant performs the one write and spends the anchor", async () => {
|
|
143
|
+
const res = await enrich(grantFor(ANCHOR), "first");
|
|
144
|
+
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
expect(await readRow()).toEqual({ anchor: null, note: "first" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("replaying the same grant fails and leaves the row alone", async () => {
|
|
150
|
+
const token = grantFor(ANCHOR);
|
|
151
|
+
expect((await enrich(token, "first")).status).toBe(200);
|
|
152
|
+
|
|
153
|
+
const replay = await enrich(token, "second");
|
|
154
|
+
|
|
155
|
+
expect(replay.status).toBe(422);
|
|
156
|
+
expect(await readRow()).toEqual({ anchor: null, note: "first" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("two simultaneous redemptions of one grant leave exactly one winner", async () => {
|
|
160
|
+
const token = grantFor(ANCHOR);
|
|
161
|
+
raceGate = createRaceGate(2);
|
|
162
|
+
|
|
163
|
+
const results = await Promise.all([enrich(token, "a"), enrich(token, "b")]);
|
|
164
|
+
|
|
165
|
+
expect(results.map((r) => r.status).sort()).toEqual([200, 422]);
|
|
166
|
+
const row = await readRow();
|
|
167
|
+
expect(row?.anchor).toBeNull();
|
|
168
|
+
expect(["a", "b"]).toContain(row?.note ?? "");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a grant for a superseded anchor fails without spending the live one", async () => {
|
|
172
|
+
const res = await enrich(grantFor("33333333-3333-4333-8333-333333333333"), "stale");
|
|
173
|
+
|
|
174
|
+
expect(res.status).toBe(422);
|
|
175
|
+
expect(await readRow()).toEqual({ anchor: ANCHOR, note: null });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a garbage token is a 422, not a 500 from the row lookup", async () => {
|
|
179
|
+
const res = await enrich("not.a.token", "junk");
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(422);
|
|
182
|
+
expect(await readRow()).toEqual({ anchor: ANCHOR, note: null });
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/shared/changes.json
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "row-bound grants: one short-lived, single-use capability for anonymous writes on one row",
|
|
6
|
+
"detail": "`signed-token.ts` moves from `auth-email-password/` to `shared/` — the mechanism\nwas never email/password specific (user-data-rights already used it). The old\npath re-exports it, so no importer breaks. New subpaths:\n`./shared/signed-token` and `./shared/row-bound-grant`.\n`shared/row-bound-grant.ts` is the new piece. It folds the row's *current*\nanchor (a request id, a status, a version — anything that moves on when the row\nis consumed) into the HMAC purpose on both mint and redeem, so a replayed token\nstops working the moment the row moves on. Single-use semantics without a burn\nkey and without Redis. Minting and redeeming share one purpose-building\nfunction, because an unanchored purpose silently degrades into a bearer token\nvalid for the whole TTL.\nAnchoring alone only closes the replay window once the row has moved on, so\nspending the anchor is part of the primitive, not homework for the caller:\n`redeemRowBoundGrant` takes a mandatory `commitAnchor(subject, expectedAnchor)`\nthat must move the row on atomically (a conditional UPDATE) and report whether\nthis caller won. It runs strictly after verification, so nobody can invalidate a\nrow by naming it with a junk token, and the loser of a race gets the same\nrejection as a forged grant. Skipping it is possible but has to be declared with\na reason (`{ unsafeSkip: { reason } }`), the way the framework handles\n`escapeHatch` — an undeclared skip is how a single-use grant quietly becomes a\nbearer token for the length of its TTL.\nEvery rejection returns a bare `{ ok: false }` with no reason, so a caller\ncannot accidentally turn an anonymous endpoint into a row-existence oracle.\n`user-data-rights`' deletion token now runs on the helper and its hand-rolled\n`peekDeletionTokenUserId` is gone. The tokens stay byte-compatible — a test\npins the wire format against the pre-refactor formula. It declares an\n`unsafeSkip` for the spend: `pendingDeletionRequestId` may only change through\n`updateUserLifecycle`, so a conditional UPDATE there would lose the field on a\nprojection rebuild. Behaviour of that flow is unchanged.\nNote for callers: the subject is not secret. `signToken` puts it in the token\nbody in the clear, so a grant on a row exposes that row's id to whoever holds\nthe link. Where the id itself must stay hidden, use an opaque handle\n(`./shared/single-use-token-store`) instead."
|
|
7
|
+
}
|
|
8
|
+
]
|
package/src/shared/index.ts
CHANGED
|
@@ -16,11 +16,25 @@ export { entitiesOf } from "./entities-of";
|
|
|
16
16
|
export { isWithinGracePeriod } from "./grace-period";
|
|
17
17
|
export { hasWhereRule } from "./has-where-rule";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
19
|
+
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter";
|
|
19
20
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
20
21
|
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
|
|
21
22
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
23
|
+
export {
|
|
24
|
+
type RowBoundGrantResult,
|
|
25
|
+
redeemRowBoundGrant,
|
|
26
|
+
signRowBoundGrant,
|
|
27
|
+
} from "./row-bound-grant";
|
|
22
28
|
export { sessionField } from "./session-field";
|
|
23
29
|
export { sessionLocaleField } from "./session-locale-field";
|
|
24
30
|
export { sessionTimezoneField } from "./session-timezone-field";
|
|
31
|
+
export {
|
|
32
|
+
peekTokenSubject,
|
|
33
|
+
signToken,
|
|
34
|
+
TokenPurpose,
|
|
35
|
+
type VerifyResult,
|
|
36
|
+
verifyToken,
|
|
37
|
+
} from "./signed-token";
|
|
38
|
+
export { createSingleUseTokenStore } from "./single-use-token-store";
|
|
25
39
|
export type { SystemQueryFn } from "./system-query";
|
|
26
40
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createLockoutCounter } from "./lockout-counter";
|
|
3
|
+
|
|
4
|
+
// Production Redis has active lockout/mfa-verify entries under these exact
|
|
5
|
+
// keys — only asserting the generated Redis key strings (not just behavior
|
|
6
|
+
// through a mocked client) catches a prefix typo that would silently make
|
|
7
|
+
// existing entries unreachable. The two prefix pairs below are byte-copies
|
|
8
|
+
// of the ones lockout-store.ts and mfa-verify-attempts.ts wire onto this
|
|
9
|
+
// factory; only integration tests exercised this logic before (which never
|
|
10
|
+
// assert on the raw key string), so this is new coverage.
|
|
11
|
+
// countKey/untilKey are not exported by the factory (only the bound
|
|
12
|
+
// operations are) — verify the key shape indirectly through a fake Redis
|
|
13
|
+
// client that records the keys it's called with.
|
|
14
|
+
function fakeRedis() {
|
|
15
|
+
const calls: { method: string; args: unknown[] }[] = [];
|
|
16
|
+
const redis = {
|
|
17
|
+
mget: async (...args: unknown[]) => {
|
|
18
|
+
calls.push({ method: "mget", args });
|
|
19
|
+
return [null, null];
|
|
20
|
+
},
|
|
21
|
+
incr: async (...args: unknown[]) => {
|
|
22
|
+
calls.push({ method: "incr", args });
|
|
23
|
+
return 1;
|
|
24
|
+
},
|
|
25
|
+
expire: async (...args: unknown[]) => {
|
|
26
|
+
calls.push({ method: "expire", args });
|
|
27
|
+
return 1;
|
|
28
|
+
},
|
|
29
|
+
set: async (...args: unknown[]) => {
|
|
30
|
+
calls.push({ method: "set", args });
|
|
31
|
+
return "OK";
|
|
32
|
+
},
|
|
33
|
+
get: async (...args: unknown[]) => {
|
|
34
|
+
calls.push({ method: "get", args });
|
|
35
|
+
return null;
|
|
36
|
+
},
|
|
37
|
+
del: async (...args: unknown[]) => {
|
|
38
|
+
calls.push({ method: "del", args });
|
|
39
|
+
return 1;
|
|
40
|
+
},
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
|
|
42
|
+
} as any;
|
|
43
|
+
return { redis, calls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("account-lockout Redis key strings", () => {
|
|
47
|
+
const counter = createLockoutCounter("kumiko:auth:lockout:count:", "kumiko:auth:lockout:until:");
|
|
48
|
+
|
|
49
|
+
test("getState reads the byte-identical count/until keys", async () => {
|
|
50
|
+
const { redis, calls } = fakeRedis();
|
|
51
|
+
await counter.getState(redis, "u1");
|
|
52
|
+
expect(calls[0]).toEqual({
|
|
53
|
+
method: "mget",
|
|
54
|
+
args: ["kumiko:auth:lockout:count:u1", "kumiko:auth:lockout:until:u1"],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("clearState deletes the byte-identical count/until keys", async () => {
|
|
59
|
+
const { redis, calls } = fakeRedis();
|
|
60
|
+
await counter.clearState(redis, "u1");
|
|
61
|
+
expect(calls[0]).toEqual({
|
|
62
|
+
method: "del",
|
|
63
|
+
args: ["kumiko:auth:lockout:count:u1", "kumiko:auth:lockout:until:u1"],
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("mfa-verify Redis key strings", () => {
|
|
69
|
+
const counter = createLockoutCounter(
|
|
70
|
+
"kumiko:auth:mfa-verify:count:",
|
|
71
|
+
"kumiko:auth:mfa-verify:until:",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
test("getState reads the byte-identical count/until keys", async () => {
|
|
75
|
+
const { redis, calls } = fakeRedis();
|
|
76
|
+
await counter.getState(redis, "u1");
|
|
77
|
+
expect(calls[0]).toEqual({
|
|
78
|
+
method: "mget",
|
|
79
|
+
args: ["kumiko:auth:mfa-verify:count:u1", "kumiko:auth:mfa-verify:until:u1"],
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("clearState deletes the byte-identical count/until keys", async () => {
|
|
84
|
+
const { redis, calls } = fakeRedis();
|
|
85
|
+
await counter.clearState(redis, "u1");
|
|
86
|
+
expect(calls[0]).toEqual({
|
|
87
|
+
method: "del",
|
|
88
|
+
args: ["kumiko:auth:mfa-verify:count:u1", "kumiko:auth:mfa-verify:until:u1"],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Generic Redis-backed failed-attempt counter with auto-expiring lockout.
|
|
2
|
+
// Extracted from auth-email-password/lockout-store.ts and
|
|
3
|
+
// auth-mfa/mfa-verify-attempts.ts (infra#446) — both were byte-identical
|
|
4
|
+
// INCR/NX logic, differing only in their Redis key prefixes.
|
|
5
|
+
|
|
6
|
+
import type Redis from "ioredis";
|
|
7
|
+
|
|
8
|
+
export type LockoutCounterState = {
|
|
9
|
+
readonly failureCount: number;
|
|
10
|
+
// Epoch milliseconds when the account/attempt auto-unlocks. null while the
|
|
11
|
+
// counter is still below threshold.
|
|
12
|
+
readonly lockedUntil: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Two keys per subject so each can carry its own TTL:
|
|
16
|
+
// - count-key: 24h, carries the streak. Monotonic — once threshold is
|
|
17
|
+
// crossed it STAYS crossed until the caller explicitly clears it.
|
|
18
|
+
// - until-key: exactly the lockout duration, auto-expires when the lock
|
|
19
|
+
// ends (Redis TTL replaces a "timer" that would otherwise need a job).
|
|
20
|
+
//
|
|
21
|
+
// Consequence of the monotonic counter: once a subject has been locked, the
|
|
22
|
+
// NEXT failure after the lock expires re-locks immediately — the INCR still
|
|
23
|
+
// returns a value ≥ threshold, so the SET NX re-arms the lock. Clearing the
|
|
24
|
+
// streak is the caller's responsibility (e.g. a successful login, or a
|
|
25
|
+
// dedicated unlock flow) — intentional: brute-force resistance favours
|
|
26
|
+
// strictness over UX for anonymous attempts.
|
|
27
|
+
export function createLockoutCounter(countKeyPrefix: string, untilKeyPrefix: string) {
|
|
28
|
+
function countKey(subjectId: string): string {
|
|
29
|
+
return `${countKeyPrefix}${subjectId}`;
|
|
30
|
+
}
|
|
31
|
+
function untilKey(subjectId: string): string {
|
|
32
|
+
return `${untilKeyPrefix}${subjectId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getState(redis: Redis, subjectId: string): Promise<LockoutCounterState | null> {
|
|
36
|
+
const [countRaw, untilRaw] = await redis.mget(countKey(subjectId), untilKey(subjectId));
|
|
37
|
+
if (countRaw === null) return null;
|
|
38
|
+
const failureCount = Number(countRaw);
|
|
39
|
+
if (!Number.isFinite(failureCount)) return null;
|
|
40
|
+
const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
|
|
41
|
+
return {
|
|
42
|
+
failureCount,
|
|
43
|
+
lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Race-free: INCR is atomic at the Redis level, so N concurrent failed
|
|
48
|
+
// attempts produce exactly N increments — no GET-SET window to lose an
|
|
49
|
+
// increment through. The NX on the until-key likewise guarantees only one
|
|
50
|
+
// attempt out of a concurrent batch sets the lock timestamp; subsequent
|
|
51
|
+
// concurrent attempts find the key already set and leave it alone, so the
|
|
52
|
+
// lock window stays anchored to the first-to-cross, not the last.
|
|
53
|
+
async function recordFailedAttempt(
|
|
54
|
+
redis: Redis,
|
|
55
|
+
subjectId: string,
|
|
56
|
+
maxFailedAttempts: number,
|
|
57
|
+
lockoutDurationMinutes: number,
|
|
58
|
+
): Promise<LockoutCounterState> {
|
|
59
|
+
const lockDurationMs = lockoutDurationMinutes * 60 * 1000;
|
|
60
|
+
// TTL on the count-key: 24h covers "I fat-fingered yesterday". The
|
|
61
|
+
// lockout duration is on the until-key; the count-key outlives it so an
|
|
62
|
+
// expired lock leaves a counter ≥ threshold — that's what makes the next
|
|
63
|
+
// miss immediately re-lock (strict-semantic; see the type-comment above).
|
|
64
|
+
const ttlSec = Math.max(lockoutDurationMinutes * 60, 24 * 3600);
|
|
65
|
+
|
|
66
|
+
const count = await redis.incr(countKey(subjectId));
|
|
67
|
+
if (count === 1) {
|
|
68
|
+
// First failure → set the TTL. INCR doesn't set one; a counter without
|
|
69
|
+
// TTL would leak forever for subjects that never return.
|
|
70
|
+
await redis.expire(countKey(subjectId), ttlSec);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let lockedUntil: number | null = null;
|
|
74
|
+
if (count >= maxFailedAttempts) {
|
|
75
|
+
const computedUntil = Date.now() + lockDurationMs;
|
|
76
|
+
// NX: only set if no lock is currently armed. A second concurrent attempt
|
|
77
|
+
// arriving after the first crossed the threshold must NOT reset the
|
|
78
|
+
// timer — the lock window should align with the attempt that crossed,
|
|
79
|
+
// not the one that happened a millisecond later.
|
|
80
|
+
const setOk = await redis.set(
|
|
81
|
+
untilKey(subjectId),
|
|
82
|
+
String(computedUntil),
|
|
83
|
+
"PX",
|
|
84
|
+
lockDurationMs,
|
|
85
|
+
"NX",
|
|
86
|
+
);
|
|
87
|
+
if (setOk === "OK") {
|
|
88
|
+
lockedUntil = computedUntil;
|
|
89
|
+
} else {
|
|
90
|
+
// Another concurrent attempt already locked — read the authoritative
|
|
91
|
+
// timestamp so the returned state matches what a follow-up
|
|
92
|
+
// getState would see.
|
|
93
|
+
const existing = await redis.get(untilKey(subjectId));
|
|
94
|
+
lockedUntil = existing !== null ? Number(existing) : null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { failureCount: count, lockedUntil };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Idempotent — deleting missing keys is a no-op, so a replayed clear just
|
|
102
|
+
// re-clears harmlessly.
|
|
103
|
+
async function clearState(redis: Redis, subjectId: string): Promise<void> {
|
|
104
|
+
await redis.del(countKey(subjectId), untilKey(subjectId));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { getState, recordFailedAttempt, clearState };
|
|
108
|
+
}
|