@cosmicdrift/kumiko-framework 0.157.2 → 0.158.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.158.2",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.158.2",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
3
|
+
import { createRedisLoginRateLimiter } from "../auth-routes";
|
|
4
|
+
|
|
5
|
+
let testRedis: TestRedis;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
testRedis = await createTestRedis();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await testRedis.cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
await testRedis.flushNamespace();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("createRedisLoginRateLimiter", () => {
|
|
20
|
+
test("allows exactly maxAttempts checks, then blocks", async () => {
|
|
21
|
+
const limiter = createRedisLoginRateLimiter(testRedis.redis, 3, 60_000);
|
|
22
|
+
|
|
23
|
+
expect(await limiter.check("1.2.3.4|user@test.local")).toBe(true);
|
|
24
|
+
expect(await limiter.check("1.2.3.4|user@test.local")).toBe(true);
|
|
25
|
+
expect(await limiter.check("1.2.3.4|user@test.local")).toBe(true);
|
|
26
|
+
expect(await limiter.check("1.2.3.4|user@test.local")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("reset clears the counter for that key", async () => {
|
|
30
|
+
const limiter = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000);
|
|
31
|
+
|
|
32
|
+
expect(await limiter.check("k")).toBe(true);
|
|
33
|
+
expect(await limiter.check("k")).toBe(false);
|
|
34
|
+
|
|
35
|
+
await limiter.reset("k");
|
|
36
|
+
expect(await limiter.check("k")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("buckets are independent per key", async () => {
|
|
40
|
+
const limiter = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000);
|
|
41
|
+
|
|
42
|
+
expect(await limiter.check("a")).toBe(true);
|
|
43
|
+
expect(await limiter.check("b")).toBe(true);
|
|
44
|
+
expect(await limiter.check("a")).toBe(false);
|
|
45
|
+
expect(await limiter.check("b")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("namespace keeps two limiter instances from sharing a keyspace", async () => {
|
|
49
|
+
const login = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000, "login");
|
|
50
|
+
const mfa = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000, "mfa-verify");
|
|
51
|
+
|
|
52
|
+
expect(await login.check("1.2.3.4")).toBe(true);
|
|
53
|
+
expect(await mfa.check("1.2.3.4")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("counter resets once windowMs elapses", async () => {
|
|
57
|
+
const limiter = createRedisLoginRateLimiter(testRedis.redis, 1, 150);
|
|
58
|
+
|
|
59
|
+
expect(await limiter.check("k")).toBe(true);
|
|
60
|
+
expect(await limiter.check("k")).toBe(false);
|
|
61
|
+
|
|
62
|
+
await Bun.sleep(200);
|
|
63
|
+
|
|
64
|
+
expect(await limiter.check("k")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
4
|
+
import type Redis from "ioredis";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { buildSessionRoles } from "../engine/membership-roles";
|
|
6
7
|
import { createSystemUser } from "../engine/system-user";
|
|
@@ -461,6 +462,37 @@ export function createInMemoryLoginRateLimiter(
|
|
|
461
462
|
};
|
|
462
463
|
}
|
|
463
464
|
|
|
465
|
+
// Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
|
|
466
|
+
// semantics (count <= maxAttempts allows, window anchored on first hit), but
|
|
467
|
+
// shared across replicas via INCR/PEXPIRE instead of an in-process Map.
|
|
468
|
+
// runProdApp defaults to this: an in-memory limiter only rate-limits within
|
|
469
|
+
// a single instance, so a multi-replica prod deployment would silently give
|
|
470
|
+
// each replica its own bucket. namespace separates the login-key keyspace
|
|
471
|
+
// from the mfa-verify one (they share the same LoginRateLimiter shape but
|
|
472
|
+
// key on different values).
|
|
473
|
+
export function createRedisLoginRateLimiter(
|
|
474
|
+
redis: Redis,
|
|
475
|
+
maxAttempts = 10,
|
|
476
|
+
windowMs = 5 * 60_000,
|
|
477
|
+
namespace = "login",
|
|
478
|
+
): LoginRateLimiter {
|
|
479
|
+
const prefix = `kumiko:auth:ratelimit:${namespace}:`;
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
async check(key) {
|
|
483
|
+
const redisKey = `${prefix}${key}`;
|
|
484
|
+
const count = await redis.incr(redisKey);
|
|
485
|
+
if (count === 1) {
|
|
486
|
+
await redis.pexpire(redisKey, windowMs);
|
|
487
|
+
}
|
|
488
|
+
return count <= maxAttempts;
|
|
489
|
+
},
|
|
490
|
+
async reset(key) {
|
|
491
|
+
await redis.del(`${prefix}${key}`);
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
464
496
|
export function createAuthRoutes(
|
|
465
497
|
dispatcher: Dispatcher,
|
|
466
498
|
jwt: JwtHelper,
|
package/src/api/index.ts
CHANGED
|
@@ -19,7 +19,11 @@ export type {
|
|
|
19
19
|
SessionMetadata,
|
|
20
20
|
SessionRevoker,
|
|
21
21
|
} from "./auth-routes";
|
|
22
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
createAuthRoutes,
|
|
24
|
+
createInMemoryLoginRateLimiter,
|
|
25
|
+
createRedisLoginRateLimiter,
|
|
26
|
+
} from "./auth-routes";
|
|
23
27
|
export type { CachedResponseInit, CachePolicy } from "./http-cache";
|
|
24
28
|
export {
|
|
25
29
|
cacheControlHeader,
|