@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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 (78) hide show
  1. package/agents/examples/i18n.md +36 -1
  2. package/agents/features.md +4 -0
  3. package/dist/api/auth-rate-limiters.d.ts +8 -0
  4. package/dist/api/auth-rate-limiters.js +105 -0
  5. package/dist/api/auth-routes-context.d.ts +34 -0
  6. package/dist/api/auth-routes-context.js +192 -0
  7. package/dist/api/auth-routes-invite.d.ts +2 -0
  8. package/dist/api/auth-routes-invite.js +171 -0
  9. package/dist/api/auth-routes-login.d.ts +2 -0
  10. package/dist/api/auth-routes-login.js +103 -0
  11. package/dist/api/auth-routes-mfa.d.ts +4 -0
  12. package/dist/api/auth-routes-mfa.js +211 -0
  13. package/dist/api/auth-routes-session.d.ts +2 -0
  14. package/dist/api/auth-routes-session.js +144 -0
  15. package/dist/api/auth-routes-shared.d.ts +33 -0
  16. package/dist/api/auth-routes-shared.js +81 -0
  17. package/dist/api/auth-routes-signup.d.ts +2 -0
  18. package/dist/api/auth-routes-signup.js +75 -0
  19. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  20. package/dist/api/auth-routes-token-flows.js +91 -0
  21. package/dist/api/auth-routes-types.d.ts +76 -0
  22. package/dist/api/auth-routes-types.js +1 -0
  23. package/dist/api/auth-routes.d.ts +5 -85
  24. package/dist/api/auth-routes.js +20 -1095
  25. package/dist/api/request-context.d.ts +2 -0
  26. package/dist/api/request-context.js +16 -7
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/crypto/pii-ciphertext-format.js +1 -1
  29. package/dist/db/blind-index-cleanup.js +1 -1
  30. package/dist/db/cross-tenant-reads.d.ts +8 -0
  31. package/dist/db/cross-tenant-reads.js +23 -0
  32. package/dist/db/dialect.js +1 -1
  33. package/dist/db/distinct-on.d.ts +9 -0
  34. package/dist/db/distinct-on.js +28 -0
  35. package/dist/db/event-store-executor-write.js +2 -2
  36. package/dist/db/migrate-runner.js +4 -4
  37. package/dist/db/pg-error.js +0 -12
  38. package/dist/db/queries/event-consumer.js +3 -3
  39. package/dist/db/queries/postgres-array-literal.js +2 -2
  40. package/dist/db/queries/test-stack.d.ts +4 -1
  41. package/dist/db/queries/test-stack.js +15 -2
  42. package/dist/db/query.js +19 -19
  43. package/dist/db/tenant-db.js +1 -1
  44. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  45. package/dist/engine/boot-validator/i18n-keys.js +8 -1
  46. package/dist/engine/boot-validator/index.d.ts +4 -0
  47. package/dist/engine/boot-validator/index.js +2 -1
  48. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  49. package/dist/engine/boot-validator/screens-actions.js +488 -0
  50. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  51. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  52. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  53. package/dist/engine/boot-validator/screens-forms.js +516 -0
  54. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  55. package/dist/engine/boot-validator/screens-lists.js +319 -0
  56. package/dist/engine/boot-validator/screens.d.ts +4 -21
  57. package/dist/engine/boot-validator/screens.js +26 -1799
  58. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  59. package/dist/engine/steps/read-find-one.js +1 -1
  60. package/dist/event-store/provenance-append.js +1 -1
  61. package/dist/files/file-routes.js +1 -1
  62. package/dist/i18n/required-surface-keys.d.ts +16 -0
  63. package/dist/i18n/required-surface-keys.js +62 -0
  64. package/dist/internal/stack-support.d.ts +1 -1
  65. package/dist/internal/stack-support.js +1 -1
  66. package/dist/jobs/job-runner-helpers.d.ts +44 -0
  67. package/dist/jobs/job-runner-helpers.js +265 -0
  68. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  69. package/dist/jobs/job-runner-schedulers.js +106 -0
  70. package/dist/jobs/job-runner-types.d.ts +65 -0
  71. package/dist/jobs/job-runner-types.js +2 -0
  72. package/dist/jobs/job-runner.d.ts +4 -75
  73. package/dist/jobs/job-runner.js +10 -386
  74. package/dist/pipeline/dispatch-shared.js +29 -4
  75. package/dist/pipeline/event-dispatcher.js +2 -3
  76. package/dist/schema-cli.js +5 -0
  77. package/package.json +3 -3
  78. package/src/changes.json +19 -0
@@ -0,0 +1,488 @@
1
+ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
2
+ import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
3
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
5
+ import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
6
+ export function isRowClickAction(action) {
7
+ return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
8
+ }
9
+ // entityList and projectionList both allow a rowAction to double as the
10
+ // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
11
+ // screen, or the renderer can't tell which one should fire.
12
+ export function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
13
+ const rowClickActions = rowActions.filter(isRowClickAction);
14
+ if (rowClickActions.length > 1) {
15
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
16
+ "rowActions marked rowClick:true — at most one may fire on a row-body click. Mark at most one rowAction with rowClick: true.");
17
+ }
18
+ }
19
+ export function rowFieldExtractorKeys(params) {
20
+ return "pick" in params ? params.pick : Object.keys(params.map);
21
+ }
22
+ // Mirrors the renderer's navigate: an explicit entityId, or a same-entity
23
+ // entityEdit target (row["id"] auto-fill), opens UPDATE mode, which ignores params.
24
+ export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourceScreenEntity) {
25
+ if (target.type === "actionForm" || target.type === "secretMint")
26
+ return true;
27
+ if (target.type !== "entityEdit")
28
+ return false;
29
+ if (explicitEntityId !== undefined)
30
+ return false;
31
+ return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
32
+ }
33
+ // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
34
+ // entityList and projectionList (framework#1708) — projectionList has no
35
+ // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
36
+ // entityEdit target without an explicit entityId reaches create there.
37
+ //
38
+ // dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
39
+ // URL search params — but only for the single `filter.id` a dashboard
40
+ // declares (useFilterParams in dashboard-body.tsx seeds its value from
41
+ // `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
42
+ // for the value to land, and a params extractor whose keys don't include
43
+ // the filter's id would silently miss it — both are boot errors instead of
44
+ // a silently-empty dashboard.
45
+ export function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
46
+ // skip: not a navigate-with-params action — nothing to validate here.
47
+ if (action.kind !== "navigate" || action.params === undefined)
48
+ return;
49
+ // skip: unresolvable/custom target already reported (or exempt) elsewhere.
50
+ if (target === undefined || target.screen.type === "custom")
51
+ return;
52
+ // skip: entityList/projectionList targets also read URL search params (Tier
53
+ // 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
54
+ // .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
55
+ if (target.screen.type === "entityList" || target.screen.type === "projectionList")
56
+ return;
57
+ if (target.screen.type === "dashboard") {
58
+ const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
59
+ const { filter, record, timeRange } = target.screen;
60
+ const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
61
+ if (readIds.length === 0) {
62
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
63
+ `params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
64
+ `filter, time range or record binding — params would be a no-op. Remove the params extractor, or add a \`filter\` (or a \`record\` binding) to the ` +
65
+ `target dashboard so it has somewhere to read the value from.`);
66
+ }
67
+ const extractedKeys = rowFieldExtractorKeys(action.params);
68
+ if (!readIds.some((id) => extractedKeys.includes(id))) {
69
+ const readDescriptor = filter !== undefined && readIds.length === 1
70
+ ? `filter id is "${filter.id}"`
71
+ : `URL params are [${readIds.join(", ")}]`;
72
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
73
+ `params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
74
+ `${readDescriptor} — none of the extracted keys match, so the value would stay ` +
75
+ `unset. Fix the params extractor to produce one of these keys, or remove it.`);
76
+ }
77
+ // skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
78
+ return;
79
+ }
80
+ if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
81
+ const isEntityEditUpdate = target.screen.type === "entityEdit";
82
+ const reason = isEntityEditUpdate
83
+ ? `resolves to UPDATE mode (${action.entityId !== undefined
84
+ ? `explicit entityId "${action.entityId}"`
85
+ : `same entity "${screenEntity}" auto-fills row["id"]`})`
86
+ : `screen type "${target.screen.type}"`;
87
+ const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
88
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
89
+ `sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
90
+ `secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
91
+ `the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
92
+ `entityEdit-create screen.`);
93
+ }
94
+ const formTarget = target.screen;
95
+ if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
96
+ formTarget.submitPrefilled === true) {
97
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
98
+ }
99
+ }
100
+ // every action rendered in a Card title row (screen-level
101
+ // `actions`, section-level `actions`, `emptyState.action`) must draw an
102
+ // icon — declared or id-derived (resolveActionIcon, same resolver the
103
+ // renderer uses). Uncovered here: entityList/projectionList rowActions —
104
+ // those render inside a DataTable row, not a Card title row, so they keep
105
+ // today's "icon optional" behavior.
106
+ //
107
+ // Registration check (not just "resolved is not undefined"): action.icon is
108
+ // typed IconKey, a closed union — but that only protects call sites this
109
+ // repo's own `tsc --build` actually covers (samples and external consumers
110
+ // aren't in that project graph). The renderer's actionIconFor
111
+ // (renderer/src/web/primitives/index.tsx) already falls back to "no icon"
112
+ // for an unregistered key instead of crashing — this check makes the same
113
+ // gap fail loudly at boot instead of silently at render.
114
+ function isRegisteredIcon(icon) {
115
+ return isOneOf(NAV_ICON_KEYS, icon);
116
+ }
117
+ export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
118
+ const resolved = resolveActionIcon(action.id, action.icon);
119
+ if (resolved === undefined || !isRegisteredIcon(resolved)) {
120
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
121
+ `resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
122
+ `its kebab segments via the default icon map (resolveActionIcon).`);
123
+ }
124
+ }
125
+ // Tier 2.7e-3: pin an action's declarative field references against the entity
126
+ // fields — a typo in pick/map source fields or visible.field would otherwise
127
+ // silently yield `undefined` in the payload or permanently wrong visibility
128
+ // (same "typo only surfaces on click" class as navigate/handler). Exported for
129
+ // query-output-columns.ts, which reuses it against a projectionDetail's
130
+ // outputSchema-derived record shape instead of an entity's field map
131
+ // (relatedList toolbarActions' visible/params).
132
+ export function validateActionFieldRefs(featureName, screenId, actionKind, actionId, action, fieldNames, rowMeta) {
133
+ // ToolbarAction.payload is a STATIC record (no row context) — only real
134
+ // pick/map extractors are checked against the field names.
135
+ const isExtractor = (v) => typeof v === "object" && v !== null && ("pick" in v || "map" in v);
136
+ const payload = "payload" in action && isExtractor(action.payload) ? action.payload : undefined;
137
+ const params = "params" in action && isExtractor(action.params) ? action.params : undefined;
138
+ const visible = "visible" in action ? action.visible : undefined;
139
+ const entityId = "entityId" in action ? action.entityId : undefined;
140
+ const known = () => [...fieldNames].sort().join(", ") || "(none)";
141
+ const checkExtractor = (label, extractor) => {
142
+ // skip: the extractor is an optional action slot — without it there are
143
+ // no field references to validate.
144
+ if (extractor === undefined) {
145
+ return;
146
+ }
147
+ const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
148
+ for (const source of sources) {
149
+ if (rowMeta.has(source))
150
+ continue;
151
+ if (!fieldNames.has(source)) {
152
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
153
+ `${label} references unknown field "${source}". Known fields: ${known()}.`);
154
+ }
155
+ }
156
+ };
157
+ checkExtractor("payload", payload);
158
+ checkExtractor("params", params);
159
+ if (visible !== undefined &&
160
+ typeof visible !== "boolean" &&
161
+ !rowMeta.has(visible.field) &&
162
+ !fieldNames.has(visible.field)) {
163
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
164
+ `visible.field references unknown field "${visible.field}". Known fields: ${known()}.`);
165
+ }
166
+ if (entityId !== undefined && entityId !== "id" && !fieldNames.has(entityId)) {
167
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
168
+ `entityId references unknown field "${entityId}". Known fields: ${known()}.`);
169
+ }
170
+ }
171
+ // Two features registering the same short screen-id is a silent routing
172
+ // footgun: create-app.tsx's runtime router resolves a bare navigate-target
173
+ // id by scanning features[] and taking the first match — the collision
174
+ // never surfaces as an error, the second feature's screen is just
175
+ // unreachable by that id (and always the same one that loses, in whatever
176
+ // order the app composed its features).
177
+ export function validateScreenShortIdCollisions(screensByShortId) {
178
+ for (const [shortId, entries] of screensByShortId) {
179
+ const featureNames = new Set(entries.map((e) => e.featureName));
180
+ if (featureNames.size > 1) {
181
+ throw new Error(`Screen short-id "${shortId}" is registered by ${featureNames.size} features ` +
182
+ `(${[...featureNames].join(", ")}) — the runtime router resolves a bare navigate-target ` +
183
+ `id by taking the first match, so all but one of these screens would be unreachable by ` +
184
+ `that id. Give each screen a distinct id.`);
185
+ }
186
+ }
187
+ }
188
+ // redirect/cancelTarget accept either a same-feature short id (unchanged
189
+ // behavior, qualified against the owning feature) or a fully-qualified
190
+ // cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
191
+ // short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
192
+ // the two forms don't collide.
193
+ export function resolveScreenTargetQn(featureName, target) {
194
+ return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
195
+ }
196
+ export function validateScreenNavTarget(featureName, screenId, screenKind, fieldName, value, allScreenQns, screens) {
197
+ const candidateQn = resolveScreenTargetQn(featureName, value);
198
+ if (!allScreenQns.has(candidateQn)) {
199
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${fieldName} "${value}" ` +
200
+ `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
201
+ `in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
202
+ }
203
+ }
204
+ // kind:"drawer" resolves same-feature only — unlike navigate/redirect,
205
+ // which the runtime router resolves app-wide (see the comment on
206
+ // validateScreens below), the drawer mounts the target inline using this
207
+ // feature's schema, so a cross-feature reference could never actually
208
+ // render. Two distinct failure messages: dangling reference vs. wrong
209
+ // screen type. Shared by ToolbarAction (fw#2225) and RowAction (fw#2710)
210
+ // drawer variants — same target-resolution rule, only the label in the
211
+ // error message ("toolbarAction" vs. "rowAction" vs. "action") differs.
212
+ export function validateDrawerTargetAction(featureName, screenId, screenKind, actionLabel, action, screens) {
213
+ const target = screens[action.screen];
214
+ if (target === undefined) {
215
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
216
+ `drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
217
+ `kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
218
+ `screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
219
+ `Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
220
+ }
221
+ // secretMint is intentionally excluded — a one-time secret reveal belongs on
222
+ // its own page, not layered in a Drawer above a list.
223
+ if (target.type !== "actionForm") {
224
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
225
+ `drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
226
+ `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
227
+ `actionForm screen, or use kind:"navigate" for a full-page target.`);
228
+ }
229
+ // The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
230
+ // when the target doesn't declare the field, marks it sensitive/password,
231
+ // or doesn't render it in its layout — each would only show up as an empty
232
+ // field at click time.
233
+ // skip: no params extractor — nothing to check against the target's fields.
234
+ if (action.params === undefined)
235
+ return;
236
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
237
+ }
238
+ function renderedEditFieldNames(sections) {
239
+ const names = new Set();
240
+ for (const section of sections) {
241
+ if (!isFieldsEditSection(section))
242
+ continue;
243
+ for (const spec of sectionFieldSpecs(section))
244
+ names.add(normalizeEditField(spec).field);
245
+ }
246
+ return names;
247
+ }
248
+ export function isSensitiveOrPasswordField(field) {
249
+ return (field !== undefined &&
250
+ (("sensitive" in field && field.sensitive === true) ||
251
+ ("writeOnly" in field && field.writeOnly === true) ||
252
+ ("format" in field && field.format === "password")));
253
+ }
254
+ export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
255
+ const renderedFieldNames = renderedEditFieldNames(target.layout.sections);
256
+ for (const fieldName of rowFieldExtractorKeys(params)) {
257
+ // A `load` form reads only the record id from these params, never a value.
258
+ if (fieldName === "id" && target.type === "actionForm" && target.load !== undefined)
259
+ continue;
260
+ if (!Object.hasOwn(target.fields, fieldName)) {
261
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
262
+ `field — the renderer would drop it and leave the form empty. Target fields: ` +
263
+ `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
264
+ }
265
+ if (isSensitiveOrPasswordField(target.fields[fieldName])) {
266
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
267
+ `sensitive or password — the renderer never prefills those fields and would leave the form empty. Remove that param or prefill a non-sensitive field.`);
268
+ }
269
+ if (!renderedFieldNames.has(fieldName)) {
270
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
271
+ `its layout — the renderer only prefills fields the layout shows and would drop it.`);
272
+ }
273
+ }
274
+ }
275
+ // The target can be any screen, so this checks the resolved target's
276
+ // sections, not the host's.
277
+ function validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target) {
278
+ // skip: no tab declared — nothing to check against the target's sections
279
+ if (action.tab === undefined)
280
+ return;
281
+ if (target === undefined) {
282
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
283
+ `sets tab "${action.tab}", but its navigate-target could not be resolved to a screen. Point the navigate-target at a registered screen.`);
284
+ }
285
+ if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
286
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
287
+ `sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
288
+ `projectionDetail with layout.mode "tabs" — only those read the tab param. Remove the tab or navigate to a projectionDetail screen with layout.mode "tabs".`);
289
+ }
290
+ const sectionIds = target.screen.layout.sections
291
+ .map((section) => section.id)
292
+ .filter((id) => id !== undefined);
293
+ if (!sectionIds.includes(action.tab)) {
294
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
295
+ `navigates to tab "${action.tab}", which is not a section id on target screen ` +
296
+ `"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`);
297
+ }
298
+ }
299
+ export function resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens) {
300
+ if (action.entity !== undefined) {
301
+ if (action.screen !== undefined) {
302
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
303
+ `sets both "screen" and "entity" — exactly one navigate-target form is allowed.`);
304
+ }
305
+ if (screenType !== "entityList" && action.entityId === undefined) {
306
+ // entityList rows are always a real entity record, so row["id"] is a
307
+ // safe implicit default. projectionList/projectionDetail rows come from
308
+ // an arbitrary query projection with no guaranteed "id" field — an
309
+ // entity-target there needs an explicit entityId, or navigation silently
310
+ // opens the detail screen with no entity context at runtime.
311
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
312
+ `navigate-target entity "${action.entity}" needs an explicit "entityId" — ${screenType} rows ` +
313
+ `come from a query projection with no guaranteed "id" field.`);
314
+ }
315
+ const detail = detailForScreens.get(action.entity);
316
+ if (detail === undefined) {
317
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
318
+ `navigate-target entity "${action.entity}" has no screen declaring ` +
319
+ `detailFor: "${action.entity}". Add detailFor: "${action.entity}" to a screen or navigate to a screen directly.`);
320
+ }
321
+ validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
322
+ return detail;
323
+ }
324
+ if (action.screen === undefined) {
325
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
326
+ `sets neither "screen" nor "entity" — exactly one navigate-target form is required.`);
327
+ }
328
+ const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
329
+ if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
330
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
331
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature. Use "<feature>:screen:<id>" of an existing screen.`);
332
+ }
333
+ const target = screensByShortId.get(action.screen)?.[0];
334
+ validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
335
+ return target;
336
+ }
337
+ // redirect is either a short screen id (same-feature, e.g. "item-list") or
338
+ // a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
339
+ // renderer strips the latter to the short id (lastSegment) when
340
+ // navigating, which the nav-router resolves app-wide (#1946). The object
341
+ // form (fw#2670) carries the same target under `screen` plus the payload
342
+ // field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
343
+ // and entityEdit — same rule, same error message, one place to keep them in sync.
344
+ export function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
345
+ // Property name used in error messages (`successLinks[0]` for a success link).
346
+ propertyPath = "redirect") {
347
+ const { allScreenQns } = lookups;
348
+ const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
349
+ if (typeof redirect !== "string" &&
350
+ (typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")) {
351
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is empty or not a string — ` +
352
+ `name the success-payload field carrying the navigation id, or use the plain string ` +
353
+ `redirect form to navigate with the handler's own "id".`);
354
+ }
355
+ // The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
356
+ // never matches and the navigation silently lands without an entityId.
357
+ if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
358
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom "${redirect.idFrom}" ` +
359
+ `has leading/trailing whitespace — it would never match a payload field. Remove the whitespace from idFrom.`);
360
+ }
361
+ validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, redirectTarget, allScreenQns, feature.screens);
362
+ // idFrom only means something to a target that carries a record id; the
363
+ // renderer drops it silently otherwise. Cross-feature QN targets can't be
364
+ // typed from here and stay unchecked.
365
+ const sameFeatureTarget = feature.screens[redirectTarget];
366
+ if (typeof redirect !== "string" &&
367
+ sameFeatureTarget !== undefined &&
368
+ sameFeatureTarget.type !== "entityEdit" &&
369
+ sameFeatureTarget.type !== "projectionDetail") {
370
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is set but target screen ` +
371
+ `"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
372
+ }
373
+ if (typeof redirect !== "string" && redirect.tab !== undefined) {
374
+ validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: redirectTarget, label: redirectTarget, tab: redirect.tab }, findNavTargetEntry(feature, redirectTarget, lookups));
375
+ }
376
+ }
377
+ function findNavTargetEntry(feature, target, lookups) {
378
+ const targetQn = resolveScreenTargetQn(feature.name, target);
379
+ return lookups.screensByShortId
380
+ .get(targetQn.slice(targetQn.lastIndexOf(":") + 1))
381
+ ?.find((entry) => qualifyEntityName(entry.featureName, "screen", entry.screen.id) === targetQn);
382
+ }
383
+ // A success link without idFrom navigates without an id, so its target must not
384
+ // be a record screen (entityEdit/projectionDetail need one).
385
+ export function validateIdlessSuccessLink(feature, screenId, screenKind, link, lookups, propertyPath) {
386
+ validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, link.screen, lookups.allScreenQns, feature.screens);
387
+ const target = findNavTargetEntry(feature, link.screen, lookups);
388
+ const targetType = target?.screen.type;
389
+ if (targetType === "entityEdit" || targetType === "projectionDetail") {
390
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath} targets "${link.screen}" ` +
391
+ `(${targetType}), a record screen — set ${propertyPath}.idFrom to the success-payload field carrying the record id.`);
392
+ }
393
+ if (link.tab !== undefined) {
394
+ validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: link.screen, label: link.screen, tab: link.tab }, target);
395
+ }
396
+ }
397
+ // A clientAction names a handler a client feature registers
398
+ // (ClientFeatureDefinition.clientActions). Nothing server-side can confirm the
399
+ // registration, so the shape is the only boot-time check.
400
+ export function validateClientActionName(featureName, screenId, screenKind, actionLabel, action) {
401
+ const segments = action.action.split(":");
402
+ const [scope, name] = segments;
403
+ const wellFormed = segments.length === 2 &&
404
+ scope !== undefined &&
405
+ name !== undefined &&
406
+ isKebabSegment(scope) &&
407
+ isKebabSegment(name);
408
+ // skip: well-formed name, nothing to reject
409
+ if (wellFormed)
410
+ return;
411
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
412
+ `action "${action.action}" is not a client action name — expected "<feature>:<kebab-name>", ` +
413
+ `e.g. "auth-email-password:request-password-reset".`);
414
+ }
415
+ // redirect is only honored on record actions; on list/related-list row
416
+ // actions it would be silently dropped, so the author would believe it works.
417
+ export function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
418
+ if (action.kind !== "navigate" &&
419
+ action.kind !== "drawer" &&
420
+ action.kind !== "clientAction" &&
421
+ action.redirect !== undefined) {
422
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
423
+ `redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
424
+ `actions) and is ignored on list row actions. Remove it.`);
425
+ }
426
+ }
427
+ // A writeHandler record action's redirect (projectionDetail/entityEdit
428
+ // header and section actions) follows the entityEdit.redirect rule.
429
+ export function validateRecordActionRedirect(feature, screenId, screenKind, action, lookups) {
430
+ if (action.kind !== "navigate" &&
431
+ action.kind !== "drawer" &&
432
+ action.kind !== "clientAction" &&
433
+ action.redirect !== undefined) {
434
+ validateRedirectTarget(feature, screenId, screenKind, action.redirect, lookups);
435
+ }
436
+ }
437
+ // "edit" is the default edit action, which only exists when detailFor names
438
+ // the entity; a typo would otherwise silently fall back to the default precedence.
439
+ export function validatePrimaryActionTarget(featureName, screenId, screen) {
440
+ // skip: no declared primaryAction, the default header precedence applies
441
+ if (screen.primaryAction === undefined)
442
+ return;
443
+ const declaredIds = (screen.actions ?? []).map((action) => action.id);
444
+ const editIsAvailable = screen.detailFor !== undefined;
445
+ const targetExists = declaredIds.includes(screen.primaryAction) ||
446
+ (editIsAvailable && screen.primaryAction === "edit");
447
+ if (!targetExists) {
448
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) primaryAction "${screen.primaryAction}" ` +
449
+ `matches no action — use one of the declared action ids (${declaredIds.join(", ") || "none declared"})` +
450
+ `${editIsAvailable ? ' or "edit"' : ' ("edit" needs detailFor on this screen)'}.`);
451
+ }
452
+ }
453
+ export function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap) {
454
+ const { visibleWhen, fallback } = screen;
455
+ const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
456
+ if (visibleWhen === undefined && fallback !== undefined) {
457
+ throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
458
+ }
459
+ if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
460
+ throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
461
+ }
462
+ if (fallback !== undefined) {
463
+ validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
464
+ }
465
+ }
466
+ function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
467
+ const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
468
+ validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
469
+ const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
470
+ if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
471
+ throw new Error(`${context} fallback points at the screen itself. Point fallback at a different screen.`);
472
+ }
473
+ // A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
474
+ // runtime gate trivially terminating.
475
+ const [fallbackFeatureName = "", , fallbackShortId = ""] = fallbackQn.split(":");
476
+ if (featureMap.get(fallbackFeatureName)?.screens[fallbackShortId]?.visibleWhen !== undefined) {
477
+ throw new Error(`${context} fallback "${fallback}" has its own visibleWhen; a fallback must always render.`);
478
+ }
479
+ }
480
+ // Only a navigate action that sets `tab` needs the target resolved (to check
481
+ // the tab id); plain navigates are validated elsewhere. One place for every
482
+ // section-level action site so a new site can't forget the tab check.
483
+ export function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
484
+ // skip: only navigate actions with an explicit tab need target validation
485
+ if (action.kind !== "navigate" || action.tab === undefined)
486
+ return;
487
+ resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
488
+ }
@@ -0,0 +1,15 @@
1
+ import type { FeatureDefinition } from "../types/index.ts";
2
+ import type { DashboardCustomPanel, DashboardScreenDefinition, DashboardScreenPanel } from "../types/screen.ts";
3
+ import { resolveRowActionNavigateTarget } from "./screens-actions.ts";
4
+ export declare function validateDashboardScreen(featureName: string, screenId: string, screen: DashboardScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
5
+ type DashboardActionNavLookups = {
6
+ readonly allScreenQns: Parameters<typeof resolveRowActionNavigateTarget>[5];
7
+ readonly navTargetShortIds: Parameters<typeof resolveRowActionNavigateTarget>[6];
8
+ readonly screensByShortId: Parameters<typeof resolveRowActionNavigateTarget>[7];
9
+ readonly detailForScreens: Parameters<typeof resolveRowActionNavigateTarget>[8];
10
+ };
11
+ export declare function validateDashboardActions(featureName: string, screenId: string, screen: DashboardScreenDefinition, allWriteHandlerQns: ReadonlySet<string>, lookups: DashboardActionNavLookups): void;
12
+ export declare function validateDashboardRecordBinding(featureName: string, screenId: string, screen: DashboardScreenDefinition): void;
13
+ export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
14
+ export declare function validateDashboardCustomPanel(featureName: string, screenId: string, panel: DashboardCustomPanel): void;
15
+ export {};