@cosmicdrift/kumiko-framework 0.157.1 → 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.157.1",
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.157.1",
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
+ });
@@ -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 { createAuthRoutes, createInMemoryLoginRateLimiter } from "./auth-routes";
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,
@@ -225,3 +225,69 @@ describe("createAllInOneEntrypoint — kumiko_job_queue_depth sees the SAME mete
225
225
  }
226
226
  });
227
227
  });
228
+
229
+ // Regression test for #1253 — effectiveFeatures was wired into
230
+ // dispatcherOptions (command-dispatcher's feature-gate) but never merged
231
+ // into the job-runner's context. A job handler reading
232
+ // `context.effectiveFeatures?.(tenantId)` always saw undefined in prod.
233
+ const featuresSeenByJob: Array<ReadonlySet<string> | undefined> = [];
234
+
235
+ const featureGateJobFeature = defineFeature("featuregate", (r) => {
236
+ r.writeHandler(
237
+ "ping",
238
+ z.object({ msg: z.string() }),
239
+ async (event) => ({
240
+ isSuccess: true as const,
241
+ data: { id: 1, msg: event.payload.msg },
242
+ }),
243
+ { access: { openToAll: true } },
244
+ );
245
+ r.job(
246
+ "record-features",
247
+ { trigger: { on: "featuregate:write:ping" }, runIn: "worker" },
248
+ async (_payload, context) => {
249
+ featuresSeenByJob.push(context.effectiveFeatures?.(adminUser.tenantId));
250
+ },
251
+ );
252
+ });
253
+
254
+ describe("createAllInOneEntrypoint — job context sees dispatcherOptions.effectiveFeatures (#1253)", () => {
255
+ test("job handler reads the same effectiveFeatures resolver as the command-dispatcher's feature-gate", async () => {
256
+ featuresSeenByJob.length = 0;
257
+ const registry = createRegistry([featureGateJobFeature]);
258
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
259
+ const entry = createAllInOneEntrypoint({
260
+ registry,
261
+ context: { db: testDb.db, redis: testRedis.redis },
262
+ jwtSecret: JWT,
263
+ redisUrl,
264
+ queueNamePrefix: `wiring-features-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
265
+ dispatcherOptions: {
266
+ effectiveFeatures: () => new Set(["featuregate", "some-feature"]),
267
+ },
268
+ });
269
+ await entry.start();
270
+ try {
271
+ const token = await entry.jwt.sign(adminUser);
272
+ const res = await entry.app.request("/api/write", {
273
+ method: "POST",
274
+ headers: {
275
+ "Content-Type": "application/json",
276
+ Authorization: `Bearer ${token}`,
277
+ },
278
+ body: JSON.stringify({ type: "featuregate:write:ping", payload: { msg: "hi" } }),
279
+ });
280
+ const result = (await res.json()) as { isSuccess: boolean };
281
+ expect(result.isSuccess).toBe(true);
282
+
283
+ // Without the fix, context.effectiveFeatures is undefined and this
284
+ // stays empty forever — waitFor times out on the missing entry.
285
+ await waitFor(() => {
286
+ expect(featuresSeenByJob.length).toBeGreaterThan(0);
287
+ expect(featuresSeenByJob[0]?.has("some-feature")).toBe(true);
288
+ });
289
+ } finally {
290
+ await entry.stop();
291
+ }
292
+ });
293
+ });
@@ -37,6 +37,7 @@ import type { KumikoServer, ServerOptions } from "../api/server";
37
37
  import { buildServer } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
39
39
  import type { PgClient } from "../db/connection";
40
+ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
40
41
  import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
41
42
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
42
43
  import { createJobRunner } from "../jobs/job-runner";
@@ -188,8 +189,17 @@ function resolveObservability(
188
189
  function contextWithObservability(
189
190
  context: AppContext,
190
191
  observability: ObservabilityProvider,
192
+ effectiveFeatures: EffectiveFeaturesResolver | undefined,
191
193
  ): AppContext {
192
- return { ...context, tracer: observability.tracer, meter: observability.meter };
194
+ return {
195
+ ...context,
196
+ // Same source of truth as buildServer's contextWithObservability
197
+ // (api/server.ts) — job handlers reading ctx.effectiveFeatures should
198
+ // see the same resolver as command handlers, not just dispatcherOptions.
199
+ ...(effectiveFeatures && { effectiveFeatures }),
200
+ tracer: observability.tracer,
201
+ meter: observability.meter,
202
+ };
193
203
  }
194
204
 
195
205
  // buildApiServer shapes ServerOptions from API-mode caller-options.
@@ -342,7 +352,11 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
342
352
  const apiJobRunner = options.jobs
343
353
  ? buildJobRunnerWithHook(
344
354
  options.registry,
345
- contextWithObservability(options.context, observability),
355
+ contextWithObservability(
356
+ options.context,
357
+ observability,
358
+ options.dispatcherOptions?.effectiveFeatures,
359
+ ),
346
360
  options.jobs,
347
361
  options.jobs.runLocalJobs ? "api" : undefined,
348
362
  lifecycle,
@@ -396,7 +410,11 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
396
410
  const observability = resolveObservability(options.observability);
397
411
  const jobRunner = buildJobRunnerWithHook(
398
412
  options.registry,
399
- contextWithObservability(options.context, observability),
413
+ contextWithObservability(
414
+ options.context,
415
+ observability,
416
+ options.dispatcherOptions?.effectiveFeatures,
417
+ ),
400
418
  options,
401
419
  "worker",
402
420
  lifecycle,
@@ -426,7 +444,11 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
426
444
  export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint {
427
445
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
428
446
  const observability = resolveObservability(options.observability);
429
- const jobRunnerContext = contextWithObservability(options.context, observability);
447
+ const jobRunnerContext = contextWithObservability(
448
+ options.context,
449
+ observability,
450
+ options.dispatcherOptions?.effectiveFeatures,
451
+ );
430
452
 
431
453
  // All-in-one consumes BOTH lanes: two runners, each with a BullMQ worker
432
454
  // for its own lane's queue. Both runners hold queue-clients for both
@@ -36,6 +36,27 @@ const manualDispatchFeature = defineFeature("manualdispatch", (r) => {
36
36
  });
37
37
  });
38
38
 
39
+ // kumiko-framework#1232: setupTestStack used to hand jobs a bare
40
+ // `{ db, registry }` context literal — nothing else buildServer's request
41
+ // path gets (tracer/meter, searchAdapter, ...). A job could pass in tests
42
+ // while reading a field only prod's context has, then break silently on
43
+ // deploy. This job records which fields actually arrived.
44
+ const jobContextFields: Array<{
45
+ hasTracer: boolean;
46
+ hasSearchAdapter: boolean;
47
+ hasEffectiveFeatures: boolean;
48
+ }> = [];
49
+
50
+ const jobContextFeature = defineFeature("jobcontextcheck", (r) => {
51
+ r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (_payload, context) => {
52
+ jobContextFields.push({
53
+ hasTracer: context.tracer !== undefined,
54
+ hasSearchAdapter: context.searchAdapter !== undefined,
55
+ hasEffectiveFeatures: context.effectiveFeatures !== undefined,
56
+ });
57
+ });
58
+ });
59
+
39
60
  let stack: TestStack | undefined;
40
61
 
41
62
  afterEach(async () => {
@@ -76,3 +97,25 @@ describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", ()
76
97
  expect(err.code).toBe("internal_error");
77
98
  });
78
99
  });
100
+
101
+ describe("setupTestStack({ jobs }) job context matches the request-path context", () => {
102
+ test("job handler's context carries tracer + searchAdapter + effectiveFeatures, not a reduced literal", async () => {
103
+ jobContextFields.length = 0;
104
+ stack = await setupTestStack({
105
+ features: [jobContextFeature],
106
+ jobs: { consumerLane: "worker" },
107
+ effectiveFeatures: () => new Set(["jobcontextcheck"]),
108
+ });
109
+
110
+ await stack.jobRunner?.dispatch("jobcontextcheck:job:record");
111
+
112
+ await waitFor(() => {
113
+ expect(jobContextFields.length).toBeGreaterThan(0);
114
+ });
115
+ expect(jobContextFields[0]).toEqual({
116
+ hasTracer: true,
117
+ hasSearchAdapter: true,
118
+ hasEffectiveFeatures: true,
119
+ });
120
+ });
121
+ });
@@ -10,7 +10,7 @@ import type { FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/
10
10
  import { createArchivedStreamsTable, createEventsTable } from "../event-store";
11
11
  import { createJobRunner, type JobRunner } from "../jobs";
12
12
  import type { Lifecycle } from "../lifecycle";
13
- import type { ObservabilityProvider } from "../observability";
13
+ import { createNoopProvider, type ObservabilityProvider } from "../observability";
14
14
  import type { Dispatcher, EventDispatcher } from "../pipeline";
15
15
  import { createEntityCache, createEventDedup, createIdempotencyGuard } from "../pipeline";
16
16
  import { createInMemorySearchAdapter } from "../search";
@@ -230,16 +230,66 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
230
230
  const events = createEventCollector();
231
231
  const registry = createRegistry([...options.features]);
232
232
 
233
+ // Wire SSE broker with event collector — built early (not just before
234
+ // buildServer) because extraContext + the job context below both need it.
235
+ const sseBroker = createSseBroker();
236
+ sseBroker.addClient(
237
+ "tenant:00000000-0000-4000-8000-000000000001",
238
+ (event) => events.sse.push(event),
239
+ () => {},
240
+ );
241
+
242
+ const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
243
+
244
+ // A static `files.storageProvider` is wired as the per-tenant resolver — the
245
+ // framework test seam that doesn't require mounting config + file-foundation.
246
+ // (Bundled GDPR tests mount the real provider features instead.)
247
+ let fileProviderResolver: import("../files").FileProviderResolver | undefined;
248
+ if (options.files) {
249
+ const provider = options.files.storageProvider;
250
+ fileProviderResolver = () => Promise.resolve(provider);
251
+ }
252
+
253
+ const observability = options.observability ?? createNoopProvider();
254
+
255
+ // Same AppContext buildServer() gets below (db/redis/searchAdapter/
256
+ // entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
257
+ // shared so the job context can't drift from the request-path context
258
+ // (kumiko-framework#1232: a reduced `{ db, registry }` literal let jobs
259
+ // pass in tests while reaching for fields only prod's context has).
260
+ //
261
+ // effectiveFeatures is included per the issue's explicit ask, even though
262
+ // prod's job context never gets it today (only dispatcherOptions.
263
+ // effectiveFeatures, consumed by the command-dispatcher — see
264
+ // buildJobRunnerWithHook in entrypoint/index.ts). Tracked as a real prod
265
+ // gap in a follow-up issue rather than silently matched here.
266
+ const appContext = {
267
+ db: testDb.db,
268
+ redis: testRedis.redis,
269
+ searchAdapter,
270
+ entityCache,
271
+ registry,
272
+ ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
273
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
274
+ ...(options.effectiveFeatures ? { effectiveFeatures: options.effectiveFeatures } : {}),
275
+ ...(typeof options.extraContext === "function"
276
+ ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
277
+ : options.extraContext),
278
+ };
279
+
233
280
  // Built before buildServer() so it can be merged into dispatcherOptions
234
281
  // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
235
282
  // event-trigger hook both need it present at dispatcher-construction
236
283
  // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
237
284
  // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
285
+ //
286
+ // context = appContext + tracer/meter, mirroring the prod entrypoint's
287
+ // `contextWithObservability(options.context, observability)`.
238
288
  let jobRunner: JobRunner | undefined;
239
289
  if (options.jobs && registry.getAllJobs().size > 0) {
240
290
  jobRunner = createJobRunner({
241
291
  registry,
242
- context: { db: testDb.db, registry },
292
+ context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
243
293
  // The real REDIS_URL, not one rebuilt from `.options` — that lost
244
294
  // password/username/tls/path (pr-review kumiko-framework #1036/2).
245
295
  redisUrl: testRedis.redisUrl,
@@ -291,42 +341,14 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
291
341
  }
292
342
  }
293
343
 
294
- // Wire SSE broker with event collector
295
- const sseBroker = createSseBroker();
296
- sseBroker.addClient(
297
- "tenant:00000000-0000-4000-8000-000000000001",
298
- (event) => events.sse.push(event),
299
- () => {},
300
- );
301
-
302
344
  const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
303
345
  const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
304
- const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
305
-
306
- // A static `files.storageProvider` is wired as the per-tenant resolver — the
307
- // framework test seam that doesn't require mounting config + file-foundation.
308
- // (Bundled GDPR tests mount the real provider features instead.)
309
- let fileProviderResolver: import("../files").FileProviderResolver | undefined;
310
- if (options.files) {
311
- const provider = options.files.storageProvider;
312
- fileProviderResolver = () => Promise.resolve(provider);
313
- }
314
346
 
315
347
  const server = buildServer({
316
348
  registry,
317
- context: {
318
- db: testDb.db,
319
- redis: testRedis.redis,
320
- searchAdapter,
321
- entityCache,
322
- registry,
323
- ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
324
- ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
325
- ...(typeof options.extraContext === "function"
326
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
327
- : options.extraContext),
328
- },
349
+ context: appContext,
329
350
  jwtSecret,
351
+ ...(options.observability ? { observability: options.observability } : {}),
330
352
  dispatcherOptions: {
331
353
  idempotency,
332
354
  ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
@@ -358,7 +380,6 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
358
380
  },
359
381
  }
360
382
  : {}),
361
- ...(options.observability ? { observability: options.observability } : {}),
362
383
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
363
384
  ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
364
385
  ...(options.anonymousAccess