@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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api.test.ts +116 -1
  4. package/src/api/__tests__/batch.integration.test.ts +53 -0
  5. package/src/api/__tests__/body-limit.test.ts +16 -0
  6. package/src/api/route-registrars.ts +4 -3
  7. package/src/api/routes.ts +47 -1
  8. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  9. package/src/db/tenant-db.ts +46 -2
  10. package/src/engine/entity-handlers.ts +8 -1
  11. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  12. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  13. package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  15. package/src/engine/feature-ast/extractors/events.ts +5 -3
  16. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  17. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  18. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  19. package/src/engine/feature-ast/patch.ts +28 -21
  20. package/src/engine/feature-ast/patterns.ts +18 -0
  21. package/src/engine/feature-ast/render.ts +19 -6
  22. package/src/engine/index.ts +1 -0
  23. package/src/files/__tests__/files.integration.test.ts +97 -1
  24. package/src/files/file-routes.ts +10 -2
  25. package/src/files/types.ts +72 -0
  26. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  27. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  28. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  29. package/src/pipeline/dispatch-batch.ts +11 -5
  30. package/src/pipeline/dispatch-shared.ts +42 -16
  31. 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.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.200.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.200.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 maxSize. `0`
37
- // disables the limit entirely — only useful when a reverse-proxy caps
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
- const generator = dispatcher.stream(body.type, body.payload, user);
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
  });
@@ -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
- export function createUncheckedSystemDb(db: TenantDb): UncheckedSystemDb {
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,2 @@
1
+ export const EXT_TENANT_DATA = "tenant-data" as const;
2
+ export const ENTITY_ITEM = "item" as const;
@@ -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
+ });