@cosmicdrift/kumiko-framework 0.200.0 → 0.201.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.
- package/package.json +3 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +16 -0
- package/src/api/route-registrars.ts +4 -3
- package/src/api/routes.ts +47 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +28 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- 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.
|
|
3
|
+
"version": "0.201.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>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.201.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.201.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -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
|
+
});
|
|
@@ -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}`;
|
|
@@ -85,4 +85,20 @@ describe("request body limit", () => {
|
|
|
85
85
|
const res = await postJson(app, "/api/write", 50_000);
|
|
86
86
|
expect(res.status).toBe(401); // passes body-limit, reaches auth
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
// Security regression (2026-08-13 audit, Finding 2): /api/stream was
|
|
90
|
+
// missing from BODY_LIMIT_PATHS, so it dispatched to the dispatcher
|
|
91
|
+
// without ever hitting the same 1MB cap /api/write enforces. Mirrors the
|
|
92
|
+
// /api/write cases above.
|
|
93
|
+
test("rejects POST /api/stream with body larger than maxRequestBytes with 413 (mirrors /api/write)", async () => {
|
|
94
|
+
const app = buildApp(1024);
|
|
95
|
+
const res = await postJson(app, "/api/stream", 2048);
|
|
96
|
+
expect(res.status).toBe(413);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("accepts POST /api/stream with body within the limit (reaches auth layer, not 413)", async () => {
|
|
100
|
+
const app = buildApp(10_000);
|
|
101
|
+
const res = await postJson(app, "/api/stream", 100);
|
|
102
|
+
expect(res.status).toBe(401); // no JWT → 401, but size is fine
|
|
103
|
+
});
|
|
88
104
|
});
|
|
@@ -27,15 +27,16 @@ const BODY_LIMIT_PATHS = [
|
|
|
27
27
|
`/api${Routes.batch}`,
|
|
28
28
|
`/api${Routes.query}`,
|
|
29
29
|
`/api${Routes.command}`,
|
|
30
|
+
`/api${Routes.stream}`,
|
|
30
31
|
`/api${Routes.auth}/*`,
|
|
31
32
|
] as const;
|
|
32
33
|
|
|
33
34
|
export const DEFAULT_MAX_REQUEST_BYTES = 1_048_576;
|
|
34
35
|
|
|
35
36
|
// Cap JSON bodies on /api/write + /api/batch + /api/query + /api/command
|
|
36
|
-
// + /api/auth/*. File uploads keep their own per-field
|
|
37
|
-
// disables the limit entirely — only useful when a
|
|
38
|
-
// upstream or tests want raw passthrough.
|
|
37
|
+
// + /api/stream + /api/auth/*. File uploads keep their own per-field
|
|
38
|
+
// maxSize. `0` disables the limit entirely — only useful when a
|
|
39
|
+
// reverse-proxy caps upstream or tests want raw passthrough.
|
|
39
40
|
export function registerBodyLimit(app: Hono, maxBytes: number): void {
|
|
40
41
|
// skip: opt-out path — caller passed `maxBytes: 0`, so no middleware
|
|
41
42
|
// is attached (upstream cap via reverse-proxy is expected). Not a bug
|
package/src/api/routes.ts
CHANGED
|
@@ -45,6 +45,7 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
|
|
|
45
45
|
const body = await c.req.json<{ type: string; payload: unknown; requestId?: string }>();
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
|
+
assertPayloadDepthAllowed(body.payload);
|
|
48
49
|
assertPatAllowed(user, body.type);
|
|
49
50
|
const result = await dispatcher.write(body.type, body.payload, user, body.requestId);
|
|
50
51
|
if (!result.isSuccess) {
|
|
@@ -83,6 +84,7 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
try {
|
|
87
|
+
for (const cmd of body.commands) assertPayloadDepthAllowed(cmd.payload);
|
|
86
88
|
if (user.pat) {
|
|
87
89
|
for (const cmd of body.commands) assertPatAllowed(user, cmd.type);
|
|
88
90
|
}
|
|
@@ -120,6 +122,7 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
|
|
|
120
122
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
121
123
|
|
|
122
124
|
try {
|
|
125
|
+
assertPayloadDepthAllowed(body.payload);
|
|
123
126
|
assertPatAllowed(user, body.type);
|
|
124
127
|
const result = await dispatcher.query(body.type, body.payload, user);
|
|
125
128
|
return jsonResponse(c, { data: result });
|
|
@@ -133,6 +136,7 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
|
|
|
133
136
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
134
137
|
|
|
135
138
|
try {
|
|
139
|
+
assertPayloadDepthAllowed(body.payload);
|
|
136
140
|
assertPatAllowed(user, body.type);
|
|
137
141
|
await dispatcher.command(body.type, body.payload, user);
|
|
138
142
|
return c.json({ ok: true }, 202);
|
|
@@ -156,9 +160,11 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
|
|
|
156
160
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
157
161
|
const requestId = requestContext.get()?.requestId;
|
|
158
162
|
|
|
159
|
-
|
|
163
|
+
let generator: AsyncGenerator<unknown>;
|
|
160
164
|
try {
|
|
165
|
+
assertPayloadDepthAllowed(body.payload);
|
|
161
166
|
assertPatAllowed(user, body.type);
|
|
167
|
+
generator = dispatcher.stream(body.type, body.payload, user);
|
|
162
168
|
} catch (e) {
|
|
163
169
|
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
164
170
|
}
|
|
@@ -274,6 +280,46 @@ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode =
|
|
|
274
280
|
|
|
275
281
|
const toKumiko = toKumikoError;
|
|
276
282
|
|
|
283
|
+
// Nesting cap for request payloads — an object-walk, not a string/byte
|
|
284
|
+
// check, since an attacker fully controls whitespace/formatting and a
|
|
285
|
+
// minified deeply-nested payload can stay well under the byte limit while
|
|
286
|
+
// still blowing the stack on recursive Zod parsing / jsonb serialization.
|
|
287
|
+
export const MAX_PAYLOAD_DEPTH = 20;
|
|
288
|
+
|
|
289
|
+
function payloadDepth(value: unknown, depth: number): number {
|
|
290
|
+
if (depth > MAX_PAYLOAD_DEPTH || value === null || typeof value !== "object") {
|
|
291
|
+
return depth;
|
|
292
|
+
}
|
|
293
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
294
|
+
let max = depth;
|
|
295
|
+
for (const child of children) {
|
|
296
|
+
const childDepth = payloadDepth(child, depth + 1);
|
|
297
|
+
if (childDepth > max) max = childDepth;
|
|
298
|
+
if (max > MAX_PAYLOAD_DEPTH) break;
|
|
299
|
+
}
|
|
300
|
+
return max;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Rejects payloads nested deeper than MAX_PAYLOAD_DEPTH before they reach
|
|
304
|
+
// per-handler Zod validation — recursive Zod parsing (and any downstream
|
|
305
|
+
// jsonb serialization of custom-fields payloads) walks the same object
|
|
306
|
+
// graph an attacker controls, so an uncapped depth is a stack-overflow /
|
|
307
|
+
// CPU-DoS vector even under the 1MB body-byte limit.
|
|
308
|
+
function assertPayloadDepthAllowed(payload: unknown): void {
|
|
309
|
+
if (payloadDepth(payload, 0) > MAX_PAYLOAD_DEPTH) {
|
|
310
|
+
throw new ValidationError({
|
|
311
|
+
fields: [
|
|
312
|
+
{
|
|
313
|
+
path: "payload",
|
|
314
|
+
code: "too_deep",
|
|
315
|
+
i18nKey: "errors.validation.payload_too_deep",
|
|
316
|
+
params: { maxDepth: MAX_PAYLOAD_DEPTH },
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
277
323
|
// PAT scope enforcement at the API boundary. No-op for cookie/JWT users
|
|
278
324
|
// (user.pat undefined → unrestricted). For a PAT-authenticated request the
|
|
279
325
|
// dispatch type must match one of the token's granted-scope QN globs, else
|
|
@@ -114,4 +114,70 @@ describe("createUncheckedSystemDb", () => {
|
|
|
114
114
|
expect(() => unchecked.acknowledgeCrossTenant(" ")).toThrow(/non-empty reason/);
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("outsideTransaction", () => {
|
|
119
|
+
describe("assertTenantMatch", () => {
|
|
120
|
+
test("returns the outside-transaction TenantDb, not the in-tx one, when the tenantId matches", () => {
|
|
121
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
122
|
+
const outsideTxDb = createTenantDb(unusedRunner(), own, "system");
|
|
123
|
+
const unchecked = createUncheckedSystemDb(systemDb, outsideTxDb);
|
|
124
|
+
|
|
125
|
+
const result = unchecked.outsideTransaction.assertTenantMatch(own);
|
|
126
|
+
expect(result).toBe(outsideTxDb);
|
|
127
|
+
expect(result).not.toBe(systemDb);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("throws AccessDeniedError when the tenantId doesn't match", () => {
|
|
131
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
132
|
+
const outsideTxDb = createTenantDb(unusedRunner(), own, "system");
|
|
133
|
+
const unchecked = createUncheckedSystemDb(systemDb, outsideTxDb);
|
|
134
|
+
|
|
135
|
+
expect(() => unchecked.outsideTransaction.assertTenantMatch(foreign)).toThrow(
|
|
136
|
+
/outsideTransaction tenant self-check failed/,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("throws when no outside-transaction db was configured, even for a matching tenantId", () => {
|
|
141
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
142
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
143
|
+
|
|
144
|
+
expect(() => unchecked.outsideTransaction.assertTenantMatch(own)).toThrow(
|
|
145
|
+
/no outside-transaction database source is configured/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("acknowledgeCrossTenant", () => {
|
|
151
|
+
test("returns the outside-transaction TenantDb without comparing tenants when given a reason", () => {
|
|
152
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
153
|
+
const outsideTxDb = createTenantDb(unusedRunner(), own, "system");
|
|
154
|
+
const unchecked = createUncheckedSystemDb(systemDb, outsideTxDb);
|
|
155
|
+
|
|
156
|
+
const result = unchecked.outsideTransaction.acknowledgeCrossTenant(
|
|
157
|
+
"durability write is cross-tenant by design",
|
|
158
|
+
);
|
|
159
|
+
expect(result).toBe(outsideTxDb);
|
|
160
|
+
expect(result).not.toBe(systemDb);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("throws on an empty reason", () => {
|
|
164
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
165
|
+
const outsideTxDb = createTenantDb(unusedRunner(), own, "system");
|
|
166
|
+
const unchecked = createUncheckedSystemDb(systemDb, outsideTxDb);
|
|
167
|
+
|
|
168
|
+
expect(() => unchecked.outsideTransaction.acknowledgeCrossTenant("")).toThrow(
|
|
169
|
+
/non-empty reason/,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("throws when no outside-transaction db was configured, even with a valid reason", () => {
|
|
174
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
175
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
176
|
+
|
|
177
|
+
expect(() => unchecked.outsideTransaction.acknowledgeCrossTenant("valid reason")).toThrow(
|
|
178
|
+
/no outside-transaction database source is configured/,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
117
183
|
});
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
type WhereObject,
|
|
18
18
|
} from "../db/query";
|
|
19
19
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
20
|
-
import { AccessDeniedError } from "../errors";
|
|
20
|
+
import { AccessDeniedError, InternalError } from "../errors";
|
|
21
21
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
22
22
|
import type { DbRunner } from "./connection";
|
|
23
23
|
|
|
@@ -32,9 +32,35 @@ export {
|
|
|
32
32
|
|
|
33
33
|
// buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
|
|
34
34
|
// mode from the caller's own tenantId, never a foreign one.
|
|
35
|
-
|
|
35
|
+
//
|
|
36
|
+
// dbOutsideTransaction is optional so every existing single-arg call site
|
|
37
|
+
// (jobs, tests, delivery-service.ts) keeps compiling — those callers have no
|
|
38
|
+
// outside-tx source to hand in and never needed one. Only
|
|
39
|
+
// buildHandlerContext passes it, which is also the only place `.outsideTransaction`
|
|
40
|
+
// is reachable through `ctx.systemDb`.
|
|
41
|
+
export function createUncheckedSystemDb(
|
|
42
|
+
db: TenantDb,
|
|
43
|
+
dbOutsideTransaction?: TenantDb,
|
|
44
|
+
): UncheckedSystemDb {
|
|
36
45
|
const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
|
|
37
46
|
|
|
47
|
+
// Fails closed instead of falling back to the in-tx `db` — a silent
|
|
48
|
+
// fallback would defeat the point of a durability write that must survive
|
|
49
|
+
// a rollback of the handler's own transaction. InternalError (not
|
|
50
|
+
// AccessDeniedError) because this is a dispatch wiring fault, not a
|
|
51
|
+
// tenant-access denial — mirrors the "no database connection configured"
|
|
52
|
+
// case in dispatch-shared.ts's appendDomainEvent.
|
|
53
|
+
function requireOutsideTransactionDb(): TenantDb {
|
|
54
|
+
if (!dbOutsideTransaction) {
|
|
55
|
+
throw new InternalError({
|
|
56
|
+
message:
|
|
57
|
+
"systemScope() outsideTransaction check failed: no outside-transaction database " +
|
|
58
|
+
"source is configured for this dispatch.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return dbOutsideTransaction;
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
return {
|
|
39
65
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
40
66
|
|
|
@@ -68,6 +94,24 @@ export function createUncheckedSystemDb(db: TenantDb): UncheckedSystemDb {
|
|
|
68
94
|
}
|
|
69
95
|
return db;
|
|
70
96
|
},
|
|
97
|
+
|
|
98
|
+
outsideTransaction: {
|
|
99
|
+
assertTenantMatch(tenantId) {
|
|
100
|
+
if (tenantId !== db.tenantId) {
|
|
101
|
+
throw new AccessDeniedError({
|
|
102
|
+
message: `systemScope() outsideTransaction tenant self-check failed: expected "${db.tenantId}", got "${tenantId}"`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return requireOutsideTransactionDb();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
acknowledgeCrossTenant(reason) {
|
|
109
|
+
if (reason.trim().length === 0) {
|
|
110
|
+
throw new Error("acknowledgeCrossTenant requires a non-empty reason");
|
|
111
|
+
}
|
|
112
|
+
return requireOutsideTransactionDb();
|
|
113
|
+
},
|
|
114
|
+
},
|
|
71
115
|
};
|
|
72
116
|
}
|
|
73
117
|
|
|
@@ -102,9 +102,16 @@ type ListPayload = {
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
const idSchema = z.object({ id: z.uuid() });
|
|
105
|
+
|
|
106
|
+
// Upper bound on entity-list `limit`: unbounded/fractional values ran
|
|
107
|
+
// straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
|
|
108
|
+
// read.ts), letting a client demand a full-table materialisation or trip a
|
|
109
|
+
// Postgres syntax error on a non-integer literal.
|
|
110
|
+
export const MAX_LIST_LIMIT = 200;
|
|
111
|
+
|
|
105
112
|
export const entityListSchema = z.object({
|
|
106
113
|
cursor: z.string().optional(),
|
|
107
|
-
limit: z.number().optional(),
|
|
114
|
+
limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
|
|
108
115
|
search: z.string().optional(),
|
|
109
116
|
sort: z.string().optional(),
|
|
110
117
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ENTITY_ITEM, EXT_TENANT_DATA } from "./constants";
|
|
2
|
+
|
|
3
|
+
// biome-ignore lint/suspicious/noExplicitAny: structural parser test fixture, never executed or type-checked at runtime
|
|
4
|
+
declare function defineFeature(name: string, setup: (r: any) => void): void;
|
|
5
|
+
|
|
6
|
+
defineFeature("cross-file-patch-const", (r) => {
|
|
7
|
+
r.useExtension(EXT_TENANT_DATA, ENTITY_ITEM);
|
|
8
|
+
});
|