@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.
- package/package.json +7 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -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 +90 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -21
- package/src/api/routes.ts +47 -1
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/build-app-schema.test.ts +25 -0
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- 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 +156 -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 +48 -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/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- package/src/http/policy.ts +193 -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
|
@@ -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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
|
|
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
|
|
104
|
-
// (prod-version.sh)
|
|
105
|
-
// kubectl + crictl
|
|
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
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
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 ?
|
|
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
|
});
|
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
|
|
|
@@ -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,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
|
+
});
|