@cosmicdrift/kumiko-framework 0.383.1 → 0.385.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 (82) hide show
  1. package/agents/examples/custom-fields-basic.md +13 -3
  2. package/agents/examples/designer-demo.md +6 -2
  3. package/agents/examples/i18n.md +36 -1
  4. package/agents/features.md +4 -0
  5. package/dist/api/auth-rate-limiters.d.ts +8 -0
  6. package/dist/api/auth-rate-limiters.js +105 -0
  7. package/dist/api/auth-routes-context.d.ts +34 -0
  8. package/dist/api/auth-routes-context.js +192 -0
  9. package/dist/api/auth-routes-invite.d.ts +2 -0
  10. package/dist/api/auth-routes-invite.js +171 -0
  11. package/dist/api/auth-routes-login.d.ts +2 -0
  12. package/dist/api/auth-routes-login.js +103 -0
  13. package/dist/api/auth-routes-mfa.d.ts +4 -0
  14. package/dist/api/auth-routes-mfa.js +211 -0
  15. package/dist/api/auth-routes-session.d.ts +2 -0
  16. package/dist/api/auth-routes-session.js +144 -0
  17. package/dist/api/auth-routes-shared.d.ts +33 -0
  18. package/dist/api/auth-routes-shared.js +81 -0
  19. package/dist/api/auth-routes-signup.d.ts +2 -0
  20. package/dist/api/auth-routes-signup.js +75 -0
  21. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  22. package/dist/api/auth-routes-token-flows.js +91 -0
  23. package/dist/api/auth-routes-types.d.ts +76 -0
  24. package/dist/api/auth-routes-types.js +1 -0
  25. package/dist/api/auth-routes.d.ts +5 -85
  26. package/dist/api/auth-routes.js +20 -1095
  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/dialect.js +1 -1
  31. package/dist/db/distinct-on.d.ts +9 -0
  32. package/dist/db/distinct-on.js +28 -0
  33. package/dist/db/event-store-executor-write.js +2 -2
  34. package/dist/db/migrate-runner.js +4 -4
  35. package/dist/db/pg-error.js +0 -12
  36. package/dist/db/queries/event-consumer.js +3 -3
  37. package/dist/db/queries/postgres-array-literal.js +2 -2
  38. package/dist/db/query.js +48 -20
  39. package/dist/db/tenant-db.js +1 -1
  40. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
  41. package/dist/engine/boot-validator/i18n-keys.js +15 -1
  42. package/dist/engine/boot-validator/index.d.ts +4 -0
  43. package/dist/engine/boot-validator/index.js +3 -1
  44. package/dist/engine/boot-validator/pii-retention.js +6 -7
  45. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  46. package/dist/engine/boot-validator/screens-actions.js +488 -0
  47. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  48. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  49. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  50. package/dist/engine/boot-validator/screens-forms.js +516 -0
  51. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  52. package/dist/engine/boot-validator/screens-lists.js +319 -0
  53. package/dist/engine/boot-validator/screens.d.ts +4 -21
  54. package/dist/engine/boot-validator/screens.js +26 -1799
  55. package/dist/engine/build-app-schema.js +1 -1
  56. package/dist/engine/factories.d.ts +3 -3
  57. package/dist/engine/steps/read-find-one.js +1 -1
  58. package/dist/engine/types/index.d.ts +1 -1
  59. package/dist/event-store/provenance-append.js +1 -1
  60. package/dist/files/file-ref-entity.d.ts +6 -126
  61. package/dist/files/file-ref-table.d.ts +6 -126
  62. package/dist/files/file-routes.js +39 -16
  63. package/dist/i18n/required-surface-keys.d.ts +22 -2
  64. package/dist/i18n/required-surface-keys.js +91 -3
  65. package/dist/jobs/job-runner-helpers.d.ts +45 -0
  66. package/dist/jobs/job-runner-helpers.js +281 -0
  67. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  68. package/dist/jobs/job-runner-schedulers.js +106 -0
  69. package/dist/jobs/job-runner-types.d.ts +65 -0
  70. package/dist/jobs/job-runner-types.js +2 -0
  71. package/dist/jobs/job-runner.d.ts +4 -75
  72. package/dist/jobs/job-runner.js +7 -384
  73. package/dist/pipeline/dispatch-shared.js +2 -2
  74. package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
  75. package/dist/pipeline/event-dispatcher-admin.js +28 -7
  76. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  77. package/dist/pipeline/event-dispatcher.js +3 -4
  78. package/dist/pipeline/index.d.ts +2 -2
  79. package/dist/pipeline/index.js +1 -1
  80. package/dist/schema-cli.js +5 -0
  81. package/package.json +3 -3
  82. package/src/changes.json +39 -0
@@ -0,0 +1,516 @@
1
+ import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
2
+ import { isKebabSegment } from "../qualified-name.js";
3
+ import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, sectionFieldSpecs, } from "../screen-helpers.js";
4
+ import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
5
+ import { isSensitiveOrPasswordField, validateIdlessSuccessLink, validateRedirectTarget, validateScreenNavTarget, } from "./screens-actions.js";
6
+ // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
7
+ // entityEdit path (#1925) — a required field the user can never fill would
8
+ // block every save. Only the statically-resolvable case is caught here: a
9
+ // literal `required: true` (screen-spec override or entity-level default).
10
+ // A dynamic FieldCondition depends on runtime form values and can't be
11
+ // evaluated at boot; buildFormSchema() silently skips presence-checking it.
12
+ // `embeddedList` and `prefill` only make sense on field types that support them;
13
+ // a misplaced spec would otherwise be a silent no-op.
14
+ export function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
15
+ const fieldDef = entityDef.fields[fieldSpec.field];
16
+ // skip: the field name is not on the entity, other checks report unknown fields
17
+ if (fieldDef === undefined)
18
+ return;
19
+ const where = `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}"`;
20
+ const listSpec = fieldSpec.embeddedList;
21
+ if (listSpec !== undefined) {
22
+ if (fieldDef.type !== "embedded" || fieldDef.multiple !== true) {
23
+ throw new Error(`${where} sets embeddedList, which only applies to an embedded list ` +
24
+ `(createEmbeddedListField); the field is type "${fieldDef.type}". Remove embeddedList or point it at an embedded list field.`);
25
+ }
26
+ for (const cell of listSpec.readOnlyCells ?? []) {
27
+ if (fieldDef.schema[cell] === undefined) {
28
+ throw new Error(`${where} embeddedList.readOnlyCells names "${cell}", which is not a sub-field ` +
29
+ `(known: ${Object.keys(fieldDef.schema).sort().join(", ")}).`);
30
+ }
31
+ }
32
+ }
33
+ const prefill = fieldSpec.prefill;
34
+ // skip: no prefill declared
35
+ if (prefill === undefined)
36
+ return;
37
+ if (prefill.kind === "today") {
38
+ if (fieldDef.type !== "date") {
39
+ throw new Error(`${where} prefill "today" needs a date field; the field is type "${fieldDef.type}". Use a date field or a different prefill.`);
40
+ }
41
+ // skip: today carries no property, nothing further to check
42
+ return;
43
+ }
44
+ const allowsUserId = fieldDef.type === "text" || fieldDef.type === "reference";
45
+ if (prefill.property === "id" ? !allowsUserId : fieldDef.type !== "text") {
46
+ throw new Error(`${where} prefill currentUser.${prefill.property} does not fit a field of type ` +
47
+ `"${fieldDef.type}" (displayName and email need text; id needs text or a reference). Use a fitting field type or a different prefill property.`);
48
+ }
49
+ }
50
+ // `{ field }` affixes and fillAction params read live form values, so every
51
+ // name must be a field of the same form — a typo would otherwise render no
52
+ // affix or send an undefined param without a hint.
53
+ export function validateFieldFormRefs(where, fieldSpec, formFieldNames) {
54
+ const affixes = [
55
+ ["prefix", fieldSpec.prefix],
56
+ ["suffix", fieldSpec.suffix],
57
+ ];
58
+ for (const [prop, affix] of affixes) {
59
+ if (affix === undefined || typeof affix === "string")
60
+ continue;
61
+ if (!formFieldNames.has(affix.field)) {
62
+ throw new Error(`${where} ${prop} references unknown field "${affix.field}" — it must be another field of the same form.`);
63
+ }
64
+ }
65
+ const params = fieldSpec.fillAction?.params;
66
+ // skip: no fill-action params to cross-check
67
+ if (params === undefined)
68
+ return;
69
+ const sourceFields = "pick" in params ? params.pick : Object.values(params.map);
70
+ for (const sourceField of sourceFields) {
71
+ if (!formFieldNames.has(sourceField)) {
72
+ throw new Error(`${where} fillAction.params reads unknown field "${sourceField}" — it must be a field of the same form.`);
73
+ }
74
+ }
75
+ }
76
+ const FILL_ACTION_FIELD_TYPES = ["text", "number", "decimal", "bigInt"];
77
+ // The fill button writes a plain text or numeric query result into the field;
78
+ // a secret value must never be filled from a query response.
79
+ export function validateFillActionTarget(where, fieldSpec, fieldDef) {
80
+ // skip: no fill action, or the field is unknown (reported elsewhere)
81
+ if (fieldSpec.fillAction === undefined || fieldDef === undefined)
82
+ return;
83
+ if (!FILL_ACTION_FIELD_TYPES.includes(fieldDef.type)) {
84
+ throw new Error(`${where} fillAction needs a ${FILL_ACTION_FIELD_TYPES.join(", ")} field, not "${fieldDef.type}". ` +
85
+ `Remove fillAction or use a fitting field type.`);
86
+ }
87
+ if (isSensitiveOrPasswordField(fieldDef)) {
88
+ throw new Error(`${where} fillAction targets a sensitive, writeOnly or password field — a query result is ` +
89
+ `never written into those. Remove fillAction.`);
90
+ }
91
+ }
92
+ export function validateNoWidgetRequiredField(featureName, screenId, entityDef, fieldSpec) {
93
+ const fieldDef = entityDef.fields[fieldSpec.field];
94
+ // skip: field doesn't exist or its type already has a widget — nothing to validate.
95
+ if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type))
96
+ return;
97
+ // Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
98
+ // grid widget (#1838) — only plain (non-list) embedded has no widget.
99
+ const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
100
+ // skip: list variant has a widget — not the no-widget case this guard targets.
101
+ if (isEmbeddedList)
102
+ return;
103
+ // skip: already read-only by spec — no fillable widget needed regardless of type.
104
+ if (fieldSpec.readOnly === true)
105
+ return;
106
+ const entityRequired = "required" in fieldDef && fieldDef.required === true;
107
+ const isStaticallyRequired = fieldSpec.required === undefined ? entityRequired : fieldSpec.required === true;
108
+ // skip: not required — a read-only widget-less field is fine to leave empty.
109
+ if (!isStaticallyRequired)
110
+ return;
111
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}" is ` +
112
+ `type "${fieldDef.type}", which renders read-only on the auto-wired entityEdit path — a ` +
113
+ `required field the user could never fill would block every save. Set required: false, ` +
114
+ `move the field to a custom-component section, or drop the required constraint.`);
115
+ }
116
+ // Shared by projectionDetail and entityEdit: a tab strip needs something to
117
+ // label each tab with and a stable id per tab — the id anchors the ?tab=
118
+ // param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
119
+ // (fw#3134).
120
+ export function validateTabSections(featureName, screenId, screenType, sections) {
121
+ if (sections.length < 2) {
122
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
123
+ `${sections.length} section(s) — tabs need at least 2 sections.`);
124
+ }
125
+ const tabIds = new Set();
126
+ sections.forEach((section, index) => {
127
+ if (section.title === undefined || section.title.trim().length === 0) {
128
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
129
+ `sections[${index}] has no title — every tab needs a title.`);
130
+ }
131
+ if (section.id === undefined || section.id.trim().length === 0) {
132
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
133
+ `sections[${index}] ("${section.title}") has no id — every tab needs a stable id.`);
134
+ }
135
+ if (!isKebabSegment(section.id)) {
136
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sections[${index}] ` +
137
+ `("${section.title}") has id "${section.id}" — must be kebab-case.`);
138
+ }
139
+ if (tabIds.has(section.id)) {
140
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has duplicate tab id ` +
141
+ `"${section.id}" (sections[${index}]). Give each tab section a unique id.`);
142
+ }
143
+ tabIds.add(section.id);
144
+ });
145
+ }
146
+ // Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
147
+ // untitled step would leave the progress indicator blank, so both fail at
148
+ // boot rather than as a broken step UI.
149
+ export function validateWizardLayout(featureName, screenId, screenType, layout, featureMap) {
150
+ // entityEdit renders tabs like wizard steps: every section stays mounted,
151
+ // submit validates across all of them, and a field error jumps to the tab
152
+ // holding it (fw#3134). The other three have no such jump — actionForm and
153
+ // secretMint are one-shot forms, configEdit fires a write per field — so a
154
+ // required field on a hidden tab would still block their submit silently.
155
+ if (layout.mode === "tabs") {
156
+ if (screenType !== "entityEdit") {
157
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
158
+ `supported on projectionDetail and entityEdit. Use mode: "wizard" or "single" instead.`);
159
+ }
160
+ validateTabSections(featureName, screenId, screenType, layout.sections);
161
+ }
162
+ // "form-draft" is hardcoded because the framework layer must not depend on
163
+ // @cosmicdrift/kumiko-bundled-features — same precedence as the
164
+ // "user-data-rights" check in gdpr-storage.ts.
165
+ if (layout.draft === true) {
166
+ if (layout.mode !== "wizard") {
167
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but ` +
168
+ `mode is not "wizard" — draft persistence only applies to wizard layouts. Remove ` +
169
+ `draft: true or set mode: "wizard".`);
170
+ }
171
+ if (!featureMap.has("form-draft")) {
172
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
173
+ `bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
174
+ `values. Add createFormDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
175
+ `feature list.`);
176
+ }
177
+ }
178
+ // skip: mode omitted/"single" — no wizard constraints apply.
179
+ if (layout.mode !== "wizard")
180
+ return;
181
+ if (layout.sections.length < 2) {
182
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but only ` +
183
+ `${layout.sections.length} section(s) — a wizard needs at least 2 sections (one per step).`);
184
+ }
185
+ layout.sections.forEach((section, index) => {
186
+ if (section.title === undefined || section.title.trim().length === 0) {
187
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but ` +
188
+ `sections[${index}] has no title — every wizard step needs a title.`);
189
+ }
190
+ });
191
+ }
192
+ // --- Screen validation ---
193
+ //
194
+ // For every r.screen() declaration check what's locally knowable at boot:
195
+ // - entityList / entityEdit: the referenced entity must exist in the
196
+ // feature (cross-feature entity-refs aren't allowed — a feature owns
197
+ // the screens over its own entities) and every column/field ref must
198
+ // name a real field on that entity
199
+ // - custom: the renderer must at least have one platform component set
200
+ // (react OR native), otherwise the screen is structurally empty
201
+ //
202
+ // Field-level renderer QN strings (cross-feature `component:` references)
203
+ // are NOT validated here — the r.uiComponent registry that would resolve
204
+ // them ships in M4/M5. Until then those are kept opaque on purpose.
205
+ // Shared by the mint step's own handler (screen.handler) and its optional
206
+ // confirm step (confirm.handler) — both are a bare write-handler QN with the
207
+ // same registration requirement.
208
+ function validateWriteHandlerRegistered(featureName, screenId, context, handler, allWriteHandlerQns) {
209
+ if (!handler || typeof handler !== "string") {
210
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler. Set handler to a non-empty handler name.`);
211
+ }
212
+ if (!allWriteHandlerQns.has(handler)) {
213
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
214
+ `is not a registered write-handler. Check the QN spelling (expected ` +
215
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
216
+ }
217
+ }
218
+ // Every field entry must carry a `type` discriminator. An author typo
219
+ // (`title: { required: true }` without a type) would otherwise let
220
+ // RenderField silently fall through to the default renderer and submit an
221
+ // empty string — failing at boot is clearer. `type as unknown` because
222
+ // FieldDefinition, as a union, only allows known type strings; here we're
223
+ // checking author code that may have circumvented the type check.
224
+ //
225
+ // Parametrized over (fields, layout) instead of a whole screen so a
226
+ // secretMint's own `confirm` step — a second, independent inline form on the
227
+ // same screen — can reuse the same checks as its `fields`/`layout` pair.
228
+ // `allowEmpty` is only ever true for a secretMint's own mint step (an
229
+ // input-less mint, fw#2838): the secret is server-generated and the mint
230
+ // step is just its submit button — actionForm and the confirm step always
231
+ // require at least one field.
232
+ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpty) {
233
+ const fieldNames = new Set(Object.keys(fields));
234
+ if (fieldNames.size === 0 && !allowEmpty) {
235
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty fields map — ` +
236
+ `declare at least one field.`);
237
+ }
238
+ for (const [fname, fdef] of Object.entries(fields)) {
239
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
240
+ const ftype = fdef.type;
241
+ if (typeof ftype !== "string" || ftype.length === 0) {
242
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fname}" has no ` +
243
+ `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
244
+ }
245
+ if (ftype === "money") {
246
+ validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
247
+ }
248
+ validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
249
+ rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
250
+ }
251
+ return fieldNames;
252
+ }
253
+ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
254
+ for (const ref of optionsQueryFieldRefs(payload)) {
255
+ if (ref === fieldName) {
256
+ throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" }). Reference a different field of the form.`);
257
+ }
258
+ if (!siblingFieldNames.has(ref)) {
259
+ throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
260
+ }
261
+ }
262
+ }
263
+ export function isWriteOnlyField(fdef) {
264
+ return fdef?.writeOnly === true; // @cast-boundary schema-walk
265
+ }
266
+ // A secret on a configEdit form keeps its value out of the client like an encrypted
267
+ // config key does, and the secrets store only exists per tenant.
268
+ export function validateConfigEditSecretField(featureName, screen, fieldName, secretQn, featureMap) {
269
+ const where = `[Feature ${featureName}] Screen "${screen.id}" (configEdit) field "${fieldName}"`;
270
+ if (screen.configKeys[fieldName] !== undefined) {
271
+ throw new Error(`${where} is mapped in both configKeys and secretKeys — keep one.`);
272
+ }
273
+ if (screen.scope !== "tenant") {
274
+ throw new Error(`${where} maps secret "${secretQn}" on a ${screen.scope}-scope screen — secrets are tenant-scoped.`);
275
+ }
276
+ const fdef = screen.fields[fieldName];
277
+ if (fdef?.type !== "text" || !isWriteOnlyField(fdef)) {
278
+ throw new Error(`${where} maps secret "${secretQn}" but is no writeOnly text field — a stored secret never goes back to the client.`);
279
+ }
280
+ const declared = [...featureMap.values()].some((feature) => Object.values(feature.secretKeys ?? {}).some((def) => def.qualifiedName === secretQn));
281
+ if (!declared) {
282
+ throw new Error(`${where} → secret "${secretQn}" is not declared via r.secret() in any feature.`);
283
+ }
284
+ }
285
+ // Inline forms have no stored value to hide or keep: writeOnly there would
286
+ // render a "set / keep" affordance with nothing behind it.
287
+ export function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
288
+ if (isWriteOnlyField(fdef)) {
289
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
290
+ `writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
291
+ `Use format: "password" for a masked input.`);
292
+ }
293
+ }
294
+ // Static options XOR optionsQuery on inline-form select fields, and `{ field }`
295
+ // payload refs must name another field of the same form (the payload also feeds
296
+ // optionsAvailabilityQuery). The QN existence check
297
+ // lives in query-refs.ts with the other query refs.
298
+ const CONDITIONAL_OPTIONS_ENTITY_ONLY = "conditionalOptions is only supported on entity select fields (enforced by the entity write path)";
299
+ export function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
300
+ if (fdef.type === "select") {
301
+ const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
302
+ if (fdef.conditionalOptions !== undefined) {
303
+ throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}. Remove conditionalOptions from this field.`);
304
+ }
305
+ if (fdef.optionsQuery !== undefined) {
306
+ if (fdef.optionsQuery.length === 0) {
307
+ throw new Error(`${where} has an empty optionsQuery. Set it to a query name or remove it.`);
308
+ }
309
+ if (fdef.options.length > 0) {
310
+ throw new Error(`${where} declares both options and optionsQuery — pick one`);
311
+ }
312
+ if (fdef.optionsAvailabilityQuery !== undefined) {
313
+ throw new Error(`${where} declares both optionsQuery and optionsAvailabilityQuery — availability only applies to static options`);
314
+ }
315
+ validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
316
+ }
317
+ else if (fdef.optionsAvailabilityQuery !== undefined) {
318
+ if (fdef.optionsAvailabilityQuery.length === 0) {
319
+ throw new Error(`${where} has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
320
+ }
321
+ validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
322
+ }
323
+ else if (fdef.optionsQueryPayload !== undefined) {
324
+ throw new Error(`${where} has optionsQueryPayload without optionsQuery. Set optionsQuery or remove optionsQueryPayload.`);
325
+ }
326
+ }
327
+ else if (fdef.type === "multiSelect" && fdef.optionsAvailabilityQuery !== undefined) {
328
+ if (fdef.optionsAvailabilityQuery.length === 0) {
329
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) multiSelect field "${fieldName}" has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
330
+ }
331
+ }
332
+ }
333
+ // Fail-closed currency-source gate (fw#2839), the runtime half of the
334
+ // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
335
+ // stop it. An inline form screen has no entity, so a money field there can't
336
+ // borrow `entity.defaultCurrency`: without a declared source the renderer
337
+ // seeds a bare `0` and the handler's zod schema rejects the submit. Entity
338
+ // money fields are exempt — create-app already refuses an entity that holds
339
+ // money without a `defaultCurrency`.
340
+ function validateFormMoneyCurrency(featureName, screenId, context, fieldName, fdef) {
341
+ const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
342
+ const validForms = `Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
343
+ `\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
344
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
345
+ const currency = fdef.currency;
346
+ if (currency === undefined) {
347
+ throw new Error(`${where} must declare where its currency comes from — this screen has no entity whose ` +
348
+ `\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
349
+ `handler's schema rejects. ${validForms}`);
350
+ }
351
+ if (typeof currency !== "object" || currency === null) {
352
+ throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
353
+ }
354
+ const kind = currency.kind;
355
+ if (kind === "literal") {
356
+ const code = currency.code;
357
+ if (typeof code !== "string" || code.trim() === "") {
358
+ throw new Error(`${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
359
+ `Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`);
360
+ }
361
+ }
362
+ else if (kind !== "tenant") {
363
+ throw new Error(`${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`);
364
+ }
365
+ }
366
+ // `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
367
+ // caller only ever passes true together with an actually-empty fields map
368
+ // (an input-less secretMint declares BOTH `fields: {}` and
369
+ // `layout.sections: []`; fields declared with no layout to show them stays
370
+ // an error).
371
+ // `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
372
+ // every EditLayout-walking screen type instead of four hand-rolled copies.
373
+ export function validateFieldsXorGroups(errorPrefix, section) {
374
+ if (section.fields !== undefined && section.groups !== undefined) {
375
+ throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — remove ` +
376
+ `\`fields: []\` when using groups.`);
377
+ }
378
+ if ((section.fields?.length ?? 0) === 0 && (section.groups?.length ?? 0) === 0) {
379
+ throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
380
+ `fields (or groups) to it.`);
381
+ }
382
+ }
383
+ function validateFormLayoutSections(featureName, screenId, context, layout, fieldNames, fieldDefs, allowEmptySections) {
384
+ if (layout.sections.length === 0) {
385
+ // skip: an input-less secretMint declares fields: {} and sections: [] together — no sections to validate.
386
+ if (allowEmptySections)
387
+ return;
388
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has an empty sections list — ` +
389
+ `declare at least one section.`);
390
+ }
391
+ for (const section of layout.sections) {
392
+ if (isExtensionEditSection(section)) {
393
+ if (section.component?.react === undefined && section.component?.native === undefined) {
394
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) extension section ` +
395
+ `"${section.title}" has no component — declare a react/native component marker.`);
396
+ }
397
+ continue;
398
+ }
399
+ if (section.kind === "relatedList") {
400
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) relatedList section ` +
401
+ `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
402
+ `primitive (fw#2166). Move the section to a projectionDetail screen or remove it.`);
403
+ }
404
+ if (isWriteFormEditSection(section)) {
405
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) writeForm section ` +
406
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive. Move the section to a projectionDetail screen or remove it.`);
407
+ }
408
+ validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
409
+ for (const fieldSpec of sectionFieldSpecs(section)) {
410
+ const normalized = normalizeEditField(fieldSpec);
411
+ if (!fieldNames.has(normalized.field)) {
412
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) layout references unknown field ` +
413
+ `"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`);
414
+ }
415
+ const fieldWhere = `[Feature ${featureName}] Screen "${screenId}" (${context}) field "${normalized.field}"`;
416
+ validateFieldFormRefs(fieldWhere, normalized, fieldNames);
417
+ validateFillActionTarget(fieldWhere, normalized, fieldDefs[normalized.field]);
418
+ }
419
+ }
420
+ }
421
+ function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups) {
422
+ const { allScreenQns } = lookups;
423
+ if (screen.redirect !== undefined) {
424
+ validateRedirectTarget(feature, screenId, kind, screen.redirect, lookups);
425
+ }
426
+ if (screen.type === "actionForm" &&
427
+ screen.successFacts !== undefined &&
428
+ screen.redirect !== undefined) {
429
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successFacts — ` +
430
+ `redirect leaves the screen, successFacts are shown on it. Use one of them.`);
431
+ }
432
+ if (screen.type === "actionForm" && screen.successLinks !== undefined) {
433
+ if (screen.redirect !== undefined) {
434
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successLinks — ` +
435
+ `redirect leaves the screen, successLinks keep it open. Use one of them.`);
436
+ }
437
+ screen.successLinks.forEach((link, index) => {
438
+ const propertyPath = `successLinks[${index}]`;
439
+ if (link.idFrom === undefined) {
440
+ validateIdlessSuccessLink(feature, screenId, "actionForm", link, lookups, propertyPath);
441
+ }
442
+ else {
443
+ validateRedirectTarget(feature, screenId, kind, { ...link, idFrom: link.idFrom }, lookups, propertyPath);
444
+ }
445
+ });
446
+ }
447
+ if (typeof screen.cancelTarget === "string") {
448
+ // Same rule as redirect — `false` (no Cancel button) needs no validation.
449
+ validateScreenNavTarget(feature.name, screenId, kind, "cancelTarget", screen.cancelTarget, allScreenQns, feature.screens);
450
+ }
451
+ }
452
+ // actionForm/secretMint have no entity link, only a write-handler QN +
453
+ // inline fields: checks handler registration, field types, layout refs, and nav targets.
454
+ export function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandlerQns, lookups, featureMap) {
455
+ validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
456
+ // Only a secretMint's own mint step may skip fields/sections entirely
457
+ // (input-less mint, fw#2838) — actionForm always needs at least one field.
458
+ const allowEmptyMintForm = kind === "secretMint";
459
+ const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
460
+ validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
461
+ validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
462
+ if (screen.type === "actionForm") {
463
+ validateFormDerivations(feature.name, screenId, screen);
464
+ validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
465
+ }
466
+ if (screen.type === "actionForm" &&
467
+ screen.load?.singleton === true &&
468
+ screen.load.idParam !== undefined) {
469
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both load.singleton: true ` +
470
+ `and load.idParam — singleton means the query picks the row from the caller's context, so ` +
471
+ `there is no record id to send under idParam. Remove idParam.`);
472
+ }
473
+ validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
474
+ }
475
+ // SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
476
+ // on the reveal card in place of the bare acknowledge button (TOTP enrollment:
477
+ // scan the code, then enter one). Unlike the mint step, confirm always needs
478
+ // at least one field — a step whose only job is proving receipt with no input
479
+ // would be meaningless — and its layout is a single plain form, never a
480
+ // wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
481
+ // not a multi-step flow, and persisting a draft of it would re-open the leak
482
+ // fw#2548 closed (a lingering copy of the just-revealed secret in storage).
483
+ export function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns) {
484
+ const confirm = screen.confirm;
485
+ // skip: the screen declares no confirm step — nothing to validate.
486
+ if (confirm === undefined)
487
+ return;
488
+ const context = "secretMint confirm";
489
+ validateWriteHandlerRegistered(feature.name, screenId, context, confirm.handler, allWriteHandlerQns);
490
+ const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
491
+ validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, confirm.fields, false);
492
+ if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
493
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
494
+ `"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
495
+ `Remove mode or set it to "single".`);
496
+ }
497
+ if (confirm.layout.draft === true) {
498
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.draft: true — ` +
499
+ `persisting a draft of the reveal-confirm step would leak the one-time secret into storage, ` +
500
+ `the exact leak fw#2548 closed. Remove draft: true.`);
501
+ }
502
+ // skip: no carry list declared — no carried fields to cross-check against confirm.fields.
503
+ if (confirm.carry === undefined)
504
+ return;
505
+ for (const carryField of confirm.carry) {
506
+ if (typeof carryField !== "string" || carryField.trim() === "") {
507
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) has an empty or non-string ` +
508
+ `entry in "carry". Use non-empty strings in carry.`);
509
+ }
510
+ if (fieldNames.has(carryField)) {
511
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) carry field "${carryField}" ` +
512
+ `also names a confirm.fields entry — a carried mint-payload value would silently ` +
513
+ `overwrite the user-entered field of the same name. Rename one of them.`);
514
+ }
515
+ }
516
+ }
@@ -0,0 +1,47 @@
1
+ import type { FeatureDefinition } from "../types/index.ts";
2
+ import type { EditRelatedListSection, ListColumnSpec, ListFacetSpec, RowAction, ScreenDefinition } from "../types/screen.ts";
3
+ import type { NavigateSourceScreenType, NavigateTargetLookups } from "./screens-actions.ts";
4
+ export declare function validateExplicitListScreenExists(featureName: string, screenId: string, screen: ScreenDefinition, screensByShortId: ReadonlyMap<string, unknown>): void;
5
+ export declare function validateScreenHasNavArea(feature: FeatureDefinition, screenId: string, screen: ScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
6
+ type RelatedListSectionValidation = {
7
+ readonly featureName: string;
8
+ readonly featureMap: ReadonlyMap<string, FeatureDefinition>;
9
+ readonly screenId: string;
10
+ readonly screenType: NavigateSourceScreenType;
11
+ readonly where: string;
12
+ readonly section: EditRelatedListSection;
13
+ readonly screens: FeatureDefinition["screens"];
14
+ readonly allWriteHandlerQns: ReadonlySet<string>;
15
+ readonly allScreenQns: ReadonlySet<string>;
16
+ readonly navTargetShortIds: ReadonlySet<string>;
17
+ readonly screensByShortId: ReadonlyMap<string, ReadonlyArray<{
18
+ readonly featureName: string;
19
+ readonly screen: ScreenDefinition;
20
+ }>>;
21
+ readonly detailForScreens: ReadonlyMap<string, {
22
+ readonly featureName: string;
23
+ readonly screen: ScreenDefinition;
24
+ }>;
25
+ };
26
+ export declare function validateRelatedListSection(args: RelatedListSectionValidation): void;
27
+ type ListActionTargetContext = NavigateTargetLookups & {
28
+ readonly featureName: string;
29
+ readonly screenId: string;
30
+ readonly screenType: NavigateSourceScreenType;
31
+ readonly screenEntity: string | undefined;
32
+ readonly where: string;
33
+ readonly screens: FeatureDefinition["screens"];
34
+ readonly allWriteHandlerQns: ReadonlySet<string>;
35
+ };
36
+ export declare function validateListActionTargets(ctx: ListActionTargetContext, actionLabel: "rowAction" | "emptyState action", action: RowAction): void;
37
+ export declare function rejectInertColumnSortable(featureName: string, screenId: string, screenType: "entityList" | "projectionList", column: {
38
+ readonly field: string;
39
+ readonly sortable?: boolean;
40
+ }): void;
41
+ export declare function validateColumnRendererForm(featureName: string, screenId: string, column: {
42
+ readonly field: string;
43
+ readonly renderer?: unknown;
44
+ }): void;
45
+ export declare function assertRefTargetRegistered(prefix: string, subject: string, refTarget: string, currentFeatureName: string, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
46
+ export declare function validateListFacets(prefix: string, facets: readonly ListFacetSpec[], columns: readonly ListColumnSpec[], currentFeatureName: string, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
47
+ export {};