@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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.
Files changed (146) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +267 -19
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  13. package/src/api/__tests__/sse-broker.test.ts +57 -0
  14. package/src/api/__tests__/sse-route.test.ts +4 -0
  15. package/src/api/auth-routes.ts +165 -33
  16. package/src/api/index.ts +1 -0
  17. package/src/api/jwt.ts +22 -1
  18. package/src/api/routes.ts +103 -35
  19. package/src/api/server.ts +17 -1
  20. package/src/api/sse-broker.ts +39 -0
  21. package/src/bun-db/index.ts +1 -0
  22. package/src/bun-db/query.ts +12 -3
  23. package/src/consumer-cli.ts +60 -13
  24. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  25. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  26. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  27. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  28. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  29. package/src/db/api.ts +2 -2
  30. package/src/db/bun-provider.ts +2 -2
  31. package/src/db/connection.ts +6 -3
  32. package/src/db/dialect.ts +1 -6
  33. package/src/db/entity-table-meta-types.ts +1 -1
  34. package/src/db/event-store-executor-context.ts +2 -3
  35. package/src/db/event-store-executor-read.ts +2 -3
  36. package/src/db/event-store-executor-write.ts +8 -0
  37. package/src/db/index.ts +8 -1
  38. package/src/db/located-timestamp.ts +4 -0
  39. package/src/db/migrate-runner.ts +107 -11
  40. package/src/db/pg-error.ts +8 -0
  41. package/src/db/postgres-provider.ts +2 -2
  42. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  43. package/src/db/queries/ddl.ts +45 -0
  44. package/src/db/queries/event-store.ts +97 -5
  45. package/src/db/queries/test-stack.ts +4 -30
  46. package/src/db/reference-data.ts +2 -3
  47. package/src/db/replay-migration-sql.ts +114 -12
  48. package/src/db/tenant-db.ts +2 -4
  49. package/src/engine/__tests__/engine.test.ts +30 -0
  50. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  51. package/src/engine/__tests__/store-table.test.ts +2 -2
  52. package/src/engine/boot-validator/nav.ts +5 -0
  53. package/src/engine/constants.ts +32 -6
  54. package/src/engine/create-app.ts +11 -0
  55. package/src/engine/effective-features.ts +12 -2
  56. package/src/engine/extensions/user-data.ts +12 -4
  57. package/src/engine/feature-ui-extensions.ts +2 -2
  58. package/src/engine/hook-helpers.ts +3 -1
  59. package/src/engine/index.ts +1 -1
  60. package/src/engine/ownership.ts +4 -3
  61. package/src/engine/registry-ingest.ts +14 -14
  62. package/src/engine/registry-state.ts +4 -1
  63. package/src/engine/schema-builder.ts +1 -0
  64. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  65. package/src/engine/steps/_duration-utils.ts +2 -0
  66. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  67. package/src/engine/types/config.ts +1 -1
  68. package/src/engine/types/define-handler.ts +1 -1
  69. package/src/engine/types/entity-handlers.ts +1 -1
  70. package/src/engine/types/event-type-map.ts +1 -1
  71. package/src/engine/types/feature.ts +1 -1
  72. package/src/engine/types/fields.ts +1 -1
  73. package/src/engine/types/handlers.ts +1 -1
  74. package/src/engine/types/hooks.ts +1 -1
  75. package/src/engine/types/http-route.ts +1 -1
  76. package/src/engine/types/nav.ts +1 -1
  77. package/src/engine/types/ownership.ts +1 -1
  78. package/src/engine/types/projection.ts +1 -1
  79. package/src/engine/types/relations.ts +1 -1
  80. package/src/engine/types/screen.ts +1 -1
  81. package/src/engine/types/step.ts +1 -1
  82. package/src/engine/types/target-ref.ts +1 -1
  83. package/src/engine/types/tree-node.ts +1 -1
  84. package/src/engine/types/workspace.ts +1 -1
  85. package/src/engine/validate-projection-allowlist.ts +5 -5
  86. package/src/errors/classes.ts +21 -0
  87. package/src/errors/index.ts +1 -0
  88. package/src/errors/write-error-info.ts +6 -2
  89. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  90. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  91. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  92. package/src/event-store/admin-api.ts +11 -4
  93. package/src/event-store/event-store.ts +19 -4
  94. package/src/event-store/types.ts +1 -1
  95. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  96. package/src/files/__tests__/local-provider.test.ts +31 -0
  97. package/src/files/__tests__/write-stream.test.ts +3 -3
  98. package/src/files/index.ts +1 -1
  99. package/src/files/local-provider.ts +6 -1
  100. package/src/files/types.ts +8 -1
  101. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  102. package/src/jobs/job-runner.ts +41 -11
  103. package/src/logging/types.ts +1 -1
  104. package/src/observability/index.ts +1 -0
  105. package/src/observability/standard-metrics.ts +35 -2
  106. package/src/observability/types/index.ts +1 -1
  107. package/src/observability/types/metric.ts +1 -1
  108. package/src/observability/types/provider.ts +1 -1
  109. package/src/observability/types/span.ts +1 -1
  110. package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
  111. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  112. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  113. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  114. package/src/pipeline/dispatch-shared.ts +59 -6
  115. package/src/pipeline/dispatch-stream.ts +32 -11
  116. package/src/pipeline/dispatcher.ts +7 -1
  117. package/src/pipeline/event-consumer-state.ts +16 -13
  118. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  119. package/src/pipeline/event-dispatcher.ts +22 -0
  120. package/src/pipeline/index.ts +2 -0
  121. package/src/pipeline/system-hooks.ts +87 -0
  122. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  123. package/src/rate-limit/resolver.ts +6 -2
  124. package/src/schema-cli.ts +24 -12
  125. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  126. package/src/search/reindex-entity.ts +31 -2
  127. package/src/search/types.ts +1 -1
  128. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  129. package/src/stack/db.ts +2 -1
  130. package/src/stack/push-entity-projection-tables.ts +2 -1
  131. package/src/stack/request-helper.ts +20 -1
  132. package/src/stack/table-helpers.ts +6 -4
  133. package/src/stack/test-stack.ts +18 -15
  134. package/src/testing/__tests__/late-bound.test.ts +7 -0
  135. package/src/testing/__tests__/wait-for.test.ts +6 -0
  136. package/src/testing/file-provider-contract.ts +26 -6
  137. package/src/testing/index.ts +1 -0
  138. package/src/testing/late-bound.ts +5 -3
  139. package/src/testing/wait-for.ts +3 -0
  140. package/src/testing/without-ambient-temporal.ts +14 -0
  141. package/src/time/geo-tz.ts +1 -1
  142. package/src/time/polyfill.ts +21 -38
  143. package/src/time/tz-context.ts +37 -31
  144. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  145. package/src/utils/safe-json.ts +13 -1
  146. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -0,0 +1,135 @@
1
+ // kumiko-framework#1539: `trustedProxyHops` on AuthRoutesConfig. Verifies
2
+ // the client-IP derivation behind /auth/mfa/verify's rate limiter — chosen
3
+ // because it keys on bare IP (no email composite), so a wrong default here
4
+ // is a shared-bucket DoS, not just a spoofing hole. Real Hono app + real
5
+ // in-memory rate limiter, HTTP requests with attacker-shaped headers — no
6
+ // fake dispatcher.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { Hono } from "hono";
10
+ import { Hono as HonoCtor } from "hono";
11
+ import { UnprocessableError } from "../../errors";
12
+ import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
13
+ import { PUBLIC_API_PATHS } from "../api-constants";
14
+ import { authMiddleware } from "../auth-middleware";
15
+ import {
16
+ type AuthRoutesConfig,
17
+ createAuthRoutes,
18
+ createInMemoryLoginRateLimiter,
19
+ } from "../auth-routes";
20
+ import { createJwtHelper } from "../jwt";
21
+
22
+ const JWT_SECRET = "test-secret-at-least-32-bytes-long-for-hs256";
23
+ const MFA_VERIFY_QN = "auth-mfa:write:verify";
24
+
25
+ function failingDispatcher(): Dispatcher {
26
+ return {
27
+ async write(): Promise<WriteResult> {
28
+ return { isSuccess: false, error: new UnprocessableError("invalid_totp_code") };
29
+ },
30
+ async query(): Promise<unknown> {
31
+ return [];
32
+ },
33
+ async *stream(): AsyncGenerator<unknown> {},
34
+ async command(): Promise<void> {},
35
+ async batch(): Promise<BatchResult> {
36
+ return { isSuccess: true, results: [] };
37
+ },
38
+ async resolveAuthClaims(): Promise<Record<string, unknown>> {
39
+ return {};
40
+ },
41
+ };
42
+ }
43
+
44
+ async function buildApp(overrides: Partial<AuthRoutesConfig> = {}): Promise<Hono> {
45
+ const jwt = createJwtHelper(JWT_SECRET);
46
+ const config: AuthRoutesConfig = {
47
+ membershipQuery: "tenant:query:memberships",
48
+ mfaVerifyHandler: MFA_VERIFY_QN,
49
+ mfaVerifyRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
50
+ ...overrides,
51
+ };
52
+ const app = new HonoCtor();
53
+ const jwtGuard = authMiddleware(jwt);
54
+ app.use("/api/*", async (c, next) => {
55
+ if (PUBLIC_API_PATHS.has(c.req.path)) return next();
56
+ return jwtGuard(c, next);
57
+ });
58
+ app.route("/api", createAuthRoutes(failingDispatcher(), jwt, config));
59
+ return app;
60
+ }
61
+
62
+ function verifyRequest(xForwardedFor?: string): Request {
63
+ return new Request("http://localhost/api/auth/mfa/verify", {
64
+ method: "POST",
65
+ headers: {
66
+ "Content-Type": "application/json",
67
+ ...(xForwardedFor !== undefined ? { "x-forwarded-for": xForwardedFor } : {}),
68
+ },
69
+ body: JSON.stringify({ challengeToken: "t", code: "000000" }),
70
+ });
71
+ }
72
+
73
+ describe("AuthRoutesConfig.trustedProxyHops", () => {
74
+ test("rejects a negative trustedProxyHops at construction — fails loud, not into a shared bucket", async () => {
75
+ await expect(buildApp({ trustedProxyHops: -1 })).rejects.toThrow(/non-negative integer/);
76
+ });
77
+
78
+ test("rejects a non-integer trustedProxyHops at construction", async () => {
79
+ await expect(buildApp({ trustedProxyHops: 1.5 })).rejects.toThrow(/non-negative integer/);
80
+ });
81
+
82
+ test("hops=1: an attacker rotating the untrusted XFF prefix still hits the real client's bucket", async () => {
83
+ const app = await buildApp({ trustedProxyHops: 1 });
84
+ // nginx-style append: "<attacker-claim>, <real-client-as-seen-by-nginx>"
85
+ // — attacker rotates the left entry per request, last entry stays fixed.
86
+ const attempt = (n: number) => app.request(verifyRequest(`1.2.3.${n}, 9.9.9.9`));
87
+ expect((await attempt(1)).status).toBe(422);
88
+ expect((await attempt(2)).status).toBe(422);
89
+ const third = await attempt(3);
90
+ expect(third.status).toBe(429);
91
+ });
92
+
93
+ test("hops=1: two genuinely different clients (differing last entry) get independent buckets", async () => {
94
+ const app = await buildApp({ trustedProxyHops: 1 });
95
+ const clientA = () => app.request(verifyRequest("fake, 9.9.9.9"));
96
+ const clientB = () => app.request(verifyRequest("fake, 8.8.8.8"));
97
+ expect((await clientA()).status).toBe(422);
98
+ expect((await clientA()).status).toBe(422);
99
+ expect((await clientA()).status).toBe(429);
100
+ // client B is unaffected by client A's cap
101
+ expect((await clientB()).status).toBe(422);
102
+ });
103
+
104
+ test("hops=0 (default): legacy behavior — trusts the first XFF entry unconditionally", async () => {
105
+ const app = await buildApp();
106
+ // No override: two "different" first-entries mean two different buckets,
107
+ // even though both requests carry the same real backend hop.
108
+ const clientA = () => app.request(verifyRequest("1.1.1.1, 9.9.9.9"));
109
+ const clientB = () => app.request(verifyRequest("2.2.2.2, 9.9.9.9"));
110
+ expect((await clientA()).status).toBe(422);
111
+ expect((await clientA()).status).toBe(422);
112
+ expect((await clientA()).status).toBe(429);
113
+ expect((await clientB()).status).toBe(422);
114
+ });
115
+
116
+ test("hops=2 with a shorter XFF chain than configured collapses distinct clients into the unknown bucket", async () => {
117
+ const app = await buildApp({ trustedProxyHops: 2 });
118
+ // Only 1 entry present but 2 hops configured — chain shorter than
119
+ // expected. Two DIFFERENT single-entry values must still share the cap:
120
+ // proves the short chain was rejected into "unknown" rather than
121
+ // extracted per-request (a same-value-only assertion would also pass
122
+ // for a constant return).
123
+ const attempt = (xff: string) => app.request(verifyRequest(xff));
124
+ expect((await attempt("9.9.9.9")).status).toBe(422);
125
+ expect((await attempt("7.7.7.7")).status).toBe(422);
126
+ const third = await attempt("6.6.6.6");
127
+ expect(third.status).toBe(429);
128
+ });
129
+
130
+ test("hops=1 with no XFF header at all falls back to unknown, not a throw", async () => {
131
+ const app = await buildApp({ trustedProxyHops: 1 });
132
+ const res = await app.request(verifyRequest(undefined));
133
+ expect(res.status).toBe(422);
134
+ });
135
+ });
@@ -97,7 +97,7 @@ const itemFeature = defineFeature("batch", (r) => {
97
97
  "postSave",
98
98
  { allOf: item },
99
99
  async (result, ctx) => {
100
- if (!ctx.db) return;
100
+ if (!ctx.db || !("insertOne" in ctx.db)) return;
101
101
  await ctx.db.insertOne(auditTable, { action: "item_saved", itemId: result.id });
102
102
  },
103
103
  { phase: HookPhases.inTransaction },
@@ -407,7 +407,26 @@ describe("POST /api/batch", () => {
407
407
 
408
408
  test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
409
409
  const requestId = "batch-rid-corrupt";
410
- await stack.redis.redis.set(`${RedisKeys.idempotency}${requestId}`, "{not-json", "EX", 60);
410
+ const cacheKey = `${RedisKeys.idempotency}${requestId}`;
411
+
412
+ // Prove key-coupling first: seed a well-formed cached entry under the
413
+ // exact manually-built key and confirm the batch short-circuits on it
414
+ // (no handler run, no DB write) — if the manual key ever drifts from
415
+ // what the guard actually reads, this half fails loud instead of the
416
+ // corrupt-JSON half below silently becoming a no-op cache-miss test.
417
+ const seeded = { isSuccess: true, results: [{ seeded: true }] };
418
+ await stack.redis.redis.set(cacheKey, JSON.stringify(seeded), "EX", 60);
419
+ const cachedRes = await stack.http.batch(
420
+ [{ type: "batch:write:item:create", payload: { name: "should-not-run" } }],
421
+ admin,
422
+ requestId,
423
+ );
424
+ expect(await cachedRes.json()).toEqual(seeded);
425
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
426
+ expect(inTxHookLog).toHaveLength(0);
427
+
428
+ // Now corrupt the same key and confirm it's treated as a miss.
429
+ await stack.redis.redis.set(cacheKey, "{not-json", "EX", 60);
411
430
 
412
431
  const res = await stack.http.batch(
413
432
  [{ type: "batch:write:item:create", payload: { name: "after-corrupt" } }],
@@ -185,10 +185,36 @@ describe("loadJwtSecretOrKeyring", () => {
185
185
  ).toThrow(/not present in the keyring/);
186
186
  });
187
187
 
188
- it("plain JWT_SECRET is ignored once a keyring is present", () => {
188
+ it("kumiko-framework#1308: plain JWT_SECRET carries over as a verify-only legacy key once a keyring is adopted", () => {
189
+ // Adopting rotation for the first time doesn't unset JWT_SECRET (it still
190
+ // signs the separate password-reset/email-verification HMAC family) —
191
+ // and sessions signed before adoption have no `kid`, so they must keep
192
+ // verifying against this exact value, not get orphaned the moment
193
+ // JWT_SECRET_V1 shows up.
194
+ const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
195
+ expect(
196
+ loadJwtSecretOrKeyring({
197
+ JWT_SECRET: PLAIN_SECRET,
198
+ JWT_SECRET_V1: SECRET,
199
+ JWT_SECRET_CURRENT_VERSION: "1",
200
+ }),
201
+ ).toEqual({ keys: { v1: SECRET, legacy: PLAIN_SECRET }, signKid: "v1" });
202
+ });
203
+
204
+ it("kumiko-framework#1308: legacy JWT_SECRET never becomes the sign key, only ever verify-only", () => {
205
+ const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
206
+ const keyring = loadJwtSecretOrKeyring({
207
+ JWT_SECRET: PLAIN_SECRET,
208
+ JWT_SECRET_V1: SECRET,
209
+ JWT_SECRET_CURRENT_VERSION: "1",
210
+ });
211
+ expect(typeof keyring).not.toBe("string");
212
+ expect((keyring as { signKid: string }).signKid).toBe("v1");
213
+ });
214
+
215
+ it("kumiko-framework#1308: no plain JWT_SECRET set alongside a keyring → no legacy key added", () => {
189
216
  expect(
190
217
  loadJwtSecretOrKeyring({
191
- JWT_SECRET: "should-be-ignored-once-keyring-present-aa",
192
218
  JWT_SECRET_V1: SECRET,
193
219
  JWT_SECRET_CURRENT_VERSION: "1",
194
220
  }),
@@ -209,6 +235,30 @@ describe("loadJwtSecretOrKeyring", () => {
209
235
  }),
210
236
  ).toThrow(/JWT_SECRET_V1 must be ≥32 chars/);
211
237
  });
238
+
239
+ it("kumiko-framework#1526: short legacy JWT_SECRET is rejected, not silently carried into the keyring", () => {
240
+ // Auth-bypass: a short JWT_SECRET carried into keys["legacy"] without a
241
+ // length check would be offline-bruteforceable — an attacker forges
242
+ // {alg:"HS256", kid:"legacy"} with arbitrary sub/tenantId/roles and
243
+ // verifyWithKeyring looks the key up by kid straight from the header.
244
+ expect(() =>
245
+ loadJwtSecretOrKeyring({
246
+ JWT_SECRET: "short",
247
+ JWT_SECRET_V1: SECRET,
248
+ JWT_SECRET_CURRENT_VERSION: "1",
249
+ }),
250
+ ).toThrow(/JWT_SECRET must be ≥32 chars/);
251
+ });
252
+
253
+ it("kumiko-framework#1308: empty JWT_SECRET_V<n> throws instead of silently falling back to single-secret mode", () => {
254
+ expect(() =>
255
+ loadJwtSecretOrKeyring({
256
+ JWT_SECRET: SECRET,
257
+ JWT_SECRET_V1: "",
258
+ JWT_SECRET_CURRENT_VERSION: "1",
259
+ }),
260
+ ).toThrow(/JWT_SECRET_V1 is set but empty/);
261
+ });
212
262
  });
213
263
 
214
264
  describe("createJwtHelper — ttlSeconds", () => {
@@ -63,4 +63,76 @@ describe("createRedisLoginRateLimiter", () => {
63
63
 
64
64
  expect(await limiter.check("k")).toBe(true);
65
65
  });
66
+
67
+ // Regression for the INCR-then-PEXPIRE race: two separate round-trips left
68
+ // a window for a crash/network blip right after the key is created
69
+ // (count===1) to skip the PEXPIRE call entirely, leaving the key
70
+ // permanently without a TTL. The single-eval fix closes that window by
71
+ // construction (one round-trip, can't be interrupted mid-way). "TTL
72
+ // exists after check()" alone doesn't distinguish old from new — the old
73
+ // two-call code also leaves a TTL set on the non-crashing path — so this
74
+ // pins the actual mechanism instead: exactly one `eval` round-trip, and
75
+ // `incr`/`pexpire` are never called directly (the only path is atomic).
76
+ test("check() goes through a single atomic eval — never incr/pexpire directly", async () => {
77
+ const limiter = createRedisLoginRateLimiter(testRedis.redis, 3, 60_000);
78
+ const key = "1.2.3.4|ttl-race@test.local";
79
+
80
+ // Manual call-counting instrumentation, not a test-library mock: each
81
+ // wrapper delegates to the real bound method on the real Redis client
82
+ // (setupTestStack), so behavior is unchanged — this only counts which
83
+ // wire commands actually went out, without the bun:test spy helper the
84
+ // integration-mock-guard forbids in *.integration.test.ts files.
85
+ const origEval = testRedis.redis.eval.bind(testRedis.redis);
86
+ const origIncr = testRedis.redis.incr.bind(testRedis.redis);
87
+ const origPexpire = testRedis.redis.pexpire.bind(testRedis.redis);
88
+ let evalCalls = 0;
89
+ let incrCalls = 0;
90
+ let pexpireCalls = 0;
91
+ testRedis.redis.eval = ((...args: Parameters<typeof origEval>) => {
92
+ evalCalls++;
93
+ return origEval(...args);
94
+ }) as typeof origEval;
95
+ testRedis.redis.incr = ((...args: Parameters<typeof origIncr>) => {
96
+ incrCalls++;
97
+ return origIncr(...args);
98
+ }) as typeof origIncr;
99
+ testRedis.redis.pexpire = ((...args: Parameters<typeof origPexpire>) => {
100
+ pexpireCalls++;
101
+ return origPexpire(...args);
102
+ }) as typeof origPexpire;
103
+ try {
104
+ expect(await limiter.check(key)).toBe(true);
105
+
106
+ expect(evalCalls).toBe(1);
107
+ expect(incrCalls).toBe(0);
108
+ expect(pexpireCalls).toBe(0);
109
+
110
+ const ttlMs = await testRedis.redis.pttl(`kumiko:auth:ratelimit:login:${key}`);
111
+ expect(ttlMs).toBeGreaterThan(0);
112
+ } finally {
113
+ testRedis.redis.eval = origEval;
114
+ testRedis.redis.incr = origIncr;
115
+ testRedis.redis.pexpire = origPexpire;
116
+ }
117
+ });
118
+
119
+ // kumiko-framework#1522 idx=5: a key that already exists WITHOUT a TTL
120
+ // (exactly what the pre-fix INCR-then-PEXPIRE race could leave behind)
121
+ // must get one applied on the very next check(), not stay permanently
122
+ // uncapped. Simulates the orphan directly via INCR (no TTL) rather than
123
+ // trying to hit the original race window, which isn't reproducible from
124
+ // outside the eval script.
125
+ test("heals a pre-existing key that has no TTL (orphaned by the old two-call race)", async () => {
126
+ const limiter = createRedisLoginRateLimiter(testRedis.redis, 5, 60_000, "heal-test");
127
+ const key = "1.2.3.4|orphan@test.local";
128
+ const redisKey = `kumiko:auth:ratelimit:heal-test:${key}`;
129
+
130
+ await testRedis.redis.incr(redisKey);
131
+ expect(await testRedis.redis.pttl(redisKey)).toBe(-1);
132
+
133
+ await limiter.check(key);
134
+
135
+ const ttlMs = await testRedis.redis.pttl(redisKey);
136
+ expect(ttlMs).toBeGreaterThan(0);
137
+ });
66
138
  });
@@ -55,4 +55,61 @@ describe("SSE broker", () => {
55
55
  expect(() => broker.removeClient("unknown", "fake-id")).not.toThrow();
56
56
  expect(broker.getClientCount("unknown")).toBe(0);
57
57
  });
58
+
59
+ test("subscribeAccessInvalidation fires only listeners on the same user's channel", () => {
60
+ const broker = createSseBroker();
61
+ const onInvalidateA = mock();
62
+ const onInvalidateB = mock();
63
+
64
+ broker.subscribeAccessInvalidation("user-a", onInvalidateA);
65
+ broker.subscribeAccessInvalidation("user-b", onInvalidateB);
66
+
67
+ broker.publishAccessInvalidation("user-a");
68
+
69
+ expect(onInvalidateA).toHaveBeenCalledTimes(1);
70
+ expect(onInvalidateB).not.toHaveBeenCalled();
71
+ });
72
+
73
+ test("subscribeAccessInvalidation supports multiple listeners on the same user", () => {
74
+ const broker = createSseBroker();
75
+ const first = mock();
76
+ const second = mock();
77
+
78
+ broker.subscribeAccessInvalidation("user-a", first);
79
+ broker.subscribeAccessInvalidation("user-a", second);
80
+ broker.publishAccessInvalidation("user-a");
81
+
82
+ expect(first).toHaveBeenCalledTimes(1);
83
+ expect(second).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ test("unsubscribe (returned closure) stops further delivery", () => {
87
+ const broker = createSseBroker();
88
+ const onInvalidate = mock();
89
+
90
+ const unsubscribe = broker.subscribeAccessInvalidation("user-a", onInvalidate);
91
+ unsubscribe();
92
+ broker.publishAccessInvalidation("user-a");
93
+
94
+ expect(onInvalidate).not.toHaveBeenCalled();
95
+ });
96
+
97
+ test("a fired listener can unsubscribe itself without skipping other listeners", () => {
98
+ const broker = createSseBroker();
99
+ let unsubscribeSelf: () => void = () => {};
100
+ const self = mock(() => unsubscribeSelf());
101
+ const other = mock();
102
+
103
+ unsubscribeSelf = broker.subscribeAccessInvalidation("user-a", self);
104
+ broker.subscribeAccessInvalidation("user-a", other);
105
+ broker.publishAccessInvalidation("user-a");
106
+
107
+ expect(self).toHaveBeenCalledTimes(1);
108
+ expect(other).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ test("publishAccessInvalidation to a user with no listeners does nothing", () => {
112
+ const broker = createSseBroker();
113
+ expect(() => broker.publishAccessInvalidation("nobody-listening")).not.toThrow();
114
+ });
58
115
  });
@@ -27,6 +27,10 @@ function createTrackingBroker(): { broker: SseBroker; subscribedChannel: Promise
27
27
  getTotalClientCount() {
28
28
  return 0;
29
29
  },
30
+ subscribeAccessInvalidation() {
31
+ return () => {};
32
+ },
33
+ publishAccessInvalidation() {},
30
34
  };
31
35
 
32
36
  return { broker, subscribedChannel };