@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 +2 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
- package/src/api/auth-routes.ts +32 -0
- package/src/api/index.ts +5 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +66 -0
- package/src/entrypoint/index.ts +26 -4
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +43 -0
- package/src/stack/test-stack.ts +54 -33
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,
|
|
@@ -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
|
+
});
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
});
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
|
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: {
|
|
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
|