@cosmicdrift/kumiko-framework 0.200.1 → 0.202.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  4. package/src/api/__tests__/api.test.ts +116 -1
  5. package/src/api/__tests__/batch.integration.test.ts +53 -0
  6. package/src/api/__tests__/body-limit.test.ts +90 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  8. package/src/api/api-constants.ts +44 -7
  9. package/src/api/auth-middleware.ts +19 -3
  10. package/src/api/index.ts +1 -0
  11. package/src/api/route-registrars.ts +19 -21
  12. package/src/api/routes.ts +47 -1
  13. package/src/api/server.ts +1 -1
  14. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  15. package/src/db/tenant-db.ts +46 -2
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/build-app-schema.test.ts +25 -0
  18. package/src/engine/boot-validator/detail-screens.ts +35 -0
  19. package/src/engine/boot-validator/index.ts +2 -0
  20. package/src/engine/entity-handlers.ts +8 -1
  21. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  23. package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  25. package/src/engine/feature-ast/extractors/events.ts +5 -3
  26. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  27. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  28. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  29. package/src/engine/feature-ast/patch.ts +48 -21
  30. package/src/engine/feature-ast/patterns.ts +18 -0
  31. package/src/engine/feature-ast/render.ts +19 -6
  32. package/src/engine/index.ts +1 -0
  33. package/src/files/__tests__/files.integration.test.ts +97 -1
  34. package/src/files/file-routes.ts +10 -2
  35. package/src/files/types.ts +72 -0
  36. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  37. package/src/http/__tests__/egress.test.ts +440 -0
  38. package/src/http/__tests__/policy.test.ts +125 -0
  39. package/src/http/egress.ts +158 -0
  40. package/src/http/index.ts +2 -0
  41. package/src/http/policy.ts +193 -0
  42. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  43. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  44. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  45. package/src/pipeline/dispatch-batch.ts +11 -5
  46. package/src/pipeline/dispatch-shared.ts +42 -16
  47. package/src/pipeline/idempotency.ts +91 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.200.1",
3
+ "version": "0.202.0",
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>",
@@ -87,6 +87,10 @@
87
87
  "types": "./src/i18n/index.ts",
88
88
  "default": "./src/i18n/index.ts"
89
89
  },
90
+ "./http": {
91
+ "types": "./src/http/index.ts",
92
+ "default": "./src/http/index.ts"
93
+ },
90
94
  "./auth": {
91
95
  "types": "./src/auth/index.ts",
92
96
  "default": "./src/auth/index.ts"
@@ -186,7 +190,7 @@
186
190
  "./package.json": "./package.json"
187
191
  },
188
192
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.200.1",
193
+ "@cosmicdrift/kumiko-types": "0.202.0",
190
194
  "bullmq": "^5.76.7",
191
195
  "bun-types": "^1.3.13",
192
196
  "hono": "^4.13.1",
@@ -202,7 +206,7 @@
202
206
  "zod": "^4.4.3"
203
207
  },
204
208
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.200.1",
209
+ "@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
206
210
  "bun-types": "^1.3.13",
207
211
  "pino-pretty": "^13.1.3"
208
212
  },
@@ -0,0 +1,84 @@
1
+ // Security regression (2026-08-13 audit, Finding 3): entityListSchema's
2
+ // `limit`/`offset` used to accept fractional/oversized numbers, which flowed
3
+ // unvalidated into a raw `LIMIT ${limit} OFFSET ${offset}` SQL string
4
+ // (event-store-executor-read.ts) — a non-integer literal 500s, and an
5
+ // unbounded limit forces a full-table materialisation.
6
+ //
7
+ // Bun.SQL-only setup via setupTestStack.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import {
11
+ createEntity,
12
+ createTextField,
13
+ defineEntityCreateHandler,
14
+ defineEntityListHandler,
15
+ defineFeature,
16
+ MAX_LIST_LIMIT,
17
+ } from "../engine";
18
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../stack";
19
+
20
+ const widgetEntity = createEntity({
21
+ table: "limit_widgets",
22
+ fields: { name: createTextField({ required: true }) },
23
+ });
24
+
25
+ const widgetFeature = defineFeature("limitwidgets", (r) => {
26
+ r.entity("widget", widgetEntity);
27
+ r.writeHandler(
28
+ defineEntityCreateHandler("widget", widgetEntity, { access: { roles: ["Admin"] } }),
29
+ );
30
+ r.queryHandler(defineEntityListHandler("widget", widgetEntity, { access: { roles: ["Admin"] } }));
31
+ });
32
+
33
+ describe("entity list limit/offset validation", () => {
34
+ let stack: TestStack;
35
+
36
+ beforeAll(async () => {
37
+ stack = await setupTestStack({ features: [widgetFeature] });
38
+ await unsafeCreateEntityTable(stack.db, widgetEntity);
39
+ });
40
+
41
+ afterAll(() => stack.cleanup());
42
+
43
+ test("fractional limit is rejected with 400, not a 500 from the raw SQL LIMIT", async () => {
44
+ const res = await stack.http.query(
45
+ "limitwidgets:query:widget:list",
46
+ { limit: 99999.5 },
47
+ TestUsers.admin,
48
+ );
49
+ expect(res.status).toBe(400);
50
+ const body = (await res.json()) as { error: { code: string } };
51
+ expect(body.error.code).toBe("validation_error");
52
+ });
53
+
54
+ test("negative offset is rejected with 400, not a 500 from the raw SQL OFFSET", async () => {
55
+ const res = await stack.http.query(
56
+ "limitwidgets:query:widget:list",
57
+ { offset: -1 },
58
+ TestUsers.admin,
59
+ );
60
+ expect(res.status).toBe(400);
61
+ const body = (await res.json()) as { error: { code: string } };
62
+ expect(body.error.code).toBe("validation_error");
63
+ });
64
+
65
+ test("limit above MAX_LIST_LIMIT is rejected with 400", async () => {
66
+ const res = await stack.http.query(
67
+ "limitwidgets:query:widget:list",
68
+ { limit: MAX_LIST_LIMIT + 1 },
69
+ TestUsers.admin,
70
+ );
71
+ expect(res.status).toBe(400);
72
+ const body = (await res.json()) as { error: { code: string } };
73
+ expect(body.error.code).toBe("validation_error");
74
+ });
75
+
76
+ test("limit at MAX_LIST_LIMIT and a valid offset succeed", async () => {
77
+ const res = await stack.http.query(
78
+ "limitwidgets:query:widget:list",
79
+ { limit: MAX_LIST_LIMIT, offset: 0 },
80
+ TestUsers.admin,
81
+ );
82
+ expect(res.status).toBe(200);
83
+ });
84
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes } from "../api-constants";
3
+
4
+ // PUBLIC_API_PATHS is an allowlist: a missing entry (typo, forgotten route)
5
+ // fails CLOSED — the route stays behind auth, never accidentally public.
6
+ // But the classification itself must be total, or a route silently falls
7
+ // into a third, unchecked state that nobody notices until it's either
8
+ // exploited (should've been non-public) or reported broken by a client
9
+ // (should've been public). This test forces every `Routes` entry into
10
+ // exactly one of the two sets, in both directions:
11
+ // - every Routes entry has a classification (no silent gap)
12
+ // - every classified path still corresponds to a real Routes entry (no
13
+ // stale/typo'd literal drifting out of sync with Routes)
14
+ function classifyRoutes(routes: Record<string, string>): {
15
+ unclassified: string[];
16
+ classifiedInBoth: string[];
17
+ } {
18
+ const unclassified: string[] = [];
19
+ const classifiedInBoth: string[] = [];
20
+
21
+ for (const routePath of Object.values(routes)) {
22
+ const apiPath = `/api${routePath}`;
23
+ const isPublic = PUBLIC_API_PATHS.has(apiPath);
24
+ const isNonPublic = NON_PUBLIC_API_PATHS.has(apiPath);
25
+ if (isPublic && isNonPublic) classifiedInBoth.push(apiPath);
26
+ else if (!isPublic && !isNonPublic) unclassified.push(apiPath);
27
+ }
28
+
29
+ return { unclassified, classifiedInBoth };
30
+ }
31
+
32
+ describe("Routes / PUBLIC_API_PATHS classification completeness", () => {
33
+ test("every Routes entry is classified as exactly public XOR non-public", () => {
34
+ const { unclassified, classifiedInBoth } = classifyRoutes(Routes);
35
+
36
+ expect(unclassified).toEqual([]);
37
+ expect(classifiedInBoth).toEqual([]);
38
+ });
39
+
40
+ test("every PUBLIC_API_PATHS / NON_PUBLIC_API_PATHS entry maps back to a real Routes value", () => {
41
+ const knownApiPaths = new Set(Object.values(Routes).map((routePath) => `/api${routePath}`));
42
+
43
+ const stalePublic = [...PUBLIC_API_PATHS].filter((path) => !knownApiPaths.has(path));
44
+ const staleNonPublic = [...NON_PUBLIC_API_PATHS].filter((path) => !knownApiPaths.has(path));
45
+
46
+ expect(stalePublic).toEqual([]);
47
+ expect(staleNonPublic).toEqual([]);
48
+ });
49
+
50
+ // Regression guard for the mechanism itself: proves the completeness
51
+ // check actually fails when a route is added without a classification,
52
+ // rather than the two tests above being vacuously true by construction.
53
+ test("regression: an unclassified route is detected", () => {
54
+ const routesWithGap = {
55
+ ...Routes,
56
+ newFeature: "/new-feature-without-classification",
57
+ };
58
+
59
+ const { unclassified } = classifyRoutes(routesWithGap);
60
+
61
+ expect(unclassified).toEqual(["/api/new-feature-without-classification"]);
62
+ });
63
+ });
@@ -12,7 +12,7 @@ import {
12
12
  import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
13
13
  import { createTestUser, TestUsers } from "../../stack";
14
14
  import { waitFor } from "../../testing";
15
- import { createApiRoutes, pumpStream, StreamFrame } from "../routes";
15
+ import { createApiRoutes, MAX_PAYLOAD_DEPTH, pumpStream, StreamFrame } from "../routes";
16
16
  import { buildServer } from "../server";
17
17
 
18
18
  const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
@@ -236,6 +236,121 @@ describe("POST /api/command", () => {
236
236
  });
237
237
  });
238
238
 
239
+ // --- Payload depth cap (security, 2026-08-13 audit Finding 1) ---
240
+ //
241
+ // A byte-limit on the body doesn't stop an attacker from nesting deeply
242
+ // enough to blow the stack during recursive Zod validation — a minified
243
+ // payload with thousands of nested wrappers stays well under 1MB. The
244
+ // depth-cap in routes.ts walks the parsed object BEFORE it reaches any
245
+ // handler's Zod schema, so it applies independently of what that schema
246
+ // actually expects.
247
+
248
+ function nestedPayload(depth: number): unknown {
249
+ let value: unknown = "leaf";
250
+ for (let i = 0; i < depth; i++) value = { nested: value };
251
+ return value;
252
+ }
253
+
254
+ describe("payload depth cap", () => {
255
+ test("rejects /api/write payload nested past MAX_PAYLOAD_DEPTH with 400 payload_too_deep", async () => {
256
+ const headers = await authHeader(adminUser);
257
+ const res = await req(
258
+ "POST",
259
+ "/api/write",
260
+ { type: "test:write:item:create", payload: nestedPayload(MAX_PAYLOAD_DEPTH + 1) },
261
+ headers,
262
+ );
263
+
264
+ expect(res.status).toBe(400);
265
+ const body = await res.json();
266
+ expect(body.error.code).toBe("validation_error");
267
+ expect(body.error.details.fields[0]).toMatchObject({
268
+ path: "payload",
269
+ code: "too_deep",
270
+ i18nKey: "errors.validation.payload_too_deep",
271
+ });
272
+ });
273
+
274
+ test("a payload nested exactly at MAX_PAYLOAD_DEPTH clears the cap (fails ordinary schema validation instead)", async () => {
275
+ const headers = await authHeader(adminUser);
276
+ const res = await req(
277
+ "POST",
278
+ "/api/write",
279
+ { type: "test:write:item:create", payload: nestedPayload(MAX_PAYLOAD_DEPTH) },
280
+ headers,
281
+ );
282
+
283
+ // Still 400 — "name" is missing from this payload — but NOT via the
284
+ // depth cap. Proves the cap's boundary is exactly MAX_PAYLOAD_DEPTH.
285
+ expect(res.status).toBe(400);
286
+ const body = await res.json();
287
+ expect(body.error.details.fields.some((f: { code: string }) => f.code === "too_deep")).toBe(
288
+ false,
289
+ );
290
+ });
291
+
292
+ test("rejects /api/query payload nested past MAX_PAYLOAD_DEPTH with 400", async () => {
293
+ const headers = await authHeader(adminUser);
294
+ const res = await req(
295
+ "POST",
296
+ "/api/query",
297
+ { type: "test:query:item:list", payload: nestedPayload(MAX_PAYLOAD_DEPTH + 1) },
298
+ headers,
299
+ );
300
+
301
+ expect(res.status).toBe(400);
302
+ const body = await res.json();
303
+ expect(body.error.details.fields[0]).toMatchObject({ code: "too_deep" });
304
+ });
305
+
306
+ test("rejects /api/command payload nested past MAX_PAYLOAD_DEPTH with 400", async () => {
307
+ const headers = await authHeader(adminUser);
308
+ const res = await req(
309
+ "POST",
310
+ "/api/command",
311
+ { type: "test:write:item:create", payload: nestedPayload(MAX_PAYLOAD_DEPTH + 1) },
312
+ headers,
313
+ );
314
+
315
+ expect(res.status).toBe(400);
316
+ const body = await res.json();
317
+ expect(body.error.details.fields[0]).toMatchObject({ code: "too_deep" });
318
+ });
319
+
320
+ test("rejects an /api/batch command payload nested past MAX_PAYLOAD_DEPTH with 400", async () => {
321
+ const headers = await authHeader(adminUser);
322
+ const res = await req(
323
+ "POST",
324
+ "/api/batch",
325
+ {
326
+ commands: [
327
+ { type: "test:write:item:create", payload: nestedPayload(MAX_PAYLOAD_DEPTH + 1) },
328
+ ],
329
+ },
330
+ headers,
331
+ );
332
+
333
+ expect(res.status).toBe(400);
334
+ const body = await res.json();
335
+ expect(body.error.details.fields[0]).toMatchObject({ code: "too_deep" });
336
+ });
337
+
338
+ test("rejects /api/stream payload nested past MAX_PAYLOAD_DEPTH with 400, not an SSE error frame", async () => {
339
+ const headers = await authHeader(adminUser);
340
+ const res = await req(
341
+ "POST",
342
+ "/api/stream",
343
+ { type: "test:stream:item:tail", payload: nestedPayload(MAX_PAYLOAD_DEPTH + 1) },
344
+ headers,
345
+ );
346
+
347
+ expect(res.status).toBe(400);
348
+ expect(res.headers.get("content-type")).not.toContain("text/event-stream");
349
+ const body = await res.json();
350
+ expect(body.error.details.fields[0]).toMatchObject({ code: "too_deep" });
351
+ });
352
+ });
353
+
239
354
  // --- pumpStream (SSE pull loop) ---
240
355
 
241
356
  function fakeSseWriter() {
@@ -50,6 +50,9 @@ const afterCommitHookLog: Array<{ id: EntityId; name: string }> = [];
50
50
  let afterCommitShouldThrow = false;
51
51
  const afterCommitThirdHookRan: string[] = [];
52
52
 
53
+ // Delay injected into item:create-slow — reset per test.
54
+ let slowHandlerDelayMs = 0;
55
+
53
56
  const itemFeature = defineFeature("batch", (r) => {
54
57
  const item = r.entity("item", itemEntity);
55
58
 
@@ -81,6 +84,21 @@ const itemFeature = defineFeature("batch", (r) => {
81
84
  { access: { roles: ["Admin"] } },
82
85
  );
83
86
 
87
+ // Handler with a controllable delay — used to simulate a slow in-flight
88
+ // handler for the parallel-idempotency race test.
89
+ r.writeHandler(
90
+ "item:create-slow",
91
+ z.object({ name: z.string().min(1) }),
92
+ async (event, ctx) => {
93
+ if (slowHandlerDelayMs > 0) {
94
+ await new Promise((resolve) => setTimeout(resolve, slowHandlerDelayMs));
95
+ }
96
+ const crud = createEventStoreExecutor(itemTable, itemEntity, { entityName: "item" });
97
+ return crud.create(event.payload, event.user, ctx.db);
98
+ },
99
+ { access: { roles: ["Admin"] } },
100
+ );
101
+
84
102
  // Entity hook: inTransaction — records in memory
85
103
  r.hook(
86
104
  "postSave",
@@ -405,6 +423,41 @@ describe("POST /api/batch", () => {
405
423
  expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
406
424
  });
407
425
 
426
+ test("idempotency: parallel writes with the same requestId — second waits for the first instead of re-executing", async () => {
427
+ slowHandlerDelayMs = 250;
428
+ const requestId = "batch-rid-parallel-slow";
429
+ const commands = [{ type: "batch:write:item:create-slow", payload: { name: "parallel-once" } }];
430
+
431
+ try {
432
+ const [first, second] = await Promise.all([
433
+ stack.http.batch(commands, admin, requestId),
434
+ (async () => {
435
+ // Give request #1 a head start so it wins the lock acquisition —
436
+ // the race being tested is "#2 waits", not "who acquires first".
437
+ await new Promise((resolve) => setTimeout(resolve, 50));
438
+ return stack.http.batch(commands, admin, requestId);
439
+ })(),
440
+ ]);
441
+
442
+ const firstBody = await first.json();
443
+ const secondBody = await second.json();
444
+
445
+ expect(firstBody.isSuccess).toBe(true);
446
+ expect(secondBody.isSuccess).toBe(true);
447
+ // Same cached response, not a fresh execution.
448
+ expect(secondBody.results).toEqual(firstBody.results);
449
+
450
+ // Exactly one row and one hook run — the second call did not re-run
451
+ // the handler and create a duplicate side effect.
452
+ const rows = await selectMany(stack.db, itemTable);
453
+ expect(rows).toHaveLength(1);
454
+ expect(inTxHookLog).toHaveLength(1);
455
+ expect(afterCommitHookLog).toHaveLength(1);
456
+ } finally {
457
+ slowHandlerDelayMs = 0;
458
+ }
459
+ });
460
+
408
461
  test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
409
462
  const requestId = "batch-rid-corrupt";
410
463
  const cacheKey = `${RedisKeys.idempotency}${admin.tenantId}:${admin.id}:${requestId}`;
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
+ import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "../api-constants";
4
5
  import { buildServer } from "../server";
5
6
 
6
7
  const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
@@ -85,4 +86,93 @@ describe("request body limit", () => {
85
86
  const res = await postJson(app, "/api/write", 50_000);
86
87
  expect(res.status).toBe(401); // passes body-limit, reaches auth
87
88
  });
89
+
90
+ // Security regression (2026-08-13 audit, Finding 2): /api/stream used to be
91
+ // missing from the old BODY_LIMIT_PATHS allowlist, so it dispatched to the
92
+ // dispatcher without ever hitting the same 1MB cap /api/write enforces.
93
+ // Mirrors the /api/write cases above. Now covered structurally (see
94
+ // "default coverage sweep" below), kept as its own test for the historical
95
+ // regression it documents.
96
+ test("rejects POST /api/stream with body larger than maxRequestBytes with 413 (mirrors /api/write)", async () => {
97
+ const app = buildApp(1024);
98
+ const res = await postJson(app, "/api/stream", 2048);
99
+ expect(res.status).toBe(413);
100
+ });
101
+
102
+ test("accepts POST /api/stream with body within the limit (reaches auth layer, not 413)", async () => {
103
+ const app = buildApp(10_000);
104
+ const res = await postJson(app, "/api/stream", 100);
105
+ expect(res.status).toBe(401); // no JWT → 401, but size is fine
106
+ });
107
+ });
108
+
109
+ // fw#2145: BODY_LIMIT_PATHS inverted from an opt-in allowlist to an opt-out
110
+ // list — registerBodyLimit now mounts on /api/* by construction, and only
111
+ // BODY_LIMIT_OPT_OUT_PATHS (api-constants.ts) escapes it. These tests prove
112
+ // that inversion rather than re-testing individual paths.
113
+ describe("body-limit opt-out completeness", () => {
114
+ test("every opt-out entry resolves to a real Routes constant (no stale/typo'd paths)", () => {
115
+ for (const optOutPath of BODY_LIMIT_OPT_OUT_PATHS) {
116
+ const matchesKnownRoute = Object.values(Routes).some(
117
+ (route) => `/api${route}` === optOutPath,
118
+ );
119
+ expect(matchesKnownRoute).toBe(true);
120
+ }
121
+ });
122
+
123
+ test("the opt-out list is pinned to its reviewed members — a new exception must touch this test", () => {
124
+ expect([...BODY_LIMIT_OPT_OUT_PATHS]).toEqual([`/api${Routes.files}`]);
125
+ });
126
+ });
127
+
128
+ describe("default coverage sweep — proves the default, not a hand-maintained list", () => {
129
+ const OVERSIZED_BYTES = 2_000_000; // exceeds the default 1 MiB cap
130
+
131
+ // Derived from Routes itself (minus opt-out) rather than a hand-picked
132
+ // list — a route added to Routes tomorrow lands in this sweep
133
+ // automatically, with no test file to remember to update. Includes routes
134
+ // this test app never mounts (e.g. authLogin, no `auth` option passed to
135
+ // buildServer) and routes whose GET handler is mounted outside /api/*
136
+ // (health, healthReady, version — registerHealthRoutes/registerVersionRoute
137
+ // mount at the bare path, not under /api). Both still 413 on an oversized
138
+ // POST here: the /api/* body-limit middleware matches on path prefix
139
+ // before Hono resolves a handler, so it runs whether or not a route
140
+ // answers underneath. Verified directly: `POST /api/health` 413s even
141
+ // though the only real handler lives at `GET /health`.
142
+ const defaultLimitedRoutes = Object.values(Routes).filter(
143
+ (route) => !BODY_LIMIT_OPT_OUT_PATHS.has(`/api${route}`),
144
+ );
145
+
146
+ for (const route of defaultLimitedRoutes) {
147
+ test(`POST /api${route} 413s on an oversized body without needing its own list entry`, async () => {
148
+ const app = buildApp();
149
+ const res = await postJson(app, `/api${route}`, OVERSIZED_BYTES);
150
+ expect(res.status).toBe(413);
151
+ });
152
+ }
153
+
154
+ test("POST /api/files does not 413 on an oversized JSON body (explicit opt-out)", async () => {
155
+ const app = buildApp();
156
+ const res = await postJson(app, "/api/files", OVERSIZED_BYTES);
157
+ expect(res.status).not.toBe(413);
158
+ });
159
+
160
+ // Regression for the DoD: "neue Route ohne Eintrag in irgendeiner Liste
161
+ // bekommt automatisch ein Limit". Mounts a route the same way an app-owner's
162
+ // `extraRoutes` callback would — after buildServer, with zero Routes/
163
+ // opt-out entries — and proves it inherits the cap AND still serves a
164
+ // small body correctly (not an accidental always-413).
165
+ test("a route mounted after buildServer with no list entry anywhere still inherits the default limit", async () => {
166
+ const app = buildApp();
167
+ app.post("/api/totally-new-route-nobody-listed", async (c) => c.json({ ok: true }));
168
+
169
+ const oversized = await postJson(app, "/api/totally-new-route-nobody-listed", OVERSIZED_BYTES);
170
+ expect(oversized.status).toBe(413);
171
+
172
+ // Small body passes the size cap and reaches the auth guard (401, no
173
+ // JWT) — proves the 413 above is the size cap doing its job, not the
174
+ // route being unreachable for some unrelated reason.
175
+ const small = await postJson(app, "/api/totally-new-route-nobody-listed", 10);
176
+ expect(small.status).toBe(401);
177
+ });
88
178
  });
@@ -21,7 +21,7 @@ describe("buildServer — jwtTtl default depends on sessionChecker wiring", () =
21
21
  expect(jwt.ttlSeconds).toBe(60 * 60);
22
22
  });
23
23
 
24
- test("auth with sessionChecker → session-backed default (24h)", () => {
24
+ test("auth with sessionChecker → session-backed default (8h)", () => {
25
25
  const { jwt } = buildServer({
26
26
  registry,
27
27
  context: {},
@@ -31,7 +31,7 @@ describe("buildServer — jwtTtl default depends on sessionChecker wiring", () =
31
31
  sessionChecker: async () => "live",
32
32
  },
33
33
  });
34
- expect(jwt.ttlSeconds).toBe(24 * 60 * 60);
34
+ expect(jwt.ttlSeconds).toBe(8 * 60 * 60);
35
35
  });
36
36
 
37
37
  test("explicit jwtTtl wins regardless of sessionChecker wiring", () => {
@@ -27,10 +27,10 @@ export const Routes = {
27
27
  authConfirmAccountUnlock: "/auth/confirm-account-unlock",
28
28
  authSignupRequest: "/auth/signup-request",
29
29
  authSignupConfirm: "/auth/signup-confirm",
30
- // Tenant-Invite (Magic-Link): 3 separate accept-Endpoints für klare
31
- // Branch-Separation. Plus invite-info als public-readable details
32
- // damit das Frontend "Du wirst eingeladen zu Tenant X als Role Y"
33
- // anzeigen kann bevor der User submitted.
30
+ // Tenant invite (magic link): 3 separate accept endpoints for clear
31
+ // branch separation. Plus invite-info as public-readable details so
32
+ // the frontend can show "You're invited to tenant X as role Y" before
33
+ // the user submits.
34
34
  authInviteAccept: "/auth/invite-accept",
35
35
  authInviteAcceptWithLogin: "/auth/invite-accept-with-login",
36
36
  authInviteSignupComplete: "/auth/invite-signup-complete",
@@ -53,9 +53,9 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
53
53
  `/api${Routes.authConfirmAccountUnlock}`,
54
54
  `/api${Routes.authSignupRequest}`,
55
55
  `/api${Routes.authSignupConfirm}`,
56
- // invite-accept braucht JWT (logged-in User, Branch 1) — NICHT public.
57
- // invite-accept-with-login (Branch 2) und invite-signup-complete
58
- // (Branch 3) sind anonymous, brauchen public-skip.
56
+ // invite-accept requires a JWT (logged-in user, branch 1) — NOT public.
57
+ // invite-accept-with-login (branch 2) and invite-signup-complete
58
+ // (branch 3) are anonymous and need the public skip.
59
59
  `/api${Routes.authInviteAcceptWithLogin}`,
60
60
  `/api${Routes.authInviteSignupComplete}`,
61
61
  `/api${Routes.authInviteInfo}`,
@@ -64,6 +64,43 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
64
64
  `/api${Routes.version}`,
65
65
  ]);
66
66
 
67
+ // Every other route in `Routes` — explicit, so a route can never fall
68
+ // through to "public" by simply being absent from PUBLIC_API_PATHS. A
69
+ // completeness test checks every `Routes` entry against the union of this
70
+ // set and PUBLIC_API_PATHS, so a new route with neither entry fails CI
71
+ // instead of shipping open.
72
+ export const NON_PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
73
+ `/api${Routes.write}`,
74
+ `/api${Routes.batch}`,
75
+ `/api${Routes.query}`,
76
+ `/api${Routes.command}`,
77
+ `/api${Routes.sse}`,
78
+ `/api${Routes.stream}`,
79
+ // Namespace prefix used only for body-limit registration
80
+ // (`/api/auth/*` in route-registrars.ts) — never dispatched as its own
81
+ // route, so it carries no auth bypass either way. Classified non-public
82
+ // to keep the completeness check total.
83
+ `/api${Routes.auth}`,
84
+ `/api${Routes.authLogout}`,
85
+ `/api${Routes.authTenants}`,
86
+ `/api${Routes.authSwitchTenant}`,
87
+ // invite-accept requires a JWT (Branch 1, see PUBLIC_API_PATHS above).
88
+ `/api${Routes.authInviteAccept}`,
89
+ `/api${Routes.files}`,
90
+ ]);
91
+
92
+ // Opt-out from the default request-body-size cap (registerBodyLimit applies
93
+ // it to all of /api/* by construction — a new route needs no entry here to
94
+ // be covered). Only routes with their own, deliberately different size
95
+ // contract belong on this list; forgetting an entry is safe (over-limited,
96
+ // not unlimited), so keep it as short as the actual exceptions.
97
+ export const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string> = new Set([
98
+ // Multipart uploads validate size against `maxUploadSize`/field `maxSize`
99
+ // (often >1 MiB) after Hono's multipart parse, not before — the generic
100
+ // JSON cap would reject legitimate uploads before that check ever runs.
101
+ `/api${Routes.files}`,
102
+ ]);
103
+
67
104
  // Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
68
105
  // CORS + SameSite-cookie semantics and skip the CSRF / Origin guards entirely.
69
106
  export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
@@ -35,6 +35,17 @@ export type AuthTransport = "cookie" | "bearer";
35
35
  // can't keep a locked account authenticated.
36
36
  export type AuthSessionStatus = "live" | "revoked" | "expired" | "missing" | "blocked";
37
37
 
38
+ // A "live" result may additionally carry roles re-derived fresh from the DB
39
+ // (global user roles + tenant-membership roles, composed via
40
+ // buildSessionRoles) instead of trusting the JWT's roles claim, which was
41
+ // frozen at login/mint time. Bare "live" (no roles) is the fail-open path —
42
+ // a DB throw during role derivation must not turn into a lockout — and the
43
+ // case where no sessionChecker is wired at all; the middleware falls back to
44
+ // payload.roles in both.
45
+ export type AuthSessionCheckResult =
46
+ | AuthSessionStatus
47
+ | { readonly status: "live"; readonly roles: readonly string[] };
48
+
38
49
  // Called by the middleware after JWT-verify. Gets the sid AND the expected
39
50
  // userId from the JWT's `sub` — the checker MUST confirm the session row
40
51
  // both exists + is live AND belongs to expectedUserId. Without the userId
@@ -45,7 +56,7 @@ export type AuthSessionStatus = "live" | "revoked" | "expired" | "missing" | "bl
45
56
  export type AuthSessionChecker = (
46
57
  sid: string,
47
58
  expectedUserId: string,
48
- ) => Promise<AuthSessionStatus>;
59
+ ) => Promise<AuthSessionCheckResult>;
49
60
 
50
61
  // Resolves a raw bearer token into a SessionUser, or null when no registered
51
62
  // provider claims it (or the claiming provider rejects it as unknown/revoked/
@@ -276,12 +287,17 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
276
287
  // token carries a sid.
277
288
  // A checker wired without a sid on the token means the token predates
278
289
  // session tracking (or the JWT was forged) — reject.
290
+ let derivedRoles: readonly string[] | undefined;
279
291
  if (sessionChecker) {
280
292
  if (payload.jti) {
281
- const status = await sessionChecker(payload.jti, payload.sub);
293
+ const result = await sessionChecker(payload.jti, payload.sub);
294
+ const status = typeof result === "string" ? result : result.status;
282
295
  if (status !== "live") {
283
296
  return sessionInvalid(c, status);
284
297
  }
298
+ if (typeof result === "object") {
299
+ derivedRoles = result.roles;
300
+ }
285
301
  } else {
286
302
  return sessionInvalid(c, "no_sid");
287
303
  }
@@ -306,7 +322,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
306
322
  const user: SessionUser = {
307
323
  id: payload.sub,
308
324
  tenantId: payload.tenantId,
309
- roles: payload.roles,
325
+ roles: derivedRoles ?? payload.roles,
310
326
  ...(payload.timezone ? { timezone: payload.timezone } : {}),
311
327
  ...(payload.claims ? { claims: payload.claims } : {}),
312
328
  ...(payload.jti ? { sid: payload.jti } : {}),
package/src/api/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  AnonymousAccessResolved,
6
6
  AuthMiddlewareOptions,
7
7
  AuthSessionChecker,
8
+ AuthSessionCheckResult,
8
9
  AuthSessionStatus,
9
10
  TenantExists,
10
11
  TenantLifecycleStatusResolver,