@cosmicdrift/kumiko-framework 0.333.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.
@@ -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,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,
@@ -1,4 +1,6 @@
1
1
  import type { EntityDefinition, SessionUser } from "./types/index.js";
2
+ export declare function maskWriteOnlyFields<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, row: T): T;
3
+ export declare function dropEmptyWriteOnlyValues<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, values: T): T;
2
4
  export declare function filterReadFields(entity: EntityDefinition, data: Readonly<Record<string, unknown>>, user: SessionUser): Record<string, unknown>;
3
5
  export declare function checkWriteFieldRoles(entity: EntityDefinition, changes: Readonly<Record<string, unknown>>, user: SessionUser): string | null;
4
6
  export declare function checkWriteFieldOwnership(entity: EntityDefinition, submittedChanges: Readonly<Record<string, unknown>>, user: SessionUser, oldRow?: Readonly<Record<string, unknown>>, rowContext?: Readonly<Record<string, unknown>>): string | null;
@@ -1,4 +1,40 @@
1
1
  import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership.js";
2
+ function writeOnlyFieldNames(entity) {
3
+ const names = [];
4
+ for (const [name, field] of Object.entries(entity.fields)) {
5
+ if (field.type === "text" && field.writeOnly === true)
6
+ names.push(name);
7
+ }
8
+ return names;
9
+ }
10
+ function maskWriteOnlyValue(value) {
11
+ return value === null || value === undefined || value === "" ? null : true;
12
+ }
13
+ // writeOnly text fields never leave the server as plaintext: set -> true,
14
+ // empty -> null. Only keys present in the row are touched (a partial `changes`
15
+ // stays partial). Returns the same object when the entity has no such field.
16
+ export function maskWriteOnlyFields(entity, row) {
17
+ const names = writeOnlyFieldNames(entity);
18
+ if (names.length === 0)
19
+ return row;
20
+ const masked = { ...row };
21
+ for (const name of names) {
22
+ if (name in masked)
23
+ masked[name] = maskWriteOnlyValue(masked[name]);
24
+ }
25
+ return masked; // @cast-boundary engine-payload
26
+ }
27
+ // Write payloads: "" on a writeOnly field means "unchanged" (an untouched
28
+ // masked input submits it), so the key is dropped before the executor sees it.
29
+ export function dropEmptyWriteOnlyValues(entity, values) {
30
+ const names = writeOnlyFieldNames(entity).filter((name) => values[name] === "");
31
+ if (names.length === 0)
32
+ return values;
33
+ const kept = { ...values };
34
+ for (const name of names)
35
+ delete kept[name];
36
+ return kept; // @cast-boundary engine-payload
37
+ }
2
38
  // Field-level read filtering. Returns a copy of `data` with fields stripped
3
39
  // if the user's roles don't grant read access OR the ownership-rule for the
4
40
  // matching role doesn't accept this concrete row. Fields without access
@@ -22,6 +58,10 @@ export function filterReadFields(entity, data, user) {
22
58
  if (!userCanReadFieldRow(user, accessMap, data)) {
23
59
  continue; // entire field stripped
24
60
  }
61
+ if (field.type === "text" && field.writeOnly === true) {
62
+ result[key] = maskWriteOnlyValue(value);
63
+ continue;
64
+ }
25
65
  // For embedded fields: filter sub-fields with access restrictions.
26
66
  // A list-embedded value is an array of rows — each row is filtered on its
27
67
  // own, so a sub-field rule can reference the row it sits in. Filtering the
@@ -40,7 +40,7 @@ export type { AddEntityArgs, AddHookArgs, AddRelationArgs, AddWriteHandlerArgs,
40
40
  export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
41
41
  export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
42
42
  export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, serializeManifest, } from "./feature-manifest.js";
43
- export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, } from "./field-access.js";
43
+ export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
44
44
  export { resolveName, withResponseData } from "./handler-helpers.js";
45
45
  export { i18nKey } from "./i18n-key.js";
46
46
  export { buildSessionRoles, findForbiddenMembershipRole, isForbiddenMembershipRole, stripForbiddenMembershipRoles, } from "./membership-roles.js";
@@ -35,7 +35,7 @@ export { createBigIntField, createBooleanField, createDateField, createDecimalFi
35
35
  export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
36
36
  export { compareVersions, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
37
37
  export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
38
- export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, } from "./field-access.js";
38
+ export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
39
39
  export { resolveName, withResponseData } from "./handler-helpers.js";
40
40
  export { i18nKey } from "./i18n-key.js";
41
41
  // findForbiddenMembershipRole/isForbiddenMembershipRole/
@@ -3,6 +3,7 @@ import type { EntityDefinition, FieldDefinition } from "./types/index.js";
3
3
  export declare function isRepresentableAtScale(n: number, scale: number): boolean;
4
4
  export declare function fieldToZod(field: FieldDefinition, currencies: readonly string[], opts?: {
5
5
  readonly applyDefaults?: boolean;
6
+ readonly isUpdate?: boolean;
6
7
  }): z.ZodTypeAny;
7
8
  export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[], options?: {
8
9
  readonly allowCallerId?: boolean;
@@ -87,6 +87,13 @@ export function fieldToZod(field, currencies, opts = {}) {
87
87
  let schema = z.string();
88
88
  if (field.maxLength)
89
89
  schema = schema.max(field.maxLength);
90
+ if (field.writeOnly === true) {
91
+ // Update: "" = unchanged (dropped before the executor), null = clear,
92
+ // unless required. Create: "" would leave a required field unset.
93
+ if (opts.isUpdate !== true)
94
+ return field.required ? schema.min(1) : schema;
95
+ return field.required ? schema : schema.nullable();
96
+ }
90
97
  if (field.format === "email")
91
98
  schema = schema.email();
92
99
  if (field.format === "url")
@@ -399,7 +406,10 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
399
406
  // fieldToZod still knows the default for its "" → default mapping
400
407
  // (e.g. select) — applyDefaults: false only suppresses the schema-level
401
408
  // `.default(...)` fallback for a genuinely omitted key.
402
- shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
409
+ shape[name] = fieldToZod(field, currencies, {
410
+ applyDefaults: false,
411
+ isUpdate: true,
412
+ }).optional();
403
413
  }
404
414
  return applyTotalsMatchRefinements(entity, z.object(shape));
405
415
  }
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
7
7
  export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
- export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
9
+ export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
10
  export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
@@ -9,6 +9,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -29,6 +30,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
29
30
  readonly filterable?: boolean;
30
31
  readonly encrypted?: boolean;
31
32
  readonly sensitive?: boolean;
33
+ readonly writeOnly?: boolean;
32
34
  readonly readAsInstruction?: boolean;
33
35
  readonly format?: "email" | "url" | "phone" | "password";
34
36
  readonly default?: string;
@@ -49,6 +51,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
49
51
  readonly filterable?: boolean;
50
52
  readonly encrypted?: boolean;
51
53
  readonly sensitive?: boolean;
54
+ readonly writeOnly?: boolean;
52
55
  readonly readAsInstruction?: boolean;
53
56
  readonly format?: "email" | "url" | "phone" | "password";
54
57
  readonly default?: string;
@@ -81,6 +84,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
81
84
  readonly filterable?: boolean;
82
85
  readonly encrypted?: boolean;
83
86
  readonly sensitive?: boolean;
87
+ readonly writeOnly?: boolean;
84
88
  readonly readAsInstruction?: boolean;
85
89
  readonly format?: "email" | "url" | "phone" | "password";
86
90
  readonly default?: string;
@@ -101,6 +105,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
101
105
  readonly filterable?: boolean;
102
106
  readonly encrypted?: boolean;
103
107
  readonly sensitive?: boolean;
108
+ readonly writeOnly?: boolean;
104
109
  readonly readAsInstruction?: boolean;
105
110
  readonly format?: "email" | "url" | "phone" | "password";
106
111
  readonly default?: string;
@@ -121,6 +126,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
121
126
  readonly filterable?: boolean;
122
127
  readonly encrypted?: boolean;
123
128
  readonly sensitive?: boolean;
129
+ readonly writeOnly?: boolean;
124
130
  readonly readAsInstruction?: boolean;
125
131
  readonly format?: "email" | "url" | "phone" | "password";
126
132
  readonly default?: string;
@@ -9,6 +9,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -29,6 +30,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
29
30
  readonly filterable?: boolean;
30
31
  readonly encrypted?: boolean;
31
32
  readonly sensitive?: boolean;
33
+ readonly writeOnly?: boolean;
32
34
  readonly readAsInstruction?: boolean;
33
35
  readonly format?: "email" | "url" | "phone" | "password";
34
36
  readonly default?: string;
@@ -49,6 +51,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
49
51
  readonly filterable?: boolean;
50
52
  readonly encrypted?: boolean;
51
53
  readonly sensitive?: boolean;
54
+ readonly writeOnly?: boolean;
52
55
  readonly readAsInstruction?: boolean;
53
56
  readonly format?: "email" | "url" | "phone" | "password";
54
57
  readonly default?: string;
@@ -81,6 +84,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
81
84
  readonly filterable?: boolean;
82
85
  readonly encrypted?: boolean;
83
86
  readonly sensitive?: boolean;
87
+ readonly writeOnly?: boolean;
84
88
  readonly readAsInstruction?: boolean;
85
89
  readonly format?: "email" | "url" | "phone" | "password";
86
90
  readonly default?: string;
@@ -101,6 +105,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
101
105
  readonly filterable?: boolean;
102
106
  readonly encrypted?: boolean;
103
107
  readonly sensitive?: boolean;
108
+ readonly writeOnly?: boolean;
104
109
  readonly readAsInstruction?: boolean;
105
110
  readonly format?: "email" | "url" | "phone" | "password";
106
111
  readonly default?: string;
@@ -121,6 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
121
126
  readonly filterable?: boolean;
122
127
  readonly encrypted?: boolean;
123
128
  readonly sensitive?: boolean;
129
+ readonly writeOnly?: boolean;
124
130
  readonly readAsInstruction?: boolean;
125
131
  readonly format?: "email" | "url" | "phone" | "password";
126
132
  readonly default?: string;
@@ -293,6 +293,10 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
293
293
  case "configEdit": {
294
294
  const config = screen;
295
295
  pushKey(out, config.submitLabel);
296
+ pushKey(out, config.description, treatDotFormAsKey);
297
+ for (const key of Object.values(config.fieldDescriptions ?? {})) {
298
+ pushKey(out, key, treatDotFormAsKey);
299
+ }
296
300
  for (const fieldName of Object.keys(config.fields)) {
297
301
  const override = config.fieldLabels?.[fieldName];
298
302
  if (override !== undefined)
@@ -308,6 +312,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
308
312
  if (section.kind === "relatedList")
309
313
  continue; // rejected at boot, unreachable here
310
314
  pushSectionTitles(out, section, treatDotFormAsKey);
315
+ pushKey(out, section.description, treatDotFormAsKey);
311
316
  for (const f of sectionFieldSpecs(section)) {
312
317
  const fieldName = editFieldName(f);
313
318
  const override = config.fieldLabels?.[fieldName];
@@ -16,14 +16,28 @@ export const SETTINGS_HUB_I18N = {
16
16
  "config.settings.tenant": { en: "Tenant" },
17
17
  "config.settings.user": { en: "Personal" },
18
18
  "config.settings.extensionSelectorHint": {
19
- en: "Save the provider selection to show its settings below.",
19
+ en: "Choose a provider and save. Its settings appear below.",
20
20
  },
21
+ "config.settings.provider": { en: "Provider" },
22
+ "config.settings.saveProvider": { en: "Save provider" },
23
+ "config.settings.audience.system": {
24
+ en: "Applies to every tenant unless a tenant sets its own value.",
25
+ },
26
+ "config.settings.audience.tenant": {
27
+ en: "Applies to everyone in this tenant. Platform defaults are shown where nothing is set here.",
28
+ },
29
+ "config.settings.audience.user": { en: "Only applies to you." },
21
30
  "config.secrets.title": { en: "Secrets" },
22
31
  "config.secrets.notSet": { en: "Not set" },
23
32
  "config.secrets.set": { en: "Set" },
24
- "config.secrets.required": { en: "Required" },
33
+ "config.secrets.saved": { en: "Saved" },
34
+ "config.secrets.section": { en: "Secrets" },
35
+ "config.secrets.description": {
36
+ en: "Stored encrypted. Saved values are never shown again.",
37
+ },
38
+ "config.secrets.stored": { en: "Stored: {preview}" },
25
39
  "config.secrets.placeholder": { en: "Enter a value" },
26
- "config.secrets.replacePlaceholder": { en: "Enter a new value to replace" },
27
- "config.secrets.delete": { en: "Delete" },
40
+ "config.secrets.replacePlaceholder": { en: "Enter a new value to replace it" },
41
+ "config.secrets.delete": { en: "Remove" },
28
42
  "screen:secrets.title": { en: "Secrets" },
29
43
  };
@@ -7,6 +7,7 @@ import { resolveDbSource } from "./dispatch-shared.js";
7
7
  import { executeNestedWrite } from "./dispatch-write.js";
8
8
  import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
9
9
  import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
10
+ import { maskBatchResultForClient } from "./write-result-masking.js";
10
11
  // afterCommit hooks fire in flushAfterCommit, outside the command's scope.
11
12
  function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
12
13
  for (let i = fromIndex; i < afterCommitHooks.length; i++) {
@@ -51,20 +52,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
51
52
  idempotencyToken = checked.token;
52
53
  }
53
54
  }
55
+ // Hooks already ran on the plaintext results; what is returned and cached
56
+ // for retries must not carry writeOnly values.
57
+ const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
54
58
  // Cache the result under requestId so retries get the same answer. Only a
55
59
  // provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
56
60
  const finalize = async (result) => {
61
+ const masked = maskForClient(result);
57
62
  if (requestId && idempotency && idempotencyToken) {
58
- await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
63
+ await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, masked);
59
64
  }
60
- return result;
65
+ return masked;
61
66
  };
62
67
  // Never for the no-tx fallback: without a rollback, a re-run would repeat
63
68
  // the side effects of the commands that already ran.
64
69
  const releaseOrFinalize = async (result, isRetryableRollback) => {
65
70
  if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
66
71
  await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
67
- return result;
72
+ return maskForClient(result);
68
73
  }
69
74
  return finalize(result);
70
75
  };
@@ -5,7 +5,7 @@ import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
5
5
  import { tenantDbRunner } from "../db/tenant-db-runner.js";
6
6
  import { hasAccess } from "../engine/access.js";
7
7
  import { ConfigScopes } from "../engine/constants.js";
8
- import { checkWriteFieldOwnership, checkWriteFieldRoles } from "../engine/field-access.js";
8
+ import { checkWriteFieldOwnership, checkWriteFieldRoles, maskWriteOnlyFields, } from "../engine/field-access.js";
9
9
  import { defineTransitions, guardTransition } from "../engine/state-machine.js";
10
10
  import { HookPhases } from "../engine/types/index.js";
11
11
  import { runValidation } from "../engine/validation.js";
@@ -262,7 +262,10 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
262
262
  }
263
263
  const subWrapper = subResult.data; // @cast-boundary engine-payload
264
264
  const subRow = (subWrapper["data"] ?? subWrapper); // @cast-boundary engine-payload
265
- subRows.push(subRow);
265
+ const subEntityName = registry.getHandlerEntity(spec.subType);
266
+ const subEntity = subEntityName ? registry.getEntity(subEntityName) : undefined;
267
+ // parentRow is returned to the client; the child's own SaveContext keeps the plaintext.
268
+ subRows.push(subEntity ? maskWriteOnlyFields(subEntity, subRow) : subRow);
266
269
  }
267
270
  parentRow[spec.key] = subRows;
268
271
  }
@@ -4,8 +4,11 @@ import { VersionConflictError as EventStoreVersionConflictError } from "../event
4
4
  export function isFailedWriteResult(result) {
5
5
  return (!!result && typeof result === "object" && "isSuccess" in result && result.isSuccess === false);
6
6
  }
7
+ const LIFECYCLE_KINDS = ["save", "delete"];
7
8
  export function isLifecycleResult(data) {
8
- return !!data && typeof data === "object" && "kind" in data;
9
+ if (!data || typeof data !== "object" || !("kind" in data))
10
+ return false;
11
+ return LIFECYCLE_KINDS.some((kind) => kind === data.kind);
9
12
  }
10
13
  export function isWriteResultShape(result) {
11
14
  return (!!result &&
@@ -0,0 +1,6 @@
1
+ import type { WriteResult } from "../engine/types/index.js";
2
+ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared.js";
3
+ type EntityLookup = Pick<DispatchContext["registry"], "getHandlerEntity" | "getEntity">;
4
+ export declare function maskWriteResultForClient(registry: EntityLookup, handlerType: string, result: WriteResult): WriteResult;
5
+ export declare function maskBatchResultForClient(registry: EntityLookup, commands: readonly BatchCommand[], batch: BatchResult): BatchResult;
6
+ export {};
@@ -0,0 +1,36 @@
1
+ import { maskWriteOnlyFields } from "../engine/field-access.js";
2
+ import { isLifecycleResult } from "./dispatcher-utils.js";
3
+ function maskLifecycleOrRow(entity, data) {
4
+ if (typeof data !== "object" || data === null || Array.isArray(data))
5
+ return data;
6
+ if (!isLifecycleResult(data))
7
+ return maskWriteOnlyFields(entity, data); // @cast-boundary engine-payload
8
+ if (data.kind === "save") {
9
+ return {
10
+ ...data,
11
+ data: maskWriteOnlyFields(entity, data.data),
12
+ changes: maskWriteOnlyFields(entity, data.changes),
13
+ previous: maskWriteOnlyFields(entity, data.previous),
14
+ };
15
+ }
16
+ return { ...data, data: maskWriteOnlyFields(entity, data.data) };
17
+ }
18
+ // Copy-on-mask: the original results still feed postSaveBatch hooks, which
19
+ // need the plaintext. Only what leaves the dispatcher is masked.
20
+ export function maskWriteResultForClient(registry, handlerType, result) {
21
+ if (!result.isSuccess)
22
+ return result;
23
+ const entityName = registry.getHandlerEntity(handlerType);
24
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
25
+ if (!entity)
26
+ return result;
27
+ const data = maskLifecycleOrRow(entity, result.data);
28
+ return data === result.data ? result : { ...result, data };
29
+ }
30
+ export function maskBatchResultForClient(registry, commands, batch) {
31
+ const results = batch.results.map((result, index) => {
32
+ const command = commands[index];
33
+ return command ? maskWriteResultForClient(registry, command.type, result) : result;
34
+ });
35
+ return { ...batch, results };
36
+ }
@@ -9,6 +9,7 @@ export declare const sharedWidgetEntity: import("@cosmicdrift/kumiko-types/field
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -31,6 +32,7 @@ export declare const sharedWidgetTable: import("../db/table-builder.js").EntityT
31
32
  readonly filterable?: boolean;
32
33
  readonly encrypted?: boolean;
33
34
  readonly sensitive?: boolean;
35
+ readonly writeOnly?: boolean;
34
36
  readonly readAsInstruction?: boolean;
35
37
  readonly format?: "email" | "url" | "phone" | "password";
36
38
  readonly default?: string;
@@ -53,6 +55,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
53
55
  readonly filterable?: boolean;
54
56
  readonly encrypted?: boolean;
55
57
  readonly sensitive?: boolean;
58
+ readonly writeOnly?: boolean;
56
59
  readonly readAsInstruction?: boolean;
57
60
  readonly format?: "email" | "url" | "phone" | "password";
58
61
  readonly default?: string;
@@ -73,6 +76,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
73
76
  readonly filterable?: boolean;
74
77
  readonly encrypted?: boolean;
75
78
  readonly sensitive?: boolean;
79
+ readonly writeOnly?: boolean;
76
80
  readonly readAsInstruction?: boolean;
77
81
  readonly format?: "email" | "url" | "phone" | "password";
78
82
  readonly default?: string;
@@ -93,6 +97,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
93
97
  readonly filterable?: boolean;
94
98
  readonly encrypted?: boolean;
95
99
  readonly sensitive?: boolean;
100
+ readonly writeOnly?: boolean;
96
101
  readonly readAsInstruction?: boolean;
97
102
  readonly format?: "email" | "url" | "phone" | "password";
98
103
  readonly default?: string;
@@ -118,6 +123,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
118
123
  readonly filterable?: boolean;
119
124
  readonly encrypted?: boolean;
120
125
  readonly sensitive?: boolean;
126
+ readonly writeOnly?: boolean;
121
127
  readonly readAsInstruction?: boolean;
122
128
  readonly format?: "email" | "url" | "phone" | "password";
123
129
  readonly default?: string;
@@ -138,6 +144,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
138
144
  readonly filterable?: boolean;
139
145
  readonly encrypted?: boolean;
140
146
  readonly sensitive?: boolean;
147
+ readonly writeOnly?: boolean;
141
148
  readonly readAsInstruction?: boolean;
142
149
  readonly format?: "email" | "url" | "phone" | "password";
143
150
  readonly default?: string;
@@ -158,6 +165,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
158
165
  readonly filterable?: boolean;
159
166
  readonly encrypted?: boolean;
160
167
  readonly sensitive?: boolean;
168
+ readonly writeOnly?: boolean;
161
169
  readonly readAsInstruction?: boolean;
162
170
  readonly format?: "email" | "url" | "phone" | "password";
163
171
  readonly default?: string;
@@ -183,6 +191,7 @@ export declare const sharedItemEntity: import("@cosmicdrift/kumiko-types/fields"
183
191
  readonly filterable?: boolean;
184
192
  readonly encrypted?: boolean;
185
193
  readonly sensitive?: boolean;
194
+ readonly writeOnly?: boolean;
186
195
  readonly readAsInstruction?: boolean;
187
196
  readonly format?: "email" | "url" | "phone" | "password";
188
197
  readonly default?: string;
@@ -224,6 +233,7 @@ export declare const sharedItemTable: import("../db/table-builder.js").EntityTab
224
233
  readonly filterable?: boolean;
225
234
  readonly encrypted?: boolean;
226
235
  readonly sensitive?: boolean;
236
+ readonly writeOnly?: boolean;
227
237
  readonly readAsInstruction?: boolean;
228
238
  readonly format?: "email" | "url" | "phone" | "password";
229
239
  readonly default?: string;
@@ -5,7 +5,7 @@ export type { ParsedRefTarget } from "../engine/parse-ref-target.js";
5
5
  export { parseRefTarget } from "../engine/parse-ref-target.js";
6
6
  export type { FieldsOrGroupsSection } from "../engine/screen-helpers.js";
7
7
  export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
8
- export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
8
+ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.333.0",
3
+ "version": "0.334.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.333.0",
210
- "@cosmicdrift/kumiko-types": "0.333.0",
209
+ "@cosmicdrift/kumiko-http": "0.334.0",
210
+ "@cosmicdrift/kumiko-types": "0.334.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.333.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.334.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.334.0",
4
+ "type": "improvement",
5
+ "title": "Settings form gaps: writeForm footer submit, option description/group, dependent optionsQueryPayload, config badge label, writeOnly secret fields",
6
+ "detail": "- A `writeForm` section that fills a whole tab puts its submit button into the pinned form footer.\n- `optionsQuery` rows may carry `description` (muted second line) and `group` (heading). The combobox and the radio list show both. Options with either one never render as segments.\n- `optionsQueryPayload` values may be `{ field: \"<sibling>\" }`. The select reloads when that field changes and clears a value the new rows no longer contain. A cleared config select resets the key, so the inherited value applies again. On config keys, `field` names another key of the same feature on the same settings mask. The boot validator checks the names, and `writeForm` fieldDefs now go through the select checks too.\n- The origin line and cascade level rows on `configEdit` fields show the option label instead of the raw value or id.\n- New `writeOnly: true` on entity text fields with `find: \"secret\"`. Reads return `true` (set) or `null` and never the value. On write, `\"\"` keeps the stored value and `null` clears it. The edit form shows a masked input with a \"set\" placeholder and a remove action. `maskWriteOnlyFields(entity, row)` is exported from `@cosmicdrift/kumiko-framework/engine` for custom query handlers that return executor rows."
7
+ },
8
+ {
9
+ "version": "0.334.0",
10
+ "type": "breaking",
11
+ "title": "Duplicate write/query handler names in one feature fail at definition time",
12
+ "migration": "Rename a custom handler that shares its name with another handler of the same feature, including names r.crud registers (<entity>:list, <entity>:detail, <entity>:create, <entity>:update, <entity>:delete, <entity>:restore). Before, the later registration replaced the earlier one silently."
13
+ },
2
14
  {
3
15
  "version": "0.332.0",
4
16
  "type": "fix",