@cosmicdrift/kumiko-framework 0.147.0 → 0.147.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.147.0",
3
+ "version": "0.147.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.147.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.147.2",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import { z } from "zod";
11
+ import type { AnonymousAccessConfig } from "../api/auth-middleware";
11
12
  import { createEventStoreExecutor } from "../db/event-store-executor";
12
13
  import { asRawClient, selectMany } from "../db/query";
13
14
  import { buildEntityTable } from "../db/table-builder";
@@ -307,6 +308,165 @@ describe("anonymous access — header-supplied tenant", () => {
307
308
  });
308
309
  });
309
310
 
311
+ describe("anonymous access — resolverTrust: authoritative", () => {
312
+ let stack: TestStack;
313
+
314
+ beforeAll(async () => {
315
+ // Simulates a subdomain resolver: TENANT_ID is "known" (the resolver
316
+ // recognises it), everything else is an unrecognised host (null).
317
+ stack = await setupTestStack({
318
+ features: [shopFeature],
319
+ anonymousAccess: {
320
+ tenantResolver: () => TENANT_ID,
321
+ resolverTrust: "authoritative",
322
+ tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
323
+ },
324
+ });
325
+ await unsafeCreateEntityTable(stack.db, productEntity);
326
+ await unsafeCreateEntityTable(stack.db, orderEntity);
327
+ });
328
+
329
+ afterAll(() => stack.cleanup());
330
+
331
+ test("no client tenant → resolver wins", async () => {
332
+ const res = await stack.http.raw("POST", "/api/query", {
333
+ type: "anonshop:query:product:list",
334
+ payload: {},
335
+ });
336
+ expect(res.status).toBe(200);
337
+ });
338
+
339
+ test("X-Tenant header agreeing with the resolver → accepted", async () => {
340
+ const res = await stack.http.raw(
341
+ "POST",
342
+ "/api/query",
343
+ { type: "anonshop:query:product:list", payload: {} },
344
+ { "X-Tenant": TENANT_ID },
345
+ );
346
+ expect(res.status).toBe(200);
347
+ });
348
+
349
+ test("X-Tenant header disagreeing with the resolver → 400 tenant_mismatch, resolver's tenant is NOT overridden", async () => {
350
+ // The core #51 regression: a header claiming a different (real, active)
351
+ // tenant than the one the resolver derived must never win — that would
352
+ // let a guest on tenantA's subdomain write into tenantB by forging a
353
+ // header. Reject, don't silently prefer either side.
354
+ const res = await stack.http.raw(
355
+ "POST",
356
+ "/api/query",
357
+ { type: "anonshop:query:product:list", payload: {} },
358
+ { "X-Tenant": OTHER_TENANT_ID },
359
+ );
360
+ expect(res.status).toBe(400);
361
+ const body = (await res.json()) as { error: { code: string } };
362
+ expect(body.error.code).toBe("tenant_mismatch");
363
+ });
364
+ });
365
+
366
+ describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
367
+ let stack: TestStack;
368
+
369
+ beforeAll(async () => {
370
+ // Resolver never recognises the host (e.g. an unmapped/unknown
371
+ // subdomain) — always returns null.
372
+ stack = await setupTestStack({
373
+ features: [shopFeature],
374
+ anonymousAccess: {
375
+ tenantResolver: () => null,
376
+ resolverTrust: "authoritative",
377
+ tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
378
+ },
379
+ });
380
+ await unsafeCreateEntityTable(stack.db, productEntity);
381
+ await unsafeCreateEntityTable(stack.db, orderEntity);
382
+ });
383
+
384
+ afterAll(() => stack.cleanup());
385
+
386
+ test("resolver null + X-Tenant header present → 400 tenant_required, header does NOT pick the tenant", async () => {
387
+ // If this fell back to the client header, an unrecognised-host request
388
+ // could still choose its own tenant via X-Tenant — the same override
389
+ // "authoritative" exists to prevent, just reached via "unknown host"
390
+ // instead of "known host, disagreeing header".
391
+ const res = await stack.http.raw(
392
+ "POST",
393
+ "/api/query",
394
+ { type: "anonshop:query:product:list", payload: {} },
395
+ { "X-Tenant": TENANT_ID },
396
+ );
397
+ expect(res.status).toBe(400);
398
+ const body = (await res.json()) as { error: { code: string } };
399
+ expect(body.error.code).toBe("tenant_required");
400
+ });
401
+
402
+ test("resolver null + no client tenant → 400 tenant_required", async () => {
403
+ const res = await stack.http.raw("POST", "/api/query", {
404
+ type: "anonshop:query:product:list",
405
+ payload: {},
406
+ });
407
+ expect(res.status).toBe(400);
408
+ const body = (await res.json()) as { error: { code: string } };
409
+ expect(body.error.code).toBe("tenant_required");
410
+ });
411
+ });
412
+
413
+ describe("anonymous access — resolverTrust: fallback-only", () => {
414
+ let stack: TestStack;
415
+
416
+ beforeAll(async () => {
417
+ stack = await setupTestStack({
418
+ features: [shopFeature],
419
+ anonymousAccess: {
420
+ tenantResolver: () => TENANT_ID,
421
+ resolverTrust: "fallback-only",
422
+ tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
423
+ },
424
+ });
425
+ await unsafeCreateEntityTable(stack.db, productEntity);
426
+ await unsafeCreateEntityTable(stack.db, orderEntity);
427
+ });
428
+
429
+ afterAll(() => stack.cleanup());
430
+
431
+ test("X-Tenant header disagreeing with the resolver → header wins (documented convenience-fallback behaviour)", async () => {
432
+ // Opposite of "authoritative": a client tenant that disagrees with the
433
+ // resolver is accepted, not rejected — this mode is for resolvers that
434
+ // carry no more trust than the client's own claim.
435
+ const res = await stack.http.raw(
436
+ "POST",
437
+ "/api/query",
438
+ { type: "anonshop:query:product:list", payload: {} },
439
+ { "X-Tenant": OTHER_TENANT_ID },
440
+ );
441
+ expect(res.status).toBe(200);
442
+ });
443
+
444
+ test("no client tenant → resolver runs as fallback", async () => {
445
+ const res = await stack.http.raw("POST", "/api/query", {
446
+ type: "anonshop:query:product:list",
447
+ payload: {},
448
+ });
449
+ expect(res.status).toBe(200);
450
+ });
451
+ });
452
+
453
+ describe("anonymous access — tenantResolver without resolverTrust", () => {
454
+ test("authMiddleware throws at boot, not silently at request time", async () => {
455
+ await expect(
456
+ setupTestStack({
457
+ features: [shopFeature],
458
+ // Simulates a caller that bypasses the compiler (JS consumer, an
459
+ // `as any` cast) — the discriminated union stops well-typed callers,
460
+ // this proves the runtime guard behind it also fires.
461
+ anonymousAccess: {
462
+ tenantResolver: () => TENANT_ID,
463
+ resolverTrust: undefined,
464
+ } as unknown as AnonymousAccessConfig,
465
+ }),
466
+ ).rejects.toThrow(/resolverTrust/);
467
+ });
468
+ });
469
+
310
470
  describe("anonymous access — disabled by default", () => {
311
471
  let stack: TestStack;
312
472
 
@@ -0,0 +1,201 @@
1
+ // auth-routes /auth/mfa/verify — framework-level route mechanics only:
2
+ // body validation, dispatch-and-mint-on-success, its OWN rate limiter
3
+ // (mfaVerifyRateLimit, distinct from loginRateLimit), and error-status
4
+ // mapping. Uses a stub Dispatcher with a fake mfaVerifyHandler — the REAL
5
+ // challenge-token verification / TOTP check / brute-force cap lives in
6
+ // auth-mfa's own handler and is covered there, not here.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { Hono } from "hono";
10
+ import { Hono as HonoCtor } from "hono";
11
+ import { InternalError, UnprocessableError } from "../../errors";
12
+ import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
13
+ import { TestUsers } from "../../stack";
14
+ import { getSetCookies } from "../../testing/http-cookies";
15
+ import { PUBLIC_API_PATHS } from "../api-constants";
16
+ import { AUTH_COOKIE_NAME, authMiddleware, CSRF_COOKIE_NAME } from "../auth-middleware";
17
+ import {
18
+ type AuthRoutesConfig,
19
+ createAuthRoutes,
20
+ createInMemoryLoginRateLimiter,
21
+ } from "../auth-routes";
22
+ import { createJwtHelper } from "../jwt";
23
+
24
+ const JWT_SECRET = "auth-routes-mfa-verify-test-secret-min-32-characters";
25
+ const MFA_VERIFY_QN = "auth-mfa:write:verify";
26
+
27
+ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
28
+ const base: Dispatcher = {
29
+ async write(): Promise<WriteResult> {
30
+ const ok: WriteResult = {
31
+ isSuccess: true,
32
+ data: { kind: "mfa-verify-success", session: TestUsers.user },
33
+ };
34
+ return ok;
35
+ },
36
+ async query(): Promise<unknown> {
37
+ return [];
38
+ },
39
+ async command(): Promise<void> {},
40
+ async batch(): Promise<BatchResult> {
41
+ const ok: BatchResult = { isSuccess: true, results: [] };
42
+ return ok;
43
+ },
44
+ async resolveAuthClaims(): Promise<Record<string, unknown>> {
45
+ return {};
46
+ },
47
+ };
48
+ return { ...base, ...overrides };
49
+ }
50
+
51
+ async function buildApp(
52
+ overrides: Partial<AuthRoutesConfig> = {},
53
+ dispatcher: Dispatcher = createStubDispatcher(),
54
+ ): Promise<{ app: Hono }> {
55
+ const jwt = createJwtHelper(JWT_SECRET);
56
+ const config: AuthRoutesConfig = {
57
+ membershipQuery: "tenant:query:memberships",
58
+ mfaVerifyHandler: MFA_VERIFY_QN,
59
+ mfaVerifyRateLimit: null,
60
+ ...overrides,
61
+ };
62
+ const app = new HonoCtor();
63
+ const jwtGuard = authMiddleware(jwt);
64
+ app.use("/api/*", async (c, next) => {
65
+ if (PUBLIC_API_PATHS.has(c.req.path)) return next();
66
+ return jwtGuard(c, next);
67
+ });
68
+ app.route("/api", createAuthRoutes(dispatcher, jwt, config));
69
+ return { app };
70
+ }
71
+
72
+ function verifyRequest(body: unknown): Request {
73
+ return new Request("http://localhost/api/auth/mfa/verify", {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ });
78
+ }
79
+
80
+ describe("POST /auth/mfa/verify", () => {
81
+ test("is public — reachable without a JWT", async () => {
82
+ expect(PUBLIC_API_PATHS.has("/api/auth/mfa/verify")).toBe(true);
83
+ });
84
+
85
+ test("not mounted when mfaVerifyHandler is unset", async () => {
86
+ const { app } = await buildApp({ mfaVerifyHandler: undefined });
87
+ const res = await app.request(verifyRequest({ challengeToken: "t", code: "123456" }));
88
+ expect(res.status).toBe(404);
89
+ });
90
+
91
+ test("400 on a malformed body, before dispatch or rate-limit", async () => {
92
+ let dispatched = false;
93
+ const dispatcher = createStubDispatcher({
94
+ async write(): Promise<WriteResult> {
95
+ dispatched = true;
96
+ return { isSuccess: true, data: { kind: "mfa-verify-success", session: TestUsers.user } };
97
+ },
98
+ });
99
+ const { app } = await buildApp({}, dispatcher);
100
+ const res = await app.request(verifyRequest({ challengeToken: "t" }));
101
+ expect(res.status).toBe(400);
102
+ const body = (await res.json()) as { isSuccess: boolean; error: string };
103
+ expect(body.error).toBe("invalid_body");
104
+ expect(dispatched).toBe(false);
105
+ });
106
+
107
+ test("on success: dispatches to mfaVerifyHandler, mints a JWT + cookies", async () => {
108
+ let receivedBody: unknown;
109
+ const dispatcher = createStubDispatcher({
110
+ async write(qn, payload): Promise<WriteResult> {
111
+ expect(qn).toBe(MFA_VERIFY_QN);
112
+ receivedBody = payload;
113
+ return { isSuccess: true, data: { kind: "mfa-verify-success", session: TestUsers.user } };
114
+ },
115
+ });
116
+ const { app } = await buildApp({}, dispatcher);
117
+ const res = await app.request(
118
+ verifyRequest({ challengeToken: "opaque-challenge-token", code: "123456" }),
119
+ );
120
+ expect(res.status).toBe(200);
121
+ expect(receivedBody).toEqual({
122
+ challengeToken: "opaque-challenge-token",
123
+ code: "123456",
124
+ });
125
+
126
+ const body = (await res.json()) as { isSuccess: boolean; token: string };
127
+ expect(body.isSuccess).toBe(true);
128
+ expect(typeof body.token).toBe("string");
129
+ expect(body.token.length).toBeGreaterThan(20);
130
+
131
+ const cookies = getSetCookies(res);
132
+ expect(cookies.get(AUTH_COOKIE_NAME)).toBeDefined();
133
+ expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined();
134
+ });
135
+
136
+ test("a handler failure maps through mfaVerifyErrorStatusMap", async () => {
137
+ const dispatcher = createStubDispatcher({
138
+ async write(): Promise<WriteResult> {
139
+ return {
140
+ isSuccess: false,
141
+ error: new UnprocessableError("invalid_totp_code", {
142
+ details: { reason: "invalid_totp_code" },
143
+ }),
144
+ };
145
+ },
146
+ });
147
+ const { app } = await buildApp(
148
+ { mfaVerifyErrorStatusMap: { invalid_totp_code: 422 } },
149
+ dispatcher,
150
+ );
151
+ const res = await app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
152
+ expect(res.status).toBe(422);
153
+ const body = (await res.json()) as { isSuccess: boolean };
154
+ expect(body.isSuccess).toBe(false);
155
+ });
156
+
157
+ test("an unmapped handler failure falls back to the error's own httpStatus", async () => {
158
+ const dispatcher = createStubDispatcher({
159
+ async write(): Promise<WriteResult> {
160
+ return { isSuccess: false, error: new InternalError({ message: "boom" }) };
161
+ },
162
+ });
163
+ const { app } = await buildApp({}, dispatcher);
164
+ const res = await app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
165
+ expect(res.status).toBe(500);
166
+ });
167
+
168
+ test("mfaVerifyRateLimit is independent from loginRateLimit — 429 after its own cap", async () => {
169
+ // A failing dispatcher — reset() only fires on success, so failed
170
+ // attempts accumulate against the cap instead of clearing it each time.
171
+ const dispatcher = createStubDispatcher({
172
+ async write(): Promise<WriteResult> {
173
+ return {
174
+ isSuccess: false,
175
+ error: new UnprocessableError("invalid_totp_code"),
176
+ };
177
+ },
178
+ });
179
+ const { app } = await buildApp(
180
+ { mfaVerifyRateLimit: createInMemoryLoginRateLimiter(2, 60_000) },
181
+ dispatcher,
182
+ );
183
+ const attempt = () => app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
184
+ expect((await attempt()).status).toBe(422);
185
+ expect((await attempt()).status).toBe(422);
186
+ const third = await attempt();
187
+ expect(third.status).toBe(429);
188
+ const body = (await third.json()) as { isSuccess: boolean; error: string };
189
+ expect(body.error).toBe("rate_limited");
190
+ });
191
+
192
+ test("a successful verify resets the rate-limit counter for that IP", async () => {
193
+ const { app } = await buildApp({
194
+ mfaVerifyRateLimit: createInMemoryLoginRateLimiter(1, 60_000),
195
+ });
196
+ const attempt = () => app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
197
+ expect((await attempt()).status).toBe(200);
198
+ // Without a reset-on-success this would be 429 (cap=1 already spent).
199
+ expect((await attempt()).status).toBe(200);
200
+ });
201
+ });
@@ -12,6 +12,7 @@ export const Routes = {
12
12
  sse: "/sse",
13
13
  auth: "/auth",
14
14
  authLogin: "/auth/login",
15
+ authMfaVerify: "/auth/mfa/verify",
15
16
  authLogout: "/auth/logout",
16
17
  authTenants: "/auth/tenants",
17
18
  authSwitchTenant: "/auth/switch-tenant",
@@ -36,6 +37,7 @@ export const Routes = {
36
37
  // The auth middleware skips these paths.
37
38
  export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
38
39
  `/api${Routes.authLogin}`,
40
+ `/api${Routes.authMfaVerify}`,
39
41
  `/api${Routes.authRequestPasswordReset}`,
40
42
  `/api${Routes.authResetPassword}`,
41
43
  `/api${Routes.authRequestEmailVerification}`,
@@ -98,25 +98,51 @@ export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId
98
98
  // deployments where a caller could otherwise probe arbitrary ids.
99
99
  export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
100
100
 
101
- export type AnonymousAccessConfig = {
102
- // Custom resolver (e.g. subdomain parser). Consulted only when neither
103
- // the X-Tenant header nor the kumiko_tenant cookie are present.
104
- readonly tenantResolver?: TenantResolver;
105
- // Single-tenant shortcut. When set, the server runs in **locked** mode:
106
- // - no client-supplied tenant: defaultTenantId is used.
107
- // - client supplies a matching tenant (header/cookie/resolver): allowed.
108
- // - client supplies a non-matching tenant: 400 tenant_mismatch (the
109
- // server is single-tenant; rejecting protects against confused clients
110
- // who think they're talking to a different deployment).
111
- // The framework does NOT verify defaultTenantId against the DB at boot;
112
- // the caller is responsible (see sample for the pattern).
101
+ // Single-tenant shortcut. When set, the server runs in **locked** mode:
102
+ // - no client-supplied tenant: defaultTenantId is used.
103
+ // - client supplies a matching tenant (header/cookie/resolver): allowed.
104
+ // - client supplies a non-matching tenant: 400 tenant_mismatch (the
105
+ // server is single-tenant; rejecting protects against confused clients
106
+ // who think they're talking to a different deployment).
107
+ // The framework does NOT verify defaultTenantId against the DB at boot;
108
+ // the caller is responsible (see sample for the pattern).
109
+ // Per-request existence check for header/cookie/resolver-supplied ids.
110
+ // Skipped for the defaultTenantId path (the caller already vetted that
111
+ // value when configuring the server).
112
+ type AnonymousAccessConfigCommon = {
113
113
  readonly defaultTenantId?: TenantId;
114
- // Per-request existence check for header/cookie/resolver-supplied ids.
115
- // Skipped for the defaultTenantId path (the caller already vetted that
116
- // value when configuring the server).
117
114
  readonly tenantExists?: TenantExists;
118
115
  };
119
116
 
117
+ // Union, not one flat optional-everything type: a tenantResolver without a
118
+ // declared resolverTrust is an ambiguous trust decision the compiler should
119
+ // catch, not a silent runtime default. Set resolverTrust to:
120
+ // - "authoritative": the resolver is trusted (e.g. it derives the tenant
121
+ // from the subdomain, which the client cannot forge) and is consulted
122
+ // FIRST. A client-supplied tenant that disagrees with the resolver's
123
+ // answer is rejected with 400 tenant_mismatch — it is never used to
124
+ // override the resolver, and it is never used as a substitute answer
125
+ // when the resolver returns null either (that would just reopen the
126
+ // same override via an unrecognised host). Pick this whenever the
127
+ // resolver derives the tenant from something the caller cannot control
128
+ // (subdomain, mTLS cert, etc.) — the whole point of such a resolver is
129
+ // defeated if a client header can still override it.
130
+ // - "fallback-only": a client-supplied header/cookie wins outright; the
131
+ // resolver only runs when neither is present. Pick this only when the
132
+ // resolver is a pure convenience fallback for callers that never send
133
+ // a tenant of their own (e.g. a bare API host with no per-tenant
134
+ // subdomains) and its answer carries no more trust than the client's
135
+ // own claim.
136
+ export type AnonymousAccessConfig =
137
+ | (AnonymousAccessConfigCommon & {
138
+ readonly tenantResolver?: undefined;
139
+ readonly resolverTrust?: undefined;
140
+ })
141
+ | (AnonymousAccessConfigCommon & {
142
+ readonly tenantResolver: TenantResolver;
143
+ readonly resolverTrust: "authoritative" | "fallback-only";
144
+ });
145
+
120
146
  // Where the candidate tenant came from. Drives the validation policy:
121
147
  // - header / cookie / resolver: untrusted, must pass tenantExists if set.
122
148
  // - default: trusted (configured at boot), no per-request check.
@@ -204,6 +230,17 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
204
230
  resolveTenantLifecycleStatus,
205
231
  } = options;
206
232
 
233
+ // Fail loud at boot, not silently at request time: a tenantResolver
234
+ // without a declared resolverTrust is an ambiguous trust decision no
235
+ // sane default can make on the app's behalf (see AnonymousAccessConfig).
236
+ if (anonymousAccess?.tenantResolver && anonymousAccess.resolverTrust === undefined) {
237
+ throw new Error(
238
+ "authMiddleware: anonymousAccess.tenantResolver is set without resolverTrust — " +
239
+ 'declare "authoritative" (resolver wins over client header/cookie) or ' +
240
+ '"fallback-only" (client wins, resolver is a last resort).',
241
+ );
242
+ }
243
+
207
244
  return async (c: Context, next: Next) => {
208
245
  const extracted = extractToken(c);
209
246
  if ("error" in extracted) {
@@ -473,34 +510,89 @@ async function resolveTenant(
473
510
  clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
474
511
  ): Promise<ResolvedTenant | ResolveError> {
475
512
  if (config.defaultTenantId !== undefined) {
476
- if (clientTenant && clientTenant.id !== config.defaultTenantId) {
513
+ return resolveAgainstDefault(config.defaultTenantId, clientTenant);
514
+ }
515
+ if (config.tenantResolver && config.resolverTrust === "authoritative") {
516
+ return await resolveWithAuthoritativeResolver(c, config.tenantResolver, clientTenant);
517
+ }
518
+ return await resolveWithClientPrecedence(c, config.tenantResolver, clientTenant);
519
+ }
520
+
521
+ // Locked single-tenant mode: the client either agrees with the default or
522
+ // gets tenant_mismatch — defending the deployment from confused clients
523
+ // that think they're talking to a different installation.
524
+ function resolveAgainstDefault(
525
+ defaultTenantId: TenantId,
526
+ clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
527
+ ): ResolvedTenant | ResolveError {
528
+ if (clientTenant && clientTenant.id !== defaultTenantId) {
529
+ return {
530
+ error: {
531
+ code: "tenant_mismatch",
532
+ status: 400,
533
+ message: `${clientTenant.source} tenant disagrees with server default`,
534
+ i18nKey: "auth.errors.tenantMismatch",
535
+ details: { clientTenantId: clientTenant.id, defaultTenantId },
536
+ },
537
+ };
538
+ }
539
+ return { tenantId: defaultTenantId, source: "default" };
540
+ }
541
+
542
+ // resolverTrust: "authoritative" — the resolver is trusted over the client.
543
+ // A client-supplied tenant that disagrees is rejected, never used to
544
+ // override the resolver's answer. Falls back to the client tenant only
545
+ // when the resolver itself has no opinion (unrecognised host).
546
+ async function resolveWithAuthoritativeResolver(
547
+ c: Context,
548
+ tenantResolver: TenantResolver,
549
+ clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
550
+ ): Promise<ResolvedTenant | ResolveError> {
551
+ const resolved = await tenantResolver(c);
552
+ if (resolved !== null && resolved !== undefined) {
553
+ if (clientTenant && clientTenant.id !== resolved) {
477
554
  return {
478
555
  error: {
479
556
  code: "tenant_mismatch",
480
557
  status: 400,
481
- message: `${clientTenant.source} tenant disagrees with server default`,
558
+ message: `${clientTenant.source} tenant disagrees with resolved tenant`,
482
559
  i18nKey: "auth.errors.tenantMismatch",
483
- details: {
484
- clientTenantId: clientTenant.id,
485
- defaultTenantId: config.defaultTenantId,
486
- },
560
+ details: { clientTenantId: clientTenant.id, resolvedTenantId: resolved },
487
561
  },
488
562
  };
489
563
  }
490
- return { tenantId: config.defaultTenantId, source: "default" };
564
+ return { tenantId: resolved, source: "resolver" };
491
565
  }
566
+ // Resolver had no opinion (unrecognised host) — do NOT fall back to the
567
+ // client-supplied tenant here. That would let an unrecognised-host
568
+ // request pick its own tenant via X-Tenant, the exact override this mode
569
+ // exists to prevent; it's just reached through "unknown host" instead of
570
+ // "known host, disagreeing header". Authoritative means the resolver's
571
+ // silence is final, not a delegation back to the client.
572
+ return tenantRequiredError();
573
+ }
492
574
 
575
+ // resolverTrust: "fallback-only" (or no resolver at all) — client tenant
576
+ // wins outright; the resolver only runs as a last resort when neither
577
+ // header nor cookie supplied a value.
578
+ async function resolveWithClientPrecedence(
579
+ c: Context,
580
+ tenantResolver: TenantResolver | undefined,
581
+ clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
582
+ ): Promise<ResolvedTenant | ResolveError> {
493
583
  if (clientTenant) {
494
584
  return { tenantId: clientTenant.id, source: clientTenant.source };
495
585
  }
496
-
497
- if (config.tenantResolver) {
498
- const resolved = await config.tenantResolver(c);
586
+ if (tenantResolver) {
587
+ const resolved = await tenantResolver(c);
499
588
  if (resolved !== null && resolved !== undefined) {
500
589
  return { tenantId: resolved, source: "resolver" };
501
590
  }
502
591
  }
592
+ return tenantRequiredError();
593
+ }
503
594
 
595
+ function tenantRequiredError(): ResolveError {
504
596
  return {
505
597
  error: {
506
598
  code: "tenant_required",
@@ -97,6 +97,16 @@ const LoginBody = z.object({
97
97
  password: z.string(),
98
98
  });
99
99
 
100
+ // Body schema for POST /auth/mfa/verify. challengeToken is opaque to the
101
+ // framework — it's minted and verified entirely by the mfaVerifyHandler
102
+ // (auth-mfa owns the token format, TOTP/recovery-code check, and the
103
+ // brute-force cap). code covers both 6-digit TOTP and XXXX-XXXX recovery
104
+ // codes (9 chars incl. the dash).
105
+ const MfaVerifyBody = z.object({
106
+ challengeToken: z.string().min(1),
107
+ code: z.string().min(6).max(9),
108
+ });
109
+
100
110
  const ResetPasswordBody = z.object({
101
111
  token: z.string().min(1),
102
112
  newPassword: z.string().min(8).max(200),
@@ -217,6 +227,28 @@ export type AuthRoutesConfig = {
217
227
  // Rate-limit for POST /auth/login. Defaults to in-memory 10/5min per
218
228
  // (ip + email) bucket. Pass `null` to disable (tests, trusted networks).
219
229
  loginRateLimit?: LoginRateLimiter | null;
230
+ // Optional: qualified write handler completing a two-step (password +
231
+ // TOTP/recovery-code) login. When set, POST /auth/mfa/verify dispatches
232
+ // { challengeToken, code } to this handler with a guest identity. On
233
+ // success the handler must return { kind: "mfa-verify-success", session:
234
+ // SessionUser } and the route mints a JWT exactly like /auth/login.
235
+ // Everything MFA-specific (challenge-token format, TOTP/recovery check,
236
+ // the per-account brute-force cap) is owned by the handler — the
237
+ // framework stays as agnostic about it as it is about password hashing.
238
+ mfaVerifyHandler?: string;
239
+ // Maps mfaVerifyHandler error codes to HTTP status codes, same pattern as
240
+ // loginErrorStatusMap. Unknown errors default to the error's own httpStatus.
241
+ mfaVerifyErrorStatusMap?: Readonly<Record<string, number>>;
242
+ // Rate-limit for POST /auth/mfa/verify, keyed by client IP. Defaults to
243
+ // in-memory 10/5min. Pass `null` to disable. This is DELIBERATELY separate
244
+ // from loginRateLimit: unlike /auth/login (a dispatcher write-handler
245
+ // route that could inherit a handler-level rateLimit), this is a
246
+ // framework route with no handler.rateLimit to fall back on — and it's
247
+ // also separate from any per-account brute-force cap the mfaVerifyHandler
248
+ // enforces itself (see its own doc comment) — IP-scoped abuse protection
249
+ // and per-account guessing protection are different threats, neither
250
+ // substitutes for the other.
251
+ mfaVerifyRateLimit?: LoginRateLimiter | null;
220
252
  // Session-lifecycle callbacks. When both are wired the JWT carries a `jti`
221
253
  // (sid) and the server can revoke individual sessions (logout, compromise,
222
254
  // password-change). When unwired the framework issues plain stateless JWTs.
@@ -511,8 +543,32 @@ export function createAuthRoutes(
511
543
  return c.json({ isSuccess: false, error: result.error }, status);
512
544
  }
513
545
 
514
- // @cast-boundary engine-payload — generic dispatcher.write result for auth-session handler
515
- const data = result.data as { kind: "auth-session"; session: SessionUser };
546
+ // @cast-boundary engine-payload — generic dispatcher.write result for
547
+ // login. Three possible shapes: a straight session, an MFA challenge
548
+ // when the loginHandler is wired with a second-factor gate, or a hard
549
+ // block when enforcement policy demands MFA but the user never
550
+ // enrolled (see auth-mfa's config.ts for why this has no in-band
551
+ // recovery yet).
552
+ const data = result.data as
553
+ | { kind: "auth-session"; session: SessionUser }
554
+ | { kind: "mfa-challenge"; challengeToken: string }
555
+ | { kind: "mfa-setup-required" };
556
+
557
+ if (data.kind === "mfa-setup-required") {
558
+ // No session, no challenge — the client must show an
559
+ // enrollment-required message. No rate-limit reset (same reasoning
560
+ // as the mfa-challenge branch below).
561
+ return c.json({ isSuccess: true, mfaSetupRequired: true });
562
+ }
563
+
564
+ if (data.kind === "mfa-challenge") {
565
+ // No session minted yet — no cookies, no token. The client must
566
+ // complete /auth/mfa/verify with this token before it gets either.
567
+ // Rate-limit counter is NOT reset here: a "correct password, wrong/
568
+ // no TOTP yet" outcome hasn't proven the caller owns the account any
569
+ // more than a wrong password did.
570
+ return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
571
+ }
516
572
 
517
573
  // Session creation (optional) + JWT sign + cookies — see
518
574
  // mintSessionAndRespond. Creating the session BEFORE signing the JWT
@@ -533,6 +589,76 @@ export function createAuthRoutes(
533
589
  });
534
590
  }
535
591
 
592
+ // POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
593
+ // structurally (public, GUEST_USER dispatch, mintSessionAndRespond on
594
+ // success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
595
+ // never goes through a dispatcher write-handler's own rateLimit config,
596
+ // so without this it would have NO rate limiting at all. Per-account
597
+ // brute-force protection (capping wrong-code guesses against one
598
+ // still-valid challenge token) is a separate mechanism the handler itself
599
+ // owns — see AuthRoutesConfig.mfaVerifyHandler's doc comment.
600
+ if (config.mfaVerifyHandler) {
601
+ const mfaVerifyQn = config.mfaVerifyHandler;
602
+ const statusMap = config.mfaVerifyErrorStatusMap ?? {};
603
+ const rateLimiter =
604
+ config.mfaVerifyRateLimit === null
605
+ ? null
606
+ : (config.mfaVerifyRateLimit ?? createInMemoryLoginRateLimiter());
607
+
608
+ api.post(Routes.authMfaVerify, async (c) => {
609
+ const raw = await c.req.json().catch(() => null);
610
+ const parsed = MfaVerifyBody.safeParse(raw);
611
+ if (!parsed.success) {
612
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
613
+ }
614
+ const body = parsed.data;
615
+
616
+ const clientIp =
617
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
618
+ c.req.header("x-real-ip") ??
619
+ "unknown";
620
+
621
+ if (rateLimiter) {
622
+ const allowed = await rateLimiter.check(clientIp);
623
+ if (!allowed) {
624
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
625
+ }
626
+ }
627
+
628
+ const result = await dispatcher.write(mfaVerifyQn, body, GUEST_USER);
629
+
630
+ if (!result.isSuccess) {
631
+ // @cast-boundary error-details — KumikoError.details shape is per-error
632
+ const reason =
633
+ (result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
634
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
635
+ const status = (statusMap[reason] ?? result.error.httpStatus) as
636
+ | 400
637
+ | 401
638
+ | 403
639
+ | 422
640
+ | 429
641
+ | 500;
642
+ return c.json({ isSuccess: false, error: result.error }, status);
643
+ }
644
+
645
+ // @cast-boundary engine-payload — generic dispatcher.write result for mfa-verify handler
646
+ const data = result.data as { kind: "mfa-verify-success"; session: SessionUser };
647
+
648
+ const token = await mintSessionAndRespond(c, data.session);
649
+
650
+ if (rateLimiter) {
651
+ await rateLimiter.reset(clientIp);
652
+ }
653
+
654
+ return c.json({
655
+ isSuccess: true,
656
+ token,
657
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
658
+ });
659
+ });
660
+ }
661
+
536
662
  // POST /auth/request-password-reset + /auth/reset-password — public.
537
663
  // Silent-success on request (no enumeration), typed failure on confirm.
538
664
  // Rate-limit covered via config.rateLimit.auth (Sprint G.5 L2, /auth/*).
package/src/api/server.ts CHANGED
@@ -3,6 +3,7 @@ import type { DbConnection, PgClient } from "../db/connection";
3
3
  import { createTenantDb } from "../db/tenant-db";
4
4
  import { EXT_FILE_PROVIDER } from "../engine/extension-names";
5
5
  import { runsInLane } from "../engine/run-in";
6
+ import { createAnonymousUser } from "../engine/system-user";
6
7
  import {
7
8
  type AppContext,
8
9
  isFileField,
@@ -670,12 +671,32 @@ export function buildServer(options: ServerOptions): KumikoServer {
670
671
  // /api/* immer Vorrang hat — feature-Routes liegen ohnehin außerhalb
671
672
  // (Boot-Validator blockt /api-Prefix). deps.app ist die Outer-App, sodass
672
673
  // der Handler /api/query intern via app.fetch(...) nutzen kann (gleicher
673
- // Auth-Pfad wie ein echter HTTP-Call).
674
+ // Auth-Pfad wie ein echter HTTP-Call). deps.systemQuery umgeht diesen
675
+ // Pfad bewusst — für Routes die einen FESTEN Tenant erzwingen müssen
676
+ // (z.B. legal-pages → immer SYSTEM_TENANT_ID), statt ihn per X-Tenant-
677
+ // Header vorzutäuschen (das sieht für die anonymousAccess-Auflösung
678
+ // wie ein normaler Client-Header aus und wird von resolverTrust:
679
+ // "authoritative"-Configs zurecht als Tenant-Override abgelehnt).
674
680
  for (const feature of options.registry.features.values()) {
675
681
  for (const route of Object.values(feature.httpRoutes)) {
676
682
  // @wrapper-known semantic-alias
677
683
  const honoHandler = async (c: import("hono").Context): Promise<Response> =>
678
- route.handler(c, { app });
684
+ route.handler(c, {
685
+ app,
686
+ systemQuery: (type, payload, tenantId) =>
687
+ // createAnonymousUser, NOT createSystemUser: httpRoute handlers
688
+ // using systemQuery are, by construction, `anonymous: true`
689
+ // public routes — the synthesized user must clear the SAME
690
+ // access gate a real anonymous visitor would, no more. The
691
+ // system role would ALSO satisfy that gate here, but it can
692
+ // read fields gated to "system" that "anonymous" can't
693
+ // (filterReadFields is a plain role-in-map check) — a future
694
+ // systemQuery caller reading a system-gated field would leak
695
+ // it into a public response. The forced tenant already comes
696
+ // from bypassing the HTTP layer entirely; no elevated role
697
+ // is needed or wanted on top of that.
698
+ dispatcher.query(type, payload, createAnonymousUser(tenantId)),
699
+ });
679
700
  switch (route.method) {
680
701
  case "GET":
681
702
  app.get(route.path, honoHandler);
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { ConfigScopes } from "../constants";
3
- import { buildManifestFromRegistry, createRegistry, defineFeature } from "../index";
3
+ import {
4
+ buildManifestFromRegistry,
5
+ createRegistry,
6
+ createSystemConfig,
7
+ defineFeature,
8
+ } from "../index";
4
9
 
5
10
  const boolKey = {
6
11
  type: "boolean",
@@ -104,4 +109,29 @@ describe("buildManifestFromRegistry — deterministic codepoint sort (#330)", ()
104
109
  });
105
110
  expect(noHints && "uiHints" in noHints).toBe(false);
106
111
  });
112
+
113
+ // #1039: backing:"secrets" routes the value through the envelope-encrypted
114
+ // secrets store — the manifest must report encrypted:true even without an
115
+ // explicit `encrypted` flag, so generated docs don't mislabel Stripe-style
116
+ // secret-backed config keys as plaintext. (createConfigKey never emits an
117
+ // explicit `encrypted: false` — falsy opts.encrypted just omits the field —
118
+ // so there's no "explicit false wins" case to pin here.)
119
+ test("encrypted flag — backing:secrets implies encrypted:true without an explicit flag", () => {
120
+ const feature = defineFeature("demo", (r) => {
121
+ r.config({
122
+ keys: {
123
+ "api-key": createSystemConfig("text", { backing: "secrets" }),
124
+ "plain-flag": createSystemConfig("boolean", {}),
125
+ },
126
+ });
127
+ });
128
+ const registry = createRegistry([feature]);
129
+
130
+ const manifest = buildManifestFromRegistry(registry, { source: "test" });
131
+ const demo = manifest.features.find((f) => f.name === "demo");
132
+ const byKey = (key: string) => demo?.configKeys.find((k) => k.key === key);
133
+
134
+ expect(byKey("api-key")?.encrypted).toBe(true);
135
+ expect(byKey("plain-flag")?.encrypted).toBe(false);
136
+ });
107
137
  });
@@ -106,7 +106,7 @@ export function buildManifestFromRegistry(
106
106
  type: def.type,
107
107
  scope: def.scope,
108
108
  default: def.default ?? null,
109
- encrypted: def.encrypted ?? false,
109
+ encrypted: def.encrypted ?? def.backing === "secrets",
110
110
  computed: def.computed !== undefined,
111
111
  options: def.options ?? null,
112
112
  bounds: def.bounds ?? null,
@@ -30,6 +30,24 @@ export type HttpRouteHandlerDeps = {
30
30
  * ansprechen (z.B. /api/query mit der vollen Auth-/Anonymous-Chain). */
31
31
  // biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
32
32
  readonly app: import("hono").Hono<any, any>;
33
+ /** Run a query handler in-process, forcing a SPECIFIC tenant — WITHOUT
34
+ * going through the public /api/query HTTP layer (no header parsing, no
35
+ * anonymousAccess tenant resolution). The synthesized caller carries
36
+ * anonymous-level access ONLY (same role a real anonymous request would
37
+ * have, no more) — the primitive forces the tenant, not the privilege
38
+ * level, so it stays safe to call from any `anonymous: true` route
39
+ * without risking a field-level disclosure a real anonymous caller
40
+ * couldn't already get. Use this whenever the route needs a tenant
41
+ * other than the one the request resolves to (e.g. always
42
+ * SYSTEM_TENANT_ID regardless of the visited host) — spoofing that via
43
+ * an internal X-Tenant header on `app.fetch(...)` is indistinguishable
44
+ * from an external client and gets rejected by resolverTrust:
45
+ * "authoritative" anonymousAccess configs (see auth-middleware.ts). */
46
+ readonly systemQuery: (
47
+ type: string,
48
+ payload: unknown,
49
+ tenantId: import("./identifiers").TenantId,
50
+ ) => Promise<unknown>;
33
51
  };
34
52
 
35
53
  export type HttpRouteHandler = (
@@ -17,9 +17,10 @@ import { z } from "zod";
17
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
18
  import { createRegistry, defineFeature } from "../../engine";
19
19
  import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
20
+ import { createNoopProvider, createPrometheusMeter } from "../../observability";
20
21
  import { createEventConsumerStateTable } from "../../pipeline";
21
22
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
22
- import { waitFor } from "../../testing";
23
+ import { sleep, waitFor } from "../../testing";
23
24
  import { createAllInOneEntrypoint } from "../index";
24
25
 
25
26
  const jobRuns: Array<{ name: string; payload: Record<string, unknown> }> = [];
@@ -172,3 +173,55 @@ describe("createAllInOneEntrypoint auto-wires jobRunner into command-dispatcher"
172
173
  }
173
174
  });
174
175
  });
176
+
177
+ // Regression test for #1046 — createJobRunner is built from the caller's
178
+ // RAW context, BEFORE buildServer ever merges observability.tracer/meter
179
+ // into a context object of its own. Without threading the resolved
180
+ // provider into the job-runner's context too, `context.meter` stayed
181
+ // undefined, the queue-depth poller's `if (context.meter)` guard skipped
182
+ // silently, and `/metrics` showed the kumiko_job_queue_depth HELP/TYPE
183
+ // header but never a single data line — forever, with no error anywhere.
184
+ describe("createAllInOneEntrypoint — kumiko_job_queue_depth sees the SAME meter as buildServer (#1046)", () => {
185
+ test("passing an explicit observability provider makes the queue-depth poller emit real data for BOTH lanes", async () => {
186
+ const registry = createRegistry([wiringFeature]);
187
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
188
+ const meter = createPrometheusMeter();
189
+ const observability = { ...createNoopProvider(), meter };
190
+ const entry = createAllInOneEntrypoint({
191
+ registry,
192
+ context: { db: testDb.db, redis: testRedis.redis },
193
+ jwtSecret: JWT,
194
+ redisUrl,
195
+ queueNamePrefix: `wiring-qd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
196
+ observability,
197
+ });
198
+ await entry.start();
199
+ try {
200
+ // If the job-runner never saw this meter (the #1046 bug), the poller's
201
+ // `if (context.meter)` guard skips entirely and no slots ever appear —
202
+ // this assertion is the one that would have caught it.
203
+ const snapshot = meter.snapshot().get("kumiko_job_queue_depth");
204
+ expect(snapshot).toBeDefined();
205
+ const workerWaiting = snapshot?.slots.find(
206
+ (s) => s.labels?.["lane"] === "worker" && s.labels?.["state"] === "waiting",
207
+ );
208
+ const apiWaiting = snapshot?.slots.find(
209
+ (s) => s.labels?.["lane"] === "api" && s.labels?.["state"] === "waiting",
210
+ );
211
+ expect(workerWaiting).toBeDefined();
212
+ expect(apiWaiting).toBeDefined();
213
+ // Same instance, not a coincidentally-equal one — proves buildServer's
214
+ // internal registerStandardMetrics() and the job-runner's poller wrote
215
+ // into the identical meter, not two disconnected Noop instances.
216
+ expect(entry.observability.meter).toBe(meter);
217
+ // BullMQ's fresh Worker connections are still settling right after
218
+ // start() returns — stopping immediately races their own connection
219
+ // teardown and throws an unrelated "Connection is closed" from
220
+ // ioredis (same artifact documented in job-queue-depth.integration.
221
+ // test.ts). Production runners live far longer than this.
222
+ await sleep(50);
223
+ } finally {
224
+ await entry.stop();
225
+ }
226
+ });
227
+ });
@@ -43,6 +43,7 @@ import { createJobRunner } from "../jobs/job-runner";
43
43
  import type { Lifecycle } from "../lifecycle";
44
44
  import { createLifecycle } from "../lifecycle";
45
45
  import type { ObservabilityOptions, ObservabilityProvider } from "../observability";
46
+ import { createNoopProvider } from "../observability";
46
47
  import type { EventDedup, EventDispatcher } from "../pipeline";
47
48
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
48
49
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
@@ -175,6 +176,40 @@ function mergeDispatcherOptions(
175
176
  return { ...(caller ?? {}), jobRunner };
176
177
  }
177
178
 
179
+ // Resolve the observability provider ONCE per entrypoint boot, before any
180
+ // job-runner is built. `buildServer` (api/server.ts) independently falls
181
+ // back to `options.observability ?? createNoopProvider()` and merges
182
+ // tracer/meter into a NEW context object it builds internally — but the
183
+ // job-runner is constructed from the caller's RAW `options.context`
184
+ // *before* buildServer ever runs, so it never saw that merge. Its
185
+ // queue-depth poller only starts `if (context.meter)` with no fallback
186
+ // (unlike the tracer, which has its own `getFallbackTracer()`), so an
187
+ // unset meter silently skipped the poller forever — `/metrics` showed the
188
+ // `kumiko_job_queue_depth` HELP/TYPE header but never a single data line
189
+ // (#1046). Resolving once here and threading it into the job-runner's
190
+ // context fixes the gap. This resolved instance is ONLY used for the
191
+ // job-runner — `buildApiServer`/`buildWorkerServer` still pass the
192
+ // caller's original `options.observability` through unchanged (not this
193
+ // resolved value), because `buildServer` also derives `shouldWrapRedis`
194
+ // from whether `options.observability` was explicitly set — forcing it to
195
+ // an always-defined value here would silently switch every app's Redis
196
+ // client onto the tracer-wrapping Proxy path even with no provider
197
+ // configured. buildServer's own independent Noop fallback is harmless in
198
+ // that case: nothing scrapes `/metrics` without a real provider, so a
199
+ // second, disconnected Noop meter costs nothing.
200
+ function resolveObservability(
201
+ observability: ObservabilityProvider | undefined,
202
+ ): ObservabilityProvider {
203
+ return observability ?? createNoopProvider();
204
+ }
205
+
206
+ function contextWithObservability(
207
+ context: AppContext,
208
+ observability: ObservabilityProvider,
209
+ ): AppContext {
210
+ return { ...context, tracer: observability.tracer, meter: observability.meter };
211
+ }
212
+
178
213
  // buildApiServer shapes ServerOptions from API-mode caller-options.
179
214
  // AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
180
215
  // subtyping makes the all-in-one path a valid caller without an explicit
@@ -291,6 +326,7 @@ function requireDispatcher(server: KumikoServer, mode: string): EventDispatcher
291
326
 
292
327
  export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint {
293
328
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
329
+ const observability = resolveObservability(options.observability);
294
330
 
295
331
  // Boot-validation (Welle 2.6.c) — fail loud before traffic arrives:
296
332
  // (a) Any jobs declared + no jobs-block → command-dispatcher would
@@ -324,7 +360,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
324
360
  const apiJobRunner = options.jobs
325
361
  ? buildJobRunnerWithHook(
326
362
  options.registry,
327
- options.context,
363
+ contextWithObservability(options.context, observability),
328
364
  options.jobs,
329
365
  options.jobs.runLocalJobs ? "api" : undefined,
330
366
  lifecycle,
@@ -375,9 +411,10 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
375
411
 
376
412
  export function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint {
377
413
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
414
+ const observability = resolveObservability(options.observability);
378
415
  const jobRunner = buildJobRunnerWithHook(
379
416
  options.registry,
380
- options.context,
417
+ contextWithObservability(options.context, observability),
381
418
  options,
382
419
  "worker",
383
420
  lifecycle,
@@ -406,6 +443,8 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
406
443
 
407
444
  export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint {
408
445
  const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
446
+ const observability = resolveObservability(options.observability);
447
+ const jobRunnerContext = contextWithObservability(options.context, observability);
409
448
 
410
449
  // All-in-one consumes BOTH lanes: two runners, each with a BullMQ worker
411
450
  // for its own lane's queue. Both runners hold queue-clients for both
@@ -416,7 +455,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
416
455
  // its own lane in its own .start().
417
456
  const workerJobRunner = buildJobRunnerWithHook(
418
457
  options.registry,
419
- options.context,
458
+ jobRunnerContext,
420
459
  options,
421
460
  "worker",
422
461
  lifecycle,
@@ -424,7 +463,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
424
463
  );
425
464
  const apiJobRunner = buildJobRunnerWithHook(
426
465
  options.registry,
427
- options.context,
466
+ jobRunnerContext,
428
467
  options,
429
468
  "api",
430
469
  lifecycle,