@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
@@ -11,7 +11,7 @@ import type { Lifecycle } from "../lifecycle";
11
11
  import type { Meter, PrometheusMeter } from "../observability";
12
12
  import { serializeOpenMetrics } from "../observability";
13
13
  import type { EventConsumer } from "../pipeline/event-dispatcher";
14
- import { Routes } from "./api-constants";
14
+ import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "./api-constants";
15
15
  import {
16
16
  createReadinessProbe,
17
17
  dbPingCheck,
@@ -22,27 +22,26 @@ import {
22
22
 
23
23
  // --- Body size limit ------------------------------------------------------
24
24
 
25
- const BODY_LIMIT_PATHS = [
26
- `/api${Routes.write}`,
27
- `/api${Routes.batch}`,
28
- `/api${Routes.query}`,
29
- `/api${Routes.command}`,
30
- `/api${Routes.auth}/*`,
31
- ] as const;
32
-
33
25
  export const DEFAULT_MAX_REQUEST_BYTES = 1_048_576;
34
26
 
35
- // 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.
27
+ // Cap every /api/* request body by default a new route needs no entry
28
+ // anywhere to be covered, it inherits the limit from being registered under
29
+ // /api at all. Routes with their own size contract (currently just uploads)
30
+ // opt out explicitly via BODY_LIMIT_OPT_OUT_PATHS in api-constants.ts;
31
+ // forgetting an opt-out entry only over-limits a route, forgetting to add a
32
+ // new route to an allowlist used to leave it unlimited — that inversion is
33
+ // the point. `maxBytes <= 0` disables the limit entirely — only useful when
34
+ // a reverse-proxy caps upstream or tests want raw passthrough.
39
35
  export function registerBodyLimit(app: Hono, maxBytes: number): void {
40
36
  // skip: opt-out path — caller passed `maxBytes: 0`, so no middleware
41
37
  // is attached (upstream cap via reverse-proxy is expected). Not a bug
42
38
  // suppression, an intentional disable.
43
39
  if (maxBytes <= 0) return;
44
40
  const limit = bodyLimit({ maxSize: maxBytes });
45
- for (const path of BODY_LIMIT_PATHS) app.use(path, limit);
41
+ app.use("/api/*", async (c, next) => {
42
+ if (BODY_LIMIT_OPT_OUT_PATHS.has(c.req.path)) return next();
43
+ return limit(c, next);
44
+ });
46
45
  }
47
46
 
48
47
  // --- /metrics (Prometheus scrape) -----------------------------------------
@@ -100,14 +99,13 @@ export function registerMetricsRoute(app: Hono, meter: Meter, options: MetricsRo
100
99
 
101
100
  // --- /version ---------------------------------------------------------------
102
101
 
103
- // Anonymous endpoint that returns build-identity. Used by ops-tooling
104
- // (prod-version.sh) und Telegram-deploy-Notification damit man nicht
105
- // kubectl + crictl auf den Master braucht um die deployed-Version zu
106
- // sehen.
102
+ // Anonymous endpoint that returns build-identity. Used by ops tooling
103
+ // (prod-version.sh) and the Telegram deploy notification so nobody needs
104
+ // kubectl + crictl on the master to see the deployed version.
107
105
  //
108
- // BUILD_VERSION + BUILD_TIME werden vom Dockerfile (ARG ENV)
109
- // durchgereichtfallen zurück auf "dev" / "unknown" wenn lokal ohne
110
- // Build-args gebaut.
106
+ // BUILD_VERSION + BUILD_TIME are passed through from the Dockerfile
107
+ // (ARG → ENV) fall back to "dev" / "unknown" when built locally
108
+ // without build-args.
111
109
  export function registerVersionRoute(app: Hono): void {
112
110
  const version = process.env["BUILD_VERSION"] ?? "dev";
113
111
  const buildTime = process.env["BUILD_TIME"] ?? "unknown";
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
package/src/api/server.ts CHANGED
@@ -310,7 +310,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
310
310
  // Stateless JWTs (no sessionChecker → no revocation) default to a shorter
311
311
  // TTL than session-backed ones, since a leaked stateless token can't be
312
312
  // revoked and stays valid until it expires. Explicit jwtTtl always wins.
313
- const defaultJwtTtl = options.auth?.sessionChecker ? 24 * 60 * 60 : 60 * 60;
313
+ const defaultJwtTtl = options.auth?.sessionChecker ? 8 * 60 * 60 : 60 * 60;
314
314
  const jwt = createJwtHelper(
315
315
  options.jwtSecret,
316
316
  options.jwtIssuer,
@@ -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
 
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "../boot-validator";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createEntity, createTextField } from "../factories";
5
+
6
+ describe("validateBoot — detailFor screens (fw#2163)", () => {
7
+ test("two screens with the same detailFor fail boot, naming both screen ids", () => {
8
+ const feature = defineFeature("demo", (r) => {
9
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
10
+ r.screen({
11
+ id: "item-detail-a",
12
+ type: "custom",
13
+ renderer: { react: "stub" },
14
+ detailFor: "item",
15
+ });
16
+ r.screen({
17
+ id: "item-detail-b",
18
+ type: "custom",
19
+ renderer: { react: "stub" },
20
+ detailFor: "item",
21
+ });
22
+ r.translations({
23
+ keys: {
24
+ "screen:item-detail-a.title": { de: "A", en: "A" },
25
+ "screen:item-detail-b.title": { de: "B", en: "B" },
26
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
27
+ },
28
+ });
29
+ });
30
+ expect(() => validateBoot([feature])).toThrow(/detailFor: "item"/);
31
+ expect(() => validateBoot([feature])).toThrow(/demo:screen:item-detail-a/);
32
+ expect(() => validateBoot([feature])).toThrow(/demo:screen:item-detail-b/);
33
+ });
34
+
35
+ test("detailFor on an unknown entity fails boot", () => {
36
+ const feature = defineFeature("demo", (r) => {
37
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
38
+ r.screen({
39
+ id: "item-detail",
40
+ type: "custom",
41
+ renderer: { react: "stub" },
42
+ detailFor: "ghost",
43
+ });
44
+ r.translations({
45
+ keys: {
46
+ "screen:item-detail.title": { de: "Detail", en: "Detail" },
47
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
48
+ },
49
+ });
50
+ });
51
+ expect(() => validateBoot([feature])).toThrow(/"ghost"/);
52
+ });
53
+
54
+ test("a valid detailFor on a custom screen passes boot", () => {
55
+ const feature = defineFeature("demo", (r) => {
56
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
57
+ r.screen({
58
+ id: "item-detail",
59
+ type: "custom",
60
+ renderer: { react: "stub" },
61
+ detailFor: "item",
62
+ });
63
+ r.translations({
64
+ keys: {
65
+ "screen:item-detail.title": { de: "Detail", en: "Detail" },
66
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
67
+ },
68
+ });
69
+ });
70
+ expect(() => validateBoot([feature])).not.toThrow();
71
+ });
72
+
73
+ test("an entity without any detail screen passes boot", () => {
74
+ const feature = defineFeature("demo", (r) => {
75
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
76
+ r.translations({
77
+ keys: { "demo:entity:item:field:name": { de: "Name", en: "Name" } },
78
+ });
79
+ });
80
+ expect(() => validateBoot([feature])).not.toThrow();
81
+ });
82
+ });
@@ -91,6 +91,31 @@ describe("buildAppSchema", () => {
91
91
  expect(screen).toMatchObject({ id: "privacy-center", dormant: true });
92
92
  });
93
93
 
94
+ // fw#2163: resolveTarget (renderer) reads screen.detailFor + screen.id
95
+ // (short, unqualified) off the client FeatureSchema — this pins that both
96
+ // survive the server→client projection verbatim, on a real registry-built
97
+ // schema rather than a hand-rolled FeatureSchema literal.
98
+ test("custom screen's `detailFor` survives the buildAppSchema projection, screen id stays unqualified (#2163)", () => {
99
+ const propertyFeature = defineFeature("property", (r) => {
100
+ r.entity("lease", {
101
+ table: "leases",
102
+ fields: { name: { type: "text" } },
103
+ } as unknown as EntityDefinition);
104
+ r.screen({
105
+ id: "lease-detail",
106
+ type: "custom",
107
+ renderer: { react: { __component: "LeaseDetailScreen" } },
108
+ detailFor: "lease",
109
+ });
110
+ r.translations({ keys: { "screen:lease-detail.title": { de: "Detail", en: "Detail" } } });
111
+ });
112
+
113
+ const app = buildAppSchema(createRegistry([propertyFeature]));
114
+ const screen = app.features.find((f) => f.featureName === "property")?.screens[0];
115
+
116
+ expect(screen).toMatchObject({ id: "lease-detail", detailFor: "lease" });
117
+ });
118
+
94
119
  test("Feature ohne r.translations lässt das Feld weg (omit-undefined-Pattern)", () => {
95
120
  const f = defineFeature("bare", (r) => {
96
121
  r.nav({ id: "x", label: "X" });
@@ -0,0 +1,35 @@
1
+ import { qualifyEntityName } from "../qualified-name";
2
+ import type { FeatureDefinition } from "../types";
3
+ import { findEntityFeature } from "./screens";
4
+
5
+ export function validateDetailForScreens(
6
+ features: readonly FeatureDefinition[],
7
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
8
+ ): void {
9
+ const screenQnByEntity = new Map<string, string>();
10
+
11
+ for (const feature of features) {
12
+ for (const [screenId, screen] of Object.entries(feature.screens)) {
13
+ const detailFor = screen.detailFor;
14
+ if (detailFor === undefined) continue;
15
+
16
+ const qualified = qualifyEntityName(feature.name, "screen", screenId);
17
+
18
+ const existingQn = screenQnByEntity.get(detailFor);
19
+ if (existingQn !== undefined) {
20
+ throw new Error(
21
+ `[detailFor] Screens "${existingQn}" and "${qualified}" both declare ` +
22
+ `detailFor: "${detailFor}" — only one screen may be the detail view for an entity.`,
23
+ );
24
+ }
25
+ screenQnByEntity.set(detailFor, qualified);
26
+
27
+ if (findEntityFeature(detailFor, featureMap) === undefined) {
28
+ throw new Error(
29
+ `[detailFor] Screen "${qualified}" declares detailFor: "${detailFor}", ` +
30
+ `but no feature registers an entity with that name.`,
31
+ );
32
+ }
33
+ }
34
+ }
35
+ }
@@ -16,6 +16,7 @@ import {
16
16
  validateConfigReads,
17
17
  warnOnToggleableDependencies,
18
18
  } from "./config-deps";
19
+ import { validateDetailForScreens } from "./detail-screens";
19
20
  import {
20
21
  validateDerivedFieldCollisions,
21
22
  validateEmbeddedFields,
@@ -208,6 +209,7 @@ export function validateBoot(
208
209
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
209
210
  validateI18nSurfaceKeys(features);
210
211
  validateEntityListScreens(features);
212
+ validateDetailForScreens(features, featureMap);
211
213
  validateExtensionPreSaveWiring(features);
212
214
  validateGdprStoragePersistence(features);
213
215
  validateFeatureBootChecks(features);
@@ -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
+ });