@cosmicdrift/kumiko-framework 0.332.0 → 0.334.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 (38) hide show
  1. package/dist/db/eagerload.js +2 -1
  2. package/dist/engine/boot-validator/config-deps.js +13 -0
  3. package/dist/engine/boot-validator/pii-retention.js +39 -2
  4. package/dist/engine/boot-validator/screens.js +38 -6
  5. package/dist/engine/build-app-schema.js +3 -0
  6. package/dist/engine/build-config-feature-schema.js +98 -21
  7. package/dist/engine/config-helpers.d.ts +2 -2
  8. package/dist/engine/entity-handlers.js +7 -2
  9. package/dist/engine/extension-names.d.ts +12 -1
  10. package/dist/engine/extension-names.js +7 -0
  11. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  12. package/dist/engine/feature-ast/extractors/index.js +1 -1
  13. package/dist/engine/feature-ast/extractors/round5.d.ts +1 -0
  14. package/dist/engine/feature-ast/extractors/round5.js +5 -0
  15. package/dist/engine/feature-ast/parse.js +3 -1
  16. package/dist/engine/feature-entity-handlers.js +22 -0
  17. package/dist/engine/field-access.d.ts +2 -0
  18. package/dist/engine/field-access.js +40 -0
  19. package/dist/engine/index.d.ts +4 -3
  20. package/dist/engine/index.js +3 -3
  21. package/dist/engine/role-assignment.d.ts +11 -1
  22. package/dist/engine/role-assignment.js +64 -3
  23. package/dist/engine/schema-builder.d.ts +1 -0
  24. package/dist/engine/schema-builder.js +11 -1
  25. package/dist/engine/types/index.d.ts +1 -1
  26. package/dist/files/file-ref-entity.d.ts +6 -0
  27. package/dist/files/file-ref-table.d.ts +6 -0
  28. package/dist/i18n/required-surface-keys.js +24 -3
  29. package/dist/i18n/settings-hub-keys.js +18 -4
  30. package/dist/pipeline/dispatch-batch.js +8 -3
  31. package/dist/pipeline/dispatch-write.js +5 -2
  32. package/dist/pipeline/dispatcher-utils.js +4 -1
  33. package/dist/pipeline/write-result-masking.d.ts +6 -0
  34. package/dist/pipeline/write-result-masking.js +36 -0
  35. package/dist/testing/shared-entities.d.ts +10 -0
  36. package/dist/ui-types/index.d.ts +2 -2
  37. package/package.json +4 -4
  38. package/src/changes.json +12 -0
@@ -25,6 +25,7 @@
25
25
  import { requestContext } from "../api/request-context.js";
26
26
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
27
27
  import { selectMany } from "../db/query.js";
28
+ import { maskWriteOnlyFields } from "../engine/field-access.js";
28
29
  import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
29
30
  import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
30
31
  import { buildEntityTable } from "./table-builder.js";
@@ -114,7 +115,7 @@ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, piiFields
114
115
  }
115
116
  const id = decrypted["id"];
116
117
  if (typeof id === "string")
117
- map.set(id, decrypted);
118
+ map.set(id, maskWriteOnlyFields(refEntity, decrypted));
118
119
  }
119
120
  return map;
120
121
  }
@@ -1,3 +1,4 @@
1
+ import { optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
1
2
  import { isEncryptedAtRest } from "../config-helpers.js";
2
3
  // --- Toggleable-dependency warnings ---
3
4
  //
@@ -123,6 +124,18 @@ export function validateConfigKeyOptionsQuery(feature) {
123
124
  if (keyDef.allowPerRequest) {
124
125
  throw new Error(`${prefix} has optionsQuery AND allowPerRequest=true — a per-request value would bypass the option set unchecked`);
125
126
  }
127
+ validateOptionsQueryKeyRefs(feature, keyName, keyDef);
128
+ }
129
+ }
130
+ function validateOptionsQueryKeyRefs(feature, keyName, keyDef) {
131
+ const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
132
+ for (const ref of optionsQueryFieldRefs(keyDef.optionsQueryPayload)) {
133
+ if (ref === keyName) {
134
+ throw new Error(`${prefix} optionsQueryPayload references itself ({ field: "${ref}" })`);
135
+ }
136
+ if (feature.configKeys[ref] === undefined) {
137
+ throw new Error(`${prefix} optionsQueryPayload references unknown config key "${ref}" of feature "${feature.name}"`);
138
+ }
126
139
  }
127
140
  }
128
141
  // --- Config key storage backing × scope matrix ---
@@ -26,6 +26,42 @@ function ownerFieldNamesReferencedByPersonalOf(fieldsByName) {
26
26
  }
27
27
  return ownerFieldNames;
28
28
  }
29
+ const WRITE_ONLY_FORBIDDEN_FLAGS = [
30
+ "default",
31
+ "searchable",
32
+ "sortable",
33
+ "filterable",
34
+ "lookupable",
35
+ ];
36
+ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
37
+ const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
38
+ if (field.type === "embedded") {
39
+ for (const [subName, subField] of Object.entries(field.schema)) {
40
+ if (subField.writeOnly === true) {
41
+ // @cast-boundary schema-walk
42
+ throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields.`);
43
+ }
44
+ }
45
+ // skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
46
+ return;
47
+ }
48
+ const flags = field; // @cast-boundary schema-walk
49
+ // skip: only writeOnly fields have constraints to validate
50
+ if (flags.writeOnly !== true)
51
+ return;
52
+ if (field.type !== "text") {
53
+ throw new Error(`${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields.`);
54
+ }
55
+ if (field.sensitive !== true) {
56
+ throw new Error(`${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`);
57
+ }
58
+ for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
59
+ const value = flags[flag];
60
+ if (value !== undefined && value !== false) {
61
+ throw new Error(`${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up.`);
62
+ }
63
+ }
64
+ }
29
65
  // --- PII / Subject-Key Annotations + Retention validation ---
30
66
  //
31
67
  // Drei Klassen von Checks:
@@ -92,19 +128,20 @@ export function validatePiiAndRetention(feature) {
92
128
  sensitiveFlags.encrypted !== true) {
93
129
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`);
94
130
  }
131
+ validateWriteOnlyField(feature.name, entityName, fieldName, field);
95
132
  // Sorting reads the projection column — that stays ciphertext, so
96
133
  // sortable + subject annotation stays a boot-fail. searchable has
97
134
  // been allowed since #1610: the search consumer decrypts into the
98
135
  // derived index and forget purges those docs (see
99
136
  // createSearchEventConsumer). sensitive + searchable stays forbidden
100
- // (nobody-may-read-back).
137
+ // (never searchable).
101
138
  {
102
139
  const flags = field; // @cast-boundary schema-walk
103
140
  if (annotCount > 0 && flags.sortable === true) {
104
141
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`);
105
142
  }
106
143
  if (flags.sensitive === true && flags.searchable === true) {
107
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means nobody may read the value back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not.`);
144
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
108
145
  }
109
146
  }
110
147
  if (annot.userOwned) {
@@ -4,7 +4,7 @@
4
4
  // into validateColumnRendererForm, splitting them would create a
5
5
  // same-folder require cycle.
6
6
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
- import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
7
+ import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
9
  import { rowMetaFieldNames } from "../../db/table-builder.js";
10
10
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
@@ -471,13 +471,35 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
471
471
  if (ftype === "money") {
472
472
  validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
473
473
  }
474
- validateFormSelectOptions(featureName, screenId, context, fname, fdef);
474
+ validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
475
+ rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
475
476
  }
476
477
  return fieldNames;
477
478
  }
478
- // Static options XOR optionsQuery on inline-form select fields. The QN
479
- // existence check lives in query-refs.ts with the other query refs.
480
- function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef) {
479
+ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
480
+ for (const ref of optionsQueryFieldRefs(payload)) {
481
+ if (ref === fieldName) {
482
+ throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" })`);
483
+ }
484
+ if (!siblingFieldNames.has(ref)) {
485
+ throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
486
+ }
487
+ }
488
+ }
489
+ // Inline forms have no stored value to hide or keep: writeOnly there would
490
+ // render a "set / keep" affordance with nothing behind it.
491
+ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
492
+ if (fdef.writeOnly === true) {
493
+ // @cast-boundary schema-walk
494
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
495
+ `writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
496
+ `Use format: "password" for a masked input.`);
497
+ }
498
+ }
499
+ // Static options XOR optionsQuery on inline-form select fields, and `{ field }`
500
+ // payload refs must name another field of the same form. The QN existence check
501
+ // lives in query-refs.ts with the other query refs.
502
+ function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
481
503
  if (fdef.type === "select") {
482
504
  const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
483
505
  if (fdef.optionsQuery !== undefined) {
@@ -487,6 +509,7 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
487
509
  if (fdef.options.length > 0) {
488
510
  throw new Error(`${where} declares both options and optionsQuery — pick one`);
489
511
  }
512
+ validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
490
513
  }
491
514
  else if (fdef.optionsQueryPayload !== undefined) {
492
515
  throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
@@ -1048,6 +1071,14 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1048
1071
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
1049
1072
  `(writeForm) has zero fields — drop the section or add fields to it.`);
1050
1073
  }
1074
+ // A ref to a fieldDef the form never renders would never resolve.
1075
+ const writeFormFieldNames = new Set(
1076
+ // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1077
+ section.fields.map((f) => normalizeEditField(f).field));
1078
+ for (const [defName, fdef] of Object.entries(section.fieldDefs)) {
1079
+ validateFormSelectOptions(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef, writeFormFieldNames);
1080
+ rejectWriteOnlyFormField(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef);
1081
+ }
1051
1082
  // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1052
1083
  for (const f of section.fields) {
1053
1084
  const fieldName = normalizeEditField(f).field;
@@ -1128,7 +1159,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1128
1159
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
1129
1160
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1130
1161
  }
1131
- validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
1162
+ validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
1163
+ rejectWriteOnlyFormField(feature.name, screenId, "configEdit", fname, fdef);
1132
1164
  }
1133
1165
  if (screen.layout.sections.length === 0) {
1134
1166
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -439,6 +439,9 @@ function projectField(fieldDef) {
439
439
  // refuse any prefill (URL or handoff) into the field.
440
440
  if (def["sensitive"] === true)
441
441
  out["sensitive"] = true;
442
+ // Without it the renderer shows a plain text input for a field whose value never leaves the server.
443
+ if (def["writeOnly"] === true)
444
+ out["writeOnly"] = true;
442
445
  // timestamp: the edit view-model derives its wall-clock input mode from this.
443
446
  if (typeof def["locatedBy"] === "string")
444
447
  out["locatedBy"] = def["locatedBy"];
@@ -14,6 +14,8 @@
14
14
  // des config-Features (featureName "config") gemerged — der Renderer
15
15
  // qualifiziert die kurzen ids/refs mit "config". Daher hier durchweg KURZE
16
16
  // ids/parent/screen-Refs (buildNavRegistrySliceForApp qualifiziert selbst).
17
+ import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
18
+ import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
17
19
  import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
18
20
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
19
21
  import { isKebabSegment, toKebab } from "./qualified-name.js";
@@ -35,6 +37,14 @@ const SCOPE_ICON = {
35
37
  user: "user",
36
38
  };
37
39
  const SCOPES_BROAD_TO_DEEP = ["system", "tenant", "user"];
40
+ const audienceDescriptionKey = (scope) => `config.settings.audience.${scope}`;
41
+ const PROVIDER_PANEL_TITLE_ENTRY = {
42
+ en: "Provider",
43
+ de: "Anbieter",
44
+ es: "Proveedor",
45
+ };
46
+ const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
47
+ const SAVE_PROVIDER_LABEL_KEY = "config.settings.saveProvider";
38
48
  const audienceNavShortId = (scope) => `audience-${scope}`;
39
49
  // Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
40
50
  export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
@@ -212,7 +222,11 @@ function assertUniquePanelIds(ownerGroup, panels) {
212
222
  function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
213
223
  const selectionScreenId = `${ownerGroup}-tenant-selection`;
214
224
  const selectionScreen = {
215
- ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub),
225
+ ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
226
+ description: EXTENSION_SELECTOR_HINT_KEY,
227
+ sectionTitle: PROVIDER_SECTION_TITLE_KEY,
228
+ submitLabel: SAVE_PROVIDER_LABEL_KEY,
229
+ }),
216
230
  dormant: true,
217
231
  };
218
232
  const screens = [selectionScreen];
@@ -220,10 +234,8 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
220
234
  const secretsPanels = [];
221
235
  const accessRules = [ownerAccess];
222
236
  const translations = {};
223
- const titleSource = [`${ownerGroup}.settings.tenant`, `${ownerGroup}.settings`].find((key) => hub.translationsByKey.has(key));
224
- const titleValues = titleSource === undefined ? undefined : hub.translationsByKey.get(titleSource);
225
- if (titleValues !== undefined)
226
- translations[`screen:${selectionScreenId}.title`] = titleValues;
237
+ translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
238
+ Object.assign(translations, providerOptionTranslations(selectionScreen, plugins, hub));
227
239
  for (const plugin of plugins) {
228
240
  const visibleWhen = {
229
241
  query: SELECTED_EXTENSIONS_QUERY,
@@ -234,7 +246,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
234
246
  const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
235
247
  const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
236
248
  screens.push({
237
- ...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub),
249
+ ...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
238
250
  dormant: true,
239
251
  });
240
252
  accessRules.push(pluginAccess);
@@ -243,6 +255,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
243
255
  id: `${plugin.feature}-config`,
244
256
  screen: pluginTenantScreenId(plugin),
245
257
  visibleWhen,
258
+ chromeless: true,
246
259
  });
247
260
  }
248
261
  if (plugin.secrets.length > 0) {
@@ -256,6 +269,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
256
269
  id: `${plugin.feature}-secrets`,
257
270
  screen: secretsScreenId,
258
271
  visibleWhen,
272
+ chromeless: true,
259
273
  });
260
274
  }
261
275
  }
@@ -263,9 +277,9 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
263
277
  const dashboard = {
264
278
  id: `${ownerGroup}-tenant`,
265
279
  type: "dashboard",
266
- description: EXTENSION_SELECTOR_HINT_KEY,
280
+ showUpdatedAt: false,
267
281
  panels: assertUniquePanelIds(ownerGroup, [
268
- { kind: "screen", id: "selection", screen: selectionScreenId },
282
+ { kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
269
283
  ...ownerPanels,
270
284
  ...configPanels,
271
285
  ...secretsPanels,
@@ -479,10 +493,12 @@ function buildSettingsWorkspace(navs) {
479
493
  navMembers,
480
494
  };
481
495
  }
482
- function buildScreen(shortId, scope, feature, keys, access, hub) {
496
+ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
483
497
  const configKeys = {};
484
498
  const fields = {};
485
499
  const fieldLabels = {};
500
+ const fieldDescriptions = {};
501
+ const requiredFields = [];
486
502
  // Field id collapses to the plain shortKey when the key stays in its own
487
503
  // feature's group (100% of today's apps — byte-identical output). Only a
488
504
  // cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
@@ -497,18 +513,16 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
497
513
  }
498
514
  seenFieldIds.set(id, k.qn);
499
515
  configKeys[id] = k.qn;
500
- fields[id] = deriveField(k.def, hub.selectorOptions.get(k.qn));
516
+ fields[id] = deriveField(k, hub.selectorOptions.get(k.qn), keys, fieldId);
501
517
  // mask is the visibility gate, so collectMaskedKeys guarantees it here.
502
518
  if (k.def.mask)
503
519
  fieldLabels[id] = k.def.mask.title;
520
+ if (k.def.mask?.description !== undefined)
521
+ fieldDescriptions[id] = k.def.mask.description;
522
+ if (k.def.required === true)
523
+ requiredFields.push(id);
504
524
  }
505
- // translate() echoes an undeclared key, so an ungated description would render raw.
506
- const descriptionKey = `${feature}.settings.description`;
507
- const section = {
508
- title: `${feature}.settings`,
509
- ...(hub.declaredTranslationKeys.has(descriptionKey) && { description: descriptionKey }),
510
- fields: keys.map(fieldId),
511
- };
525
+ const { section, screenDescription } = buildSettingsSection(feature, scope, keys.map(fieldId), hub, options);
512
526
  return {
513
527
  id: shortId,
514
528
  type: "configEdit",
@@ -516,16 +530,79 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
516
530
  configKeys,
517
531
  fields,
518
532
  fieldLabels,
519
- layout: { sections: [section], width: "full" },
533
+ ...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
534
+ ...(requiredFields.length > 0 && { requiredFields }),
535
+ ...(screenDescription !== undefined && { description: screenDescription }),
536
+ ...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
537
+ layout: { sections: [section], width: "full", variant: "settings-list" },
520
538
  access,
521
539
  };
522
540
  }
523
- function deriveField(def, selectorPluginIds) {
541
+ // `{ field: <shortKey> }` names another key of the owner feature; on the form it
542
+ // has to point at that key's generated field id, so it must sit on the same mask.
543
+ function rewriteOptionsQueryFieldRefs(key, payload, screenKeys, fieldIdOf) {
544
+ return Object.fromEntries(Object.entries(payload).map(([name, value]) => {
545
+ if (!isOptionsQueryFieldRef(value))
546
+ return [name, value];
547
+ const target = screenKeys.find((k) => k.ownerFeature === key.ownerFeature && k.shortKey === toKebab(value.field));
548
+ if (target === undefined) {
549
+ throw new Error(`[Settings-Hub] config key "${key.qn}" optionsQueryPayload "${name}" references key "${value.field}", which is not on the same generated settings screen (group "${key.feature}") — give both keys the same group, scope and a mask.`);
550
+ }
551
+ return [name, { field: fieldIdOf(target) }];
552
+ }));
553
+ }
554
+ function buildSettingsSection(feature, scope, fieldIds, hub, options) {
555
+ // translate() echoes an undeclared key, so an ungated description would render raw.
556
+ const descriptionKey = `${feature}.settings.description`;
557
+ const featureDescription = hub.declaredTranslationKeys.has(descriptionKey)
558
+ ? descriptionKey
559
+ : undefined;
560
+ const audienceDescription = options.description === null
561
+ ? undefined
562
+ : (options.description ?? audienceDescriptionKey(scope));
563
+ // The section's header column holds one sentence: the feature's own, else the
564
+ // audience one. When both exist the audience sentence stays on the screen.
565
+ const sectionDescription = featureDescription ?? audienceDescription;
566
+ const screenDescription = featureDescription !== undefined ? audienceDescription : undefined;
567
+ // A feature whose page title equals its `<feature>.settings` label would have
568
+ // the section heading dropped as a duplicate; `.settings.section` lets it
569
+ // declare a distinct heading.
570
+ const sectionKey = `${feature}.settings.section`;
571
+ const section = {
572
+ title: options.sectionTitle ??
573
+ (hub.declaredTranslationKeys.has(sectionKey) ? sectionKey : `${feature}.settings`),
574
+ ...(sectionDescription !== undefined && { description: sectionDescription }),
575
+ fields: fieldIds,
576
+ };
577
+ return { section, screenDescription };
578
+ }
579
+ // Option labels of the provider select: each plugin feature's `<feature>.settings`
580
+ // copy, under the option-label key the configEdit renderer already derives.
581
+ function providerOptionTranslations(selectionScreen, plugins, hub) {
582
+ const out = {};
583
+ for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
584
+ if (!hub.selectorOptions.has(qn))
585
+ continue;
586
+ for (const plugin of plugins) {
587
+ const label = hub.translationsByKey.get(`${plugin.feature}.settings`);
588
+ if (label === undefined)
589
+ continue;
590
+ const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${plugin.pluginId}`;
591
+ out[key] = label;
592
+ }
593
+ }
594
+ return out;
595
+ }
596
+ function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
597
+ const def = key.def;
524
598
  if (selectorPluginIds !== undefined)
525
599
  return createSelectField({ options: selectorPluginIds });
526
600
  switch (def.type) {
527
601
  case "number":
528
- return createNumberField();
602
+ return createNumberField({
603
+ ...(def.bounds?.min !== undefined && { min: def.bounds.min }),
604
+ ...(def.bounds?.max !== undefined && { max: def.bounds.max }),
605
+ });
529
606
  case "boolean":
530
607
  return createBooleanField();
531
608
  case "select":
@@ -534,7 +611,7 @@ function deriveField(def, selectorPluginIds) {
534
611
  options: [],
535
612
  optionsQuery: def.optionsQuery,
536
613
  ...(def.optionsQueryPayload !== undefined && {
537
- optionsQueryPayload: def.optionsQueryPayload,
614
+ optionsQueryPayload: rewriteOptionsQueryFieldRefs(key, def.optionsQueryPayload, screenKeys, fieldIdOf),
538
615
  }),
539
616
  });
540
617
  }
@@ -1,4 +1,4 @@
1
- import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions } from "./types/index.js";
1
+ import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
2
2
  export declare function isEncryptedAtRest(def: Pick<ConfigKeyDefinition, "encrypted" | "backing">): boolean;
3
3
  export declare const access: {
4
4
  readonly all: readonly string[];
@@ -19,7 +19,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
19
19
  piiEncrypted?: T extends "text" ? boolean : never;
20
20
  options?: readonly string[];
21
21
  optionsQuery?: T extends "select" ? string : never;
22
- optionsQueryPayload?: T extends "select" ? Readonly<Record<string, string | number | boolean>> : never;
22
+ optionsQueryPayload?: T extends "select" ? OptionsQueryPayload : never;
23
23
  bounds?: T extends "number" ? ConfigBounds : never;
24
24
  pattern?: T extends "text" ? {
25
25
  regex: string;
@@ -7,6 +7,7 @@ import { acknowledgeConventionCrossTenant } from "../db/tenant-db.js";
7
7
  import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
8
8
  import { assertUnreachable } from "../utils/index.js";
9
9
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
10
+ import { dropEmptyWriteOnlyValues } from "./field-access.js";
10
11
  import { instructionFieldNames } from "./instruction-fields.js";
11
12
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
12
13
  // Convention-based handler factories for event-sourced aggregates.
@@ -233,7 +234,7 @@ export function defineEntityWriteHandler(name, entity, options) {
233
234
  // present that identity, so their payload.id is dropped exactly as
234
235
  // before this field became part of the schema.
235
236
  const { id: _callerId, ...payloadWithoutId } = event.payload;
236
- const payload = isSystemIdentity(event.user) ? event.payload : payloadWithoutId;
237
+ const payload = dropEmptyWriteOnlyValues(entity, isSystemIdentity(event.user) ? event.payload : payloadWithoutId);
237
238
  return executor.create(payload, event.user, dbFor(ctx), {
238
239
  preSave: runPreSave &&
239
240
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -249,7 +250,11 @@ export function defineEntityWriteHandler(name, entity, options) {
249
250
  handler = async (event, ctx) => {
250
251
  const { runPreSave } = ctx;
251
252
  const db = dbFor(ctx);
252
- const payload = event.payload; // @cast-boundary engine-payload
253
+ const submitted = event.payload; // @cast-boundary engine-payload
254
+ const payload = {
255
+ ...submitted,
256
+ changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
257
+ };
253
258
  const user = await actingUserFor(event.user, payload.id, db);
254
259
  // skipUnchanged (#464): API-driven updates diff against the stored
255
260
  // row so a resubmitted-but-identical field doesn't force a fresh
@@ -5,6 +5,7 @@ import type { PrincipalStatusPlugin, TenantLifecycleStatusPlugin } from "./activ
5
5
  import type { TenantDataExtensionHooks } from "./extensions/tenant-data.js";
6
6
  import type { TenantResourceExtensionHooks } from "./extensions/tenant-resource.js";
7
7
  import type { UserDataExtensionOptions } from "./extensions/user-data.js";
8
+ import type { AssignableFromRole } from "./role-assignment.js";
8
9
  import { TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin } from "./tier-resolver-extension.js";
9
10
  /**
10
11
  * `userData` — User-Daten-Rights-Hooks (DSGVO Art. 15 + 17 + 20).
@@ -137,13 +138,20 @@ export declare const EXT_PRINCIPAL_STATUS: "principalStatus";
137
138
  * the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
138
139
  */
139
140
  export declare const EXT_TENANT_LIFECYCLE_STATUS: "tenantLifecycleStatus";
141
+ /**
142
+ * `assignableRole` — an app declares a membership role as assignable:
143
+ * `r.useExtension(EXT_ASSIGNABLE_ROLE, "<RoleName>", { assignableFrom? })`
144
+ * (`assignableFrom` defaults to "Admin").
145
+ * Provider is the bundled `tenant` feature; consumers are the membership-role guards.
146
+ */
147
+ export declare const EXT_ASSIGNABLE_ROLE: "assignableRole";
140
148
  export declare const TENANT_MEMBERSHIPS_QUERY: "tenant:query:memberships";
141
149
  /**
142
150
  * Union aller standardisierten Extension-Namen der Datenschutz-Surface.
143
151
  * Nicht alle Extensions im System sind in dieser Liste — andere
144
152
  * Features koennen weiterhin eigene Extension-Namen registrieren.
145
153
  */
146
- export type KumikoExtensionName = typeof EXT_USER_DATA | typeof EXT_TENANT_DATA | typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE | typeof EXT_FILE_PROVIDER | typeof EXT_DERIVATIVE_RENDERER | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER | typeof EXT_PRINCIPAL_STATUS | typeof EXT_TENANT_LIFECYCLE_STATUS;
154
+ export type KumikoExtensionName = typeof EXT_USER_DATA | typeof EXT_TENANT_DATA | typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE | typeof EXT_FILE_PROVIDER | typeof EXT_DERIVATIVE_RENDERER | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER | typeof EXT_PRINCIPAL_STATUS | typeof EXT_TENANT_LIFECYCLE_STATUS | typeof EXT_ASSIGNABLE_ROLE;
147
155
  /** The four `destroyTenant(tenantId, ctx)`-only resource-cleanup extension points. */
148
156
  export type TenantResourceExtensionName = typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE;
149
157
  declare module "@cosmicdrift/kumiko-framework/engine" {
@@ -155,6 +163,9 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
155
163
  [EXT_DERIVATIVE_OVERLAY_RESOLVER]: OverlayResolverPlugin;
156
164
  [EXT_PRINCIPAL_STATUS]: PrincipalStatusPlugin;
157
165
  [EXT_TENANT_LIFECYCLE_STATUS]: TenantLifecycleStatusPlugin;
166
+ [EXT_ASSIGNABLE_ROLE]: {
167
+ readonly assignableFrom?: AssignableFromRole;
168
+ };
158
169
  [EXT_STORAGE_PROVIDER]: TenantResourceExtensionHooks;
159
170
  [EXT_SEARCH_ADAPTER]: TenantResourceExtensionHooks;
160
171
  [EXT_EXTERNAL_RESOURCE]: TenantResourceExtensionHooks;
@@ -167,6 +167,13 @@ export const EXT_PRINCIPAL_STATUS = "principalStatus";
167
167
  * the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
168
168
  */
169
169
  export const EXT_TENANT_LIFECYCLE_STATUS = "tenantLifecycleStatus";
170
+ /**
171
+ * `assignableRole` — an app declares a membership role as assignable:
172
+ * `r.useExtension(EXT_ASSIGNABLE_ROLE, "<RoleName>", { assignableFrom? })`
173
+ * (`assignableFrom` defaults to "Admin").
174
+ * Provider is the bundled `tenant` feature; consumers are the membership-role guards.
175
+ */
176
+ export const EXT_ASSIGNABLE_ROLE = "assignableRole";
170
177
  // Default membership-list query handler name. Accepted coupling, same
171
178
  // pattern as FILE_PROVIDER_CONFIG_KEY — the `tenant` feature registers a handler under this literal.
172
179
  export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships";
@@ -8,7 +8,7 @@ export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-imp
8
8
  export { extractDescribe, extractOptionalRequires, extractReadsConfig, extractRequires, extractSystemScope, extractToggleable, extractUiHints, } from "./round1.js";
9
9
  export { extractEntity, extractNav, extractRelation, extractWorkspace, } from "./round2.js";
10
10
  export { extractClaimKey, extractConfig, extractMetric, extractReferenceData, extractSecret, extractTranslations, extractUseExtension, isClaimKeyType, type NamedOptionsResult, readNamedOptions, } from "./round3.js";
11
- export { extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
11
+ export { extractBootCheck, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
12
12
  export { extractTreeActions } from "./round6.js";
13
13
  export type { ExtractOutput } from "./shared.js";
14
14
  export { fail, findFunctionLiteral, isPlainObject, ok, readBooleanProperty, readDataLiteralNode, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, readPropertyKey, readStringLiteralArgs, readVarargsOrArrayProp, resolveSameFileObjectLiteral, } from "./shared.js";
@@ -8,6 +8,6 @@ export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-imp
8
8
  export { extractDescribe, extractOptionalRequires, extractReadsConfig, extractRequires, extractSystemScope, extractToggleable, extractUiHints, } from "./round1.js";
9
9
  export { extractEntity, extractNav, extractRelation, extractWorkspace, } from "./round2.js";
10
10
  export { extractClaimKey, extractConfig, extractMetric, extractReferenceData, extractSecret, extractTranslations, extractUseExtension, isClaimKeyType, readNamedOptions, } from "./round3.js";
11
- export { extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
11
+ export { extractBootCheck, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
12
12
  export { extractTreeActions } from "./round6.js";
13
13
  export { fail, findFunctionLiteral, isPlainObject, ok, readBooleanProperty, readDataLiteralNode, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, readPropertyKey, readStringLiteralArgs, readVarargsOrArrayProp, resolveSameFileObjectLiteral, } from "./shared.js";
@@ -6,3 +6,4 @@ export declare function extractExtendsRegistrar(call: CallExpression, sourceFile
6
6
  export declare function extractUsesApi(call: CallExpression, sourceFile: SourceFile): ExtractOutput<UsesApiPattern>;
7
7
  export declare function extractExposesApi(call: CallExpression, sourceFile: SourceFile): ExtractOutput<ExposesApiPattern>;
8
8
  export declare function extractStoreTable(call: CallExpression, sourceFile: SourceFile): ExtractOutput<never>;
9
+ export declare function extractBootCheck(call: CallExpression, sourceFile: SourceFile): ExtractOutput<never>;
@@ -61,3 +61,8 @@ export function extractStoreTable(call, sourceFile) {
61
61
  // UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
62
62
  return fail("storeTable", sourceLocationFromNode(call, sourceFile), "storeTable meta is a factory/identifier argument, not a static literal");
63
63
  }
64
+ export function extractBootCheck(call, sourceFile) {
65
+ // The argument is a runtime closure over the composed feature list —
66
+ // nothing to extract statically, so a clean ParseError like storeTable.
67
+ return fail("bootCheck", sourceLocationFromNode(call, sourceFile), "bootCheck takes a runtime closure, not a static literal");
68
+ }
@@ -21,7 +21,7 @@
21
21
  // Per-pattern extractors fill in iteratively (C1.5) — each round adds
22
22
  // one extractor + a focused test.
23
23
  import { Project, SyntaxKind } from "ts-morph";
24
- import { extractAiClassify, extractAiExtract, extractAiGenerate, extractAuthClaims, extractClaimKey, extractConfig, extractDefineEvent, extractDescribe, extractEntity, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractHook, extractHttpRoute, extractJob, extractMetric, extractMultiStreamProjection, extractNav, extractNotification, extractOptionalRequires, extractProjection, extractQueryHandler, extractReadsConfig, extractReferenceData, extractRelation, extractRequires, extractScreen, extractSecret, extractStoreTable, extractStreamHandler, extractSystemScope, extractToggleable, extractTranslations, extractTreeActions, extractUiHints, extractUseExtension, extractUsesApi, extractWorkspace, extractWriteHandler, findFunctionLiteral, findImportBindingForLocalName, readObjectPropertyInitializer, resolveModuleFile, resolveSameFileObjectLiteral, } from "./extractors/index.js";
24
+ import { extractAiClassify, extractAiExtract, extractAiGenerate, extractAuthClaims, extractBootCheck, extractClaimKey, extractConfig, extractDefineEvent, extractDescribe, extractEntity, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractHook, extractHttpRoute, extractJob, extractMetric, extractMultiStreamProjection, extractNav, extractNotification, extractOptionalRequires, extractProjection, extractQueryHandler, extractReadsConfig, extractReferenceData, extractRelation, extractRequires, extractScreen, extractSecret, extractStoreTable, extractStreamHandler, extractSystemScope, extractToggleable, extractTranslations, extractTreeActions, extractUiHints, extractUseExtension, extractUsesApi, extractWorkspace, extractWriteHandler, findFunctionLiteral, findImportBindingForLocalName, readObjectPropertyInitializer, resolveModuleFile, resolveSameFileObjectLiteral, } from "./extractors/index.js";
25
25
  import { sourceLocationFromNode } from "./source-location.js";
26
26
  /**
27
27
  * Parse the given feature file and return all recognised r.* calls
@@ -440,6 +440,8 @@ function dispatchExtractor(methodName, call, sourceFile) {
440
440
  return extractExposesApi(call, sourceFile);
441
441
  case "storeTable":
442
442
  return extractStoreTable(call, sourceFile);
443
+ case "bootCheck":
444
+ return extractBootCheck(call, sourceFile);
443
445
  // Round 6 — Tree-Actions pattern
444
446
  case "treeActions":
445
447
  return extractTreeActions(call, sourceFile);
@@ -5,6 +5,24 @@ import { registerEntityCrud } from "./entity-handlers.js";
5
5
  import { resolveName } from "./handler-helpers.js";
6
6
  import { splitNamedDefinition } from "./object-form.js";
7
7
  const CRUD_VERBS = new Set(["create", "update", "delete"]);
8
+ const CRUD_REGISTERED_VERBS = new Set([...CRUD_VERBS, "restore", "list", "detail"]);
9
+ // A silent overwrite would drop framework behaviour that rides on the r.crud
10
+ // handlers (e.g. writeOnly masking), so a same-name registration fails loudly.
11
+ function assertHandlerNameFree(state, featureName, kind, handlerName) {
12
+ const registered = kind === "write" ? state.writeHandlers : state.queryHandlers;
13
+ // skip: a free name is the normal case
14
+ if (!Object.hasOwn(registered, handlerName))
15
+ return;
16
+ const [entityName, verb] = handlerName.split(":");
17
+ const crudShaped = entityName !== undefined &&
18
+ verb !== undefined &&
19
+ state.entities[entityName] !== undefined &&
20
+ CRUD_REGISTERED_VERBS.has(verb);
21
+ throw new Error(`Feature "${featureName}" registers the ${kind} handler "${handlerName}" twice.` +
22
+ (crudShaped
23
+ ? ` r.crud("${entityName}", ...) already registers that name; use a distinct name or drop the "${verb}" verb from r.crud.`
24
+ : " Use a distinct name."));
25
+ }
8
26
  // Map handler name to entity via colon convention.
9
27
  // "task:create" → entity "task". Bare CRUD verbs (create/update/delete) map
10
28
  // when feature name matches an entity or the feature owns exactly one entity.
@@ -70,6 +88,7 @@ export function buildEntityHandlerMethods(state, name) {
70
88
  writeHandler(nameOrDef, schema, handler, options) {
71
89
  if (typeof nameOrDef === "object") {
72
90
  const def = nameOrDef;
91
+ assertHandlerNameFree(state, name, "write", def.name);
73
92
  state.writeHandlers[def.name] = {
74
93
  name: def.name,
75
94
  schema: def.schema,
@@ -101,6 +120,7 @@ export function buildEntityHandlerMethods(state, name) {
101
120
  return { name: def.name };
102
121
  }
103
122
  const inline = requireInlineHandlerArgs("writeHandler", schema, handler, options);
123
+ assertHandlerNameFree(state, name, "write", nameOrDef);
104
124
  state.writeHandlers[nameOrDef] = {
105
125
  name: nameOrDef,
106
126
  schema: inline.schema,
@@ -116,6 +136,7 @@ export function buildEntityHandlerMethods(state, name) {
116
136
  queryHandler(nameOrDef, schema, handler, options) {
117
137
  if (typeof nameOrDef === "object") {
118
138
  const def = nameOrDef;
139
+ assertHandlerNameFree(state, name, "query", def.name);
119
140
  state.queryHandlers[def.name] = {
120
141
  name: def.name,
121
142
  schema: def.schema,
@@ -137,6 +158,7 @@ export function buildEntityHandlerMethods(state, name) {
137
158
  return { name: def.name };
138
159
  }
139
160
  const inline = requireInlineHandlerArgs("queryHandler", schema, handler, options);
161
+ assertHandlerNameFree(state, name, "query", nameOrDef);
140
162
  state.queryHandlers[nameOrDef] = {
141
163
  name: nameOrDef,
142
164
  schema: inline.schema,