@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 +2 -2
- package/src/__tests__/anonymous-access.integration.test.ts +160 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +201 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-middleware.ts +117 -25
- package/src/api/auth-routes.ts +128 -2
- package/src/api/server.ts +23 -2
- package/src/engine/__tests__/feature-manifest.test.ts +31 -1
- package/src/engine/feature-manifest.ts +1 -1
- package/src/engine/types/http-route.ts +18 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +54 -1
- package/src/entrypoint/index.ts +43 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.147.
|
|
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.
|
|
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
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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",
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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
|
|
515
|
-
|
|
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, {
|
|
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 {
|
|
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 ??
|
|
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
|
+
});
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
466
|
+
jobRunnerContext,
|
|
428
467
|
options,
|
|
429
468
|
"api",
|
|
430
469
|
lifecycle,
|