@cosmicdrift/kumiko-framework 0.372.0 → 0.374.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/agents/examples/rate-limiting.md +65 -1
- package/agents/widgets.md +1 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +47 -16
- package/dist/engine/boot-validator/projection-list-screens.js +28 -37
- package/dist/engine/boot-validator/query-refs.js +26 -8
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +327 -41
- package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
- package/dist/engine/build-app-schema.js +7 -3
- package/dist/engine/entity-list-searchability.d.ts +5 -0
- package/dist/engine/entity-list-searchability.js +35 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ui-extensions.js +5 -2
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/paged-list.d.ts +29 -0
- package/dist/engine/paged-list.js +56 -0
- package/dist/engine/project-app-schema-for-roles.js +37 -6
- package/dist/engine/types/index.d.ts +3 -3
- package/dist/engine/types/index.js +1 -1
- package/dist/files/bind-pending-file-refs.d.ts +31 -1
- package/dist/files/bind-pending-file-refs.js +80 -6
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -1
- package/dist/i18n/required-surface-keys.js +50 -2
- package/dist/pipeline/dispatch-shared.js +55 -35
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/resolver.d.ts +1 -1
- package/dist/rate-limit/resolver.js +104 -2
- package/dist/rate-limit/unlimited-resolver.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +5 -5
- package/src/changes.json +74 -0
|
@@ -13,8 +13,29 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
|
|
|
13
13
|
## What it shows
|
|
14
14
|
|
|
15
15
|
- Handler `rateLimit: { per, limit, windowSeconds }`
|
|
16
|
+
- Several buckets on one handler: `rateLimit: [{ per: "user", ... }, { per: "tenant+handler", ... }]`
|
|
16
17
|
- How L1/L2 from `buildServer` stack with L3
|
|
17
18
|
|
|
19
|
+
## Combined buckets
|
|
20
|
+
|
|
21
|
+
Pass a list to enforce several budgets on one handler, for example a per-caller and a per-tenant cap:
|
|
22
|
+
|
|
23
|
+
```ts illustration
|
|
24
|
+
r.writeHandler({
|
|
25
|
+
// ...
|
|
26
|
+
rateLimit: [
|
|
27
|
+
{ per: "user", limit: 2, windowSeconds: 3600 },
|
|
28
|
+
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Each bucket applies on its own. The check is all-or-nothing: if any bucket is exhausted the call
|
|
34
|
+
answers 429 `rate_limited` for that bucket (the one with the longest wait when several are
|
|
35
|
+
exhausted) and no bucket is charged. Each `per` may appear once; an empty list fails at boot.
|
|
36
|
+
An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
|
|
37
|
+
stays a single declaration.
|
|
38
|
+
|
|
18
39
|
## Per-payload limits
|
|
19
40
|
|
|
20
41
|
A public write handler that mails an address typed by the caller is also limited per
|
|
@@ -67,7 +88,8 @@ bun test
|
|
|
67
88
|
// in each layer's test.
|
|
68
89
|
|
|
69
90
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
70
|
-
import {
|
|
91
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
92
|
+
import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
71
93
|
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
72
94
|
import { createRateLimitShowcaseFeature } from "../feature";
|
|
73
95
|
|
|
@@ -130,6 +152,35 @@ describe("L3 — handler opt-in (per-user budget on expensive-search)", () => {
|
|
|
130
152
|
});
|
|
131
153
|
});
|
|
132
154
|
|
|
155
|
+
describe("L3 — combined buckets (per-user and per-tenant budget on submit-report)", () => {
|
|
156
|
+
const submitReport = "rl-showcase:write:submit-report";
|
|
157
|
+
|
|
158
|
+
async function rejectedBucket(user: SessionUser): Promise<string | undefined> {
|
|
159
|
+
const res = await stack.http.write(submitReport, {}, user);
|
|
160
|
+
expect(res.status).toBe(429);
|
|
161
|
+
const body = (await res.json()) as { error: { details?: { bucket?: string } } };
|
|
162
|
+
return body.error.details?.bucket;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("the user bucket rejects first; the tenant bucket only counts accepted requests", async () => {
|
|
166
|
+
await stack.http.writeOk(submitReport, {}, admin);
|
|
167
|
+
await stack.http.writeOk(submitReport, {}, admin);
|
|
168
|
+
expect(await rejectedBucket(admin)).toBe("user");
|
|
169
|
+
|
|
170
|
+
// The rejected third call did not touch the tenant bucket: one of its 3 tokens is left.
|
|
171
|
+
await stack.http.writeOk(submitReport, {}, TestUsers.user);
|
|
172
|
+
expect(await rejectedBucket(TestUsers.user)).toBe("tenant+handler");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a caller without Admin or User role is rejected with access_denied before any bucket is charged", async () => {
|
|
176
|
+
const outsider = createTestUser({ id: 9, tenantId: admin.tenantId, roles: ["Guest"] });
|
|
177
|
+
const res = await stack.http.write(submitReport, {}, outsider);
|
|
178
|
+
expect(res.status).toBe(403);
|
|
179
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
180
|
+
expect(body.error.code).toBe("access_denied");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
133
184
|
describe("L1 — global IP middleware (covers every /api/* request)", () => {
|
|
134
185
|
test("21st request from same IP returns 429 with l1:ip bucket", async () => {
|
|
135
186
|
// L1 sits ahead of auth + every /api/* route. Target an authless,
|
|
@@ -205,6 +256,19 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
|
|
|
205
256
|
access: { roles: ["Admin", "User"] },
|
|
206
257
|
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
|
|
207
258
|
});
|
|
259
|
+
|
|
260
|
+
// Combined budget: each caller gets 2 submits per hour and the tenant 3 per hour in
|
|
261
|
+
// total. Both buckets are checked together; a request one bucket rejects charges neither.
|
|
262
|
+
r.writeHandler({
|
|
263
|
+
name: "submit-report",
|
|
264
|
+
schema: z.object({}),
|
|
265
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
266
|
+
access: { roles: ["Admin", "User"] },
|
|
267
|
+
rateLimit: [
|
|
268
|
+
{ per: "user", limit: 2, windowSeconds: 3600 },
|
|
269
|
+
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
270
|
+
],
|
|
271
|
+
});
|
|
208
272
|
});
|
|
209
273
|
}
|
|
210
274
|
```
|
package/agents/widgets.md
CHANGED
|
@@ -118,6 +118,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
118
118
|
| `useDraft` | Formular-State für Rechner-Screens: ein Draft-Objekt + `patch` für Teil-Updates + `field(name)` das die `{ id, name, value, onChange }`-Props fertig für die Feld-Widgets (NumberField/MoneyField/PercentField) liefert. |
|
|
119
119
|
| `useForm` | |
|
|
120
120
|
| `useIsNarrowViewport` | |
|
|
121
|
+
| `useIsViewportBelow` | |
|
|
121
122
|
| `useLiveEvents` | Hook für useQuery-like Consumer. |
|
|
122
123
|
| `useLocale` | Liefert den aktuellen LocaleResolver und abonniert automatisch Locale-Änderungen — der aufrufende Component re-rendert sobald die Sprache gewechselt wird. |
|
|
123
124
|
| `useNav` | |
|
|
@@ -10,6 +10,7 @@ export declare function validateAdditionalRateLimits(featureName: string, handle
|
|
|
10
10
|
export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
11
11
|
export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
12
12
|
export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
13
|
+
export declare function validateRateLimitBucketList(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
13
14
|
export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
|
|
14
15
|
export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
15
16
|
export declare function validateMultiStreamProjections(feature: FeatureDefinition): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
-
import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { declaredRateLimitOptions, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import { ZodObject } from "zod";
|
|
4
4
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
|
|
5
5
|
import { access as accessPresets } from "../config-helpers.js";
|
|
@@ -133,6 +133,7 @@ export function validateHandlerAccess(feature) {
|
|
|
133
133
|
`Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`);
|
|
134
134
|
}
|
|
135
135
|
validateNoAllRoleInHandlerAccess(feature.name, kind, name, handler.access);
|
|
136
|
+
validateRateLimitBucketList(feature.name, kind, name, handler.rateLimit);
|
|
136
137
|
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
137
138
|
validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
|
|
138
139
|
}
|
|
@@ -158,8 +159,17 @@ export function validateAdditionalRateLimits(featureName, handlerName, handler)
|
|
|
158
159
|
}
|
|
159
160
|
validateAnonymousHasIpBucket(where, handler);
|
|
160
161
|
const shape = handler.schema instanceof ZodObject ? handler.schema.shape : undefined;
|
|
162
|
+
const seenFields = new Set();
|
|
161
163
|
for (const entry of additionalRateLimits) {
|
|
162
164
|
validatePayloadBucket(where, shape, entry);
|
|
165
|
+
const field = entry.per.payloadField;
|
|
166
|
+
// Two entries on one field hash the same value into the same Redis key, so enforceAll
|
|
167
|
+
// would deduct only one of the costs.
|
|
168
|
+
if (seenFields.has(field)) {
|
|
169
|
+
throw new Error(`${where} with payloadField "${field}" more than once — both entries would share one ` +
|
|
170
|
+
`Redis bucket per value. Keep one entry per payloadField.`);
|
|
171
|
+
}
|
|
172
|
+
seenFields.add(field);
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
function validateAnonymousHasIpBucket(where, handler) {
|
|
@@ -168,9 +178,7 @@ function validateAnonymousHasIpBucket(where, handler) {
|
|
|
168
178
|
// skip: only anonymous callers need an ip bucket
|
|
169
179
|
if (!allowsAnonymous)
|
|
170
180
|
return;
|
|
171
|
-
const hasIpBucket = rateLimit
|
|
172
|
-
!isRateLimitDisabled(rateLimit) &&
|
|
173
|
-
IP_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per);
|
|
181
|
+
const hasIpBucket = declaredRateLimitOptions(rateLimit).some((option) => IP_BUCKETED_RATE_LIMIT_PER.has(option.per));
|
|
174
182
|
if (!hasIpBucket) {
|
|
175
183
|
throw new Error(`${where} and allows anonymous callers, but has no ip-keyed rateLimit ` +
|
|
176
184
|
`(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket. Set rateLimit: { per: "ip", ... } on the handler.`);
|
|
@@ -217,13 +225,16 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
|
217
225
|
throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
|
|
218
226
|
`carries no principal beyond the anonymous visitor. Set access to { roles: ["anonymous"] } or remove tenantlessAnonymous.`);
|
|
219
227
|
}
|
|
220
|
-
|
|
228
|
+
const options = declaredRateLimitOptions(handler.rateLimit);
|
|
229
|
+
if (options.length === 0) {
|
|
221
230
|
throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
|
|
222
231
|
`needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
|
|
223
232
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
for (const { per } of options) {
|
|
234
|
+
if (IP_SCOPED_RATE_LIMIT_PER.includes(per))
|
|
235
|
+
continue;
|
|
236
|
+
throw new Error(`${where} with rateLimit.per "${per}" — every anonymous visitor shares the same ` +
|
|
237
|
+
`tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((allowed) => `"${allowed}"`).join(" or ")}.`);
|
|
227
238
|
}
|
|
228
239
|
}
|
|
229
240
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
@@ -236,6 +247,23 @@ export function validateRateLimitDisabledReason(featureName, kind, handlerName,
|
|
|
236
247
|
`be rate-limited.`);
|
|
237
248
|
}
|
|
238
249
|
}
|
|
250
|
+
export function validateRateLimitBucketList(featureName, kind, handlerName, rateLimit) {
|
|
251
|
+
// skip: a single option or a disabled declaration has no bucket list to check
|
|
252
|
+
if (!Array.isArray(rateLimit))
|
|
253
|
+
return;
|
|
254
|
+
const where = `${kind} handler "${featureName}:${kind}:${handlerName}"`;
|
|
255
|
+
if (rateLimit.length === 0) {
|
|
256
|
+
throw new Error(`${where} declares rateLimit as an empty array — declare at least one bucket or drop the option.`);
|
|
257
|
+
}
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
for (const { per } of rateLimit) {
|
|
260
|
+
if (seen.has(per)) {
|
|
261
|
+
throw new Error(`${where} declares rateLimit with per "${per}" more than once — both entries would share one ` +
|
|
262
|
+
`Redis bucket with conflicting limits. Keep one entry per "per".`);
|
|
263
|
+
}
|
|
264
|
+
seen.add(per);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
239
267
|
// No session ever carries the role "all" — `roles: ["all"]` is unreachable dead config, not a wildcard.
|
|
240
268
|
export function validateNoAllRoleInHandlerAccess(featureName, kind, handlerName, access) {
|
|
241
269
|
// skip: openToAll has no roles list to check
|
|
@@ -260,19 +288,22 @@ export function validateAnonymousRateLimit(featureName, kind, handlerName, acces
|
|
|
260
288
|
// skip: disabled declarations carry no `.per` to bucket on
|
|
261
289
|
if (isRateLimitDisabled(rateLimit))
|
|
262
290
|
return;
|
|
263
|
-
|
|
291
|
+
const options = declaredRateLimitOptions(rateLimit);
|
|
292
|
+
if (options.length === 0) {
|
|
264
293
|
throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
|
|
265
294
|
`rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
|
|
266
295
|
`{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
|
|
267
296
|
`rateLimit: { disabled: true, reason: "..." }.`);
|
|
268
297
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
298
|
+
for (const { per } of options) {
|
|
299
|
+
// skip: this bucket is keyed on something safe (ip / tenant)
|
|
300
|
+
if (!USER_BUCKETED_RATE_LIMIT_PER.has(per))
|
|
301
|
+
continue;
|
|
302
|
+
throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` +
|
|
303
|
+
`rateLimit.per="${per}" — every anonymous request shares user.id="anonymous", ` +
|
|
304
|
+
`so this bucket would be a single global tap any caller could drain. ` +
|
|
305
|
+
`Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`);
|
|
306
|
+
}
|
|
276
307
|
}
|
|
277
308
|
// --- MultiStreamProjection delivery-invariant ---
|
|
278
309
|
//
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
|
|
2
|
+
import { QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
3
3
|
import { isEditLayoutScreen } from "../screen-helpers.js";
|
|
4
4
|
import { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../types/index.js";
|
|
5
5
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
|
|
@@ -28,28 +28,38 @@ function schemaAccepts(schema, key) {
|
|
|
28
28
|
const shape = getZodObjectShape(schema);
|
|
29
29
|
return shape !== undefined && key in shape;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
// sortable/paginated are derived by buildAppSchema from the query's Zod
|
|
32
|
+
// schema (fw#2165) — there is no separate wire type from the author-facing
|
|
33
|
+
// ProjectionListScreenDefinition, so hand-authoring them would otherwise be
|
|
34
|
+
// silently overwritten with no signal to the author. Reject outright.
|
|
35
|
+
function rejectHandAuthoredDerivedFlags(prefix, screen) {
|
|
36
|
+
if (screen.sortable !== undefined) {
|
|
37
|
+
throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
|
|
38
|
+
}
|
|
39
|
+
if (screen.paginated !== undefined) {
|
|
40
|
+
throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function validateOneProjectionListScreen(feature, screen, queryHandlers, featureMap) {
|
|
32
44
|
const prefix = `[projectionList] Feature "${feature.name}" screen "${screen.id}"`;
|
|
33
|
-
const
|
|
45
|
+
const handler = queryHandlers.get(screen.query);
|
|
46
|
+
const schema = handler?.schema;
|
|
47
|
+
// buildAppSchema derives searchable: false for these, so no whitelist entry is needed.
|
|
48
|
+
const searchDerivedOff = isListQueryWithoutSearchableFields(screen.query, handler, (name) => findEntity(featureMap, name));
|
|
34
49
|
if (screen.searchable === true && !schemaAccepts(schema, "search")) {
|
|
35
50
|
throw new Error(`${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema. Add a "search" field to the query schema or remove searchable: true.`);
|
|
36
51
|
}
|
|
52
|
+
if (screen.searchable === true && searchDerivedOff) {
|
|
53
|
+
throw new Error(`${prefix}: searchable: true but query "${screen.query}" lists an entity with no searchable field, so a search would never match. Mark a field searchable: true on the entity or remove searchable: true.`);
|
|
54
|
+
}
|
|
37
55
|
if (screen.searchable === false &&
|
|
38
56
|
schemaAccepts(schema, "search") &&
|
|
57
|
+
!searchDerivedOff &&
|
|
39
58
|
!SEARCHABLE_FALSE_WHITELIST.has(screen.id)) {
|
|
40
59
|
throw new Error(`${prefix}: query "${screen.query}" accepts "search" but searchable: false disables it — remove searchable: false or add "${screen.id}" to SEARCHABLE_FALSE_WHITELIST`);
|
|
41
60
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// ProjectionListScreenDefinition, so hand-authoring them would otherwise be
|
|
45
|
-
// silently overwritten with no signal to the author. Reject outright.
|
|
46
|
-
if (screen.sortable !== undefined) {
|
|
47
|
-
throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
|
|
48
|
-
}
|
|
49
|
-
if (screen.paginated !== undefined) {
|
|
50
|
-
throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
|
|
51
|
-
}
|
|
52
|
-
const searchActive = screen.searchable !== false && schemaAccepts(schema, "search");
|
|
61
|
+
rejectHandAuthoredDerivedFlags(prefix, screen);
|
|
62
|
+
const searchActive = screen.searchable !== false && !searchDerivedOff && schemaAccepts(schema, "search");
|
|
53
63
|
const sortActive = schemaAccepts(schema, "sort");
|
|
54
64
|
if ((searchActive || sortActive) && screen.defaultSort === undefined) {
|
|
55
65
|
throw new Error(`${prefix}: defaultSort required when search or sort is active. Set defaultSort: { field, direction }.`);
|
|
@@ -132,11 +142,12 @@ function validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema) {
|
|
|
132
142
|
}
|
|
133
143
|
export function validateProjectionListScreens(features) {
|
|
134
144
|
const queryHandlers = buildQueryHandlerMap(features);
|
|
145
|
+
const featureMap = new Map(features.map((f) => [f.name, f]));
|
|
135
146
|
for (const feature of features) {
|
|
136
147
|
for (const screen of Object.values(feature.screens)) {
|
|
137
148
|
if (screen.type !== "projectionList")
|
|
138
149
|
continue;
|
|
139
|
-
validateOneProjectionListScreen(feature, screen, queryHandlers);
|
|
150
|
+
validateOneProjectionListScreen(feature, screen, queryHandlers, featureMap);
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
}
|
|
@@ -155,26 +166,6 @@ function validateRelatedListFacets(prefix, section, schema) {
|
|
|
155
166
|
return;
|
|
156
167
|
throw new Error(`${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
|
|
157
168
|
}
|
|
158
|
-
// parentFilter resolves an entity field map to
|
|
159
|
-
// check `field` against ONLY when the query is one of the entity-convention
|
|
160
|
-
// factories (defineEntityListHandler et al., branded by
|
|
161
|
-
// ENTITY_CONVENTION_QUERY_BRAND) — a hand-written handler carries no entity,
|
|
162
|
-
// same "capability absent, no throw" policy as schemaAccepts above. The
|
|
163
|
-
// entity name comes from the handler's own `<entityName>:list` naming
|
|
164
|
-
// convention (parseHandlerName in entity-handlers.ts), recovered here from
|
|
165
|
-
// the query's fully qualified name.
|
|
166
|
-
function resolveEntityBehindQuery(queryQn, handler, featureMap) {
|
|
167
|
-
if (handler === undefined || handler[ENTITY_CONVENTION_QUERY_BRAND] !== true)
|
|
168
|
-
return undefined;
|
|
169
|
-
let entityName;
|
|
170
|
-
try {
|
|
171
|
-
entityName = parseQn(queryQn).name.split(":")[0] ?? "";
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return undefined;
|
|
175
|
-
}
|
|
176
|
-
return entityName === "" ? undefined : findEntity(featureMap, entityName);
|
|
177
|
-
}
|
|
178
169
|
// parentFilter lets a relatedList reuse the
|
|
179
170
|
// generic `<entity>:list` query instead of a bespoke child-rows handler —
|
|
180
171
|
// same 422 footgun as facets/filter above, plus its own mutual-exclusivity
|
|
@@ -193,7 +184,7 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
|
|
|
193
184
|
`Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
194
185
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
|
|
195
186
|
}
|
|
196
|
-
const entity =
|
|
187
|
+
const entity = resolveEntityBehindListQuery(section.query, handler, (name) => findEntity(featureMap, name));
|
|
197
188
|
if (entity !== undefined && entity.fields[section.parentFilter.field] === undefined) {
|
|
198
189
|
throw new Error(`${prefix}: parentFilter.field "${section.parentFilter.field}" is not a declared field on the ` +
|
|
199
190
|
`entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
|
|
1
2
|
import { buildQueryHandlerMap } from "./projection-list-screens.js";
|
|
2
3
|
const NOT_REGISTERED_SUFFIX = "is not a registered query-handler. Check the QN spelling (expected " +
|
|
3
4
|
'"<feature>:query:<short>") and that the handler is declared via r.queryHandler(...).';
|
|
@@ -29,19 +30,30 @@ function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
|
|
|
29
30
|
// types) keeps this collector correct if that restriction is ever lifted.
|
|
30
31
|
function checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screenType, layout) {
|
|
31
32
|
for (const section of layout.sections) {
|
|
33
|
+
if (isFieldsEditSection(section)) {
|
|
34
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
35
|
+
const { field, fillAction } = normalizeEditField(fieldSpec);
|
|
36
|
+
if (fillAction === undefined)
|
|
37
|
+
continue;
|
|
38
|
+
checkQueryRef(queryHandlers, fillAction.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${field}" fillAction`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
32
41
|
if (section.kind !== "relatedList")
|
|
33
42
|
continue;
|
|
34
43
|
checkQueryRef(queryHandlers, section.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`);
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
47
|
+
if (screen.record?.query !== undefined) {
|
|
48
|
+
checkQueryRef(queryHandlers, screen.record.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) record`);
|
|
49
|
+
}
|
|
38
50
|
for (const panel of screen.panels) {
|
|
39
51
|
if (panel.kind === "custom")
|
|
40
52
|
continue;
|
|
41
53
|
if (panel.visibleWhen !== undefined) {
|
|
42
54
|
checkVisibleWhenQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}" visibleWhen`);
|
|
43
55
|
}
|
|
44
|
-
if (panel.kind === "screen")
|
|
56
|
+
if (panel.kind === "screen" || panel.kind === "text")
|
|
45
57
|
continue;
|
|
46
58
|
if (panel.kind === "stat-group") {
|
|
47
59
|
for (const stat of panel.stats) {
|
|
@@ -70,6 +82,18 @@ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenTyp
|
|
|
70
82
|
}
|
|
71
83
|
}
|
|
72
84
|
}
|
|
85
|
+
function checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
86
|
+
checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
|
|
87
|
+
if (screen.type === "actionForm" && screen.load !== undefined) {
|
|
88
|
+
checkQueryRef(queryHandlers, screen.load.query, () => `[Feature ${featureName}] Screen "${screenId}" (actionForm) load`);
|
|
89
|
+
}
|
|
90
|
+
if (screen.type !== "entityEdit") {
|
|
91
|
+
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
|
|
92
|
+
}
|
|
93
|
+
if (screen.type === "secretMint" && screen.confirm !== undefined) {
|
|
94
|
+
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
73
97
|
function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
74
98
|
if (screen.visibleWhen !== undefined) {
|
|
75
99
|
checkVisibleWhenQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
|
|
@@ -90,13 +114,7 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
90
114
|
screen.type === "actionForm" ||
|
|
91
115
|
screen.type === "configEdit" ||
|
|
92
116
|
screen.type === "secretMint") {
|
|
93
|
-
|
|
94
|
-
if (screen.type !== "entityEdit") {
|
|
95
|
-
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
|
|
96
|
-
}
|
|
97
|
-
if (screen.type === "secretMint" && screen.confirm !== undefined) {
|
|
98
|
-
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
|
|
99
|
-
}
|
|
117
|
+
checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, screen);
|
|
100
118
|
}
|
|
101
119
|
else if (screen.type === "dashboard") {
|
|
102
120
|
checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen);
|
|
@@ -3,7 +3,7 @@ import type { ConfigKeyDefinition, FeatureDefinition } from "../types/index.ts";
|
|
|
3
3
|
import type { ActionFormScreenDefinition, DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, SecretMintScreenDefinition, ToolbarAction } from "../types/screen.ts";
|
|
4
4
|
export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
|
|
5
5
|
export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
|
|
6
|
-
export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList", context: string, action: {
|
|
6
|
+
export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList" | "dashboard", context: string, action: {
|
|
7
7
|
readonly id: string;
|
|
8
8
|
readonly icon?: IconKey;
|
|
9
9
|
}): void;
|