@cosmicdrift/kumiko-framework 0.333.0 → 0.335.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 (172) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -14,6 +14,9 @@
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";
19
+ import { isEncryptedAtRest } from "./config-helpers.js";
17
20
  import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
18
21
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
19
22
  import { isKebabSegment, toKebab } from "./qualified-name.js";
@@ -35,6 +38,14 @@ const SCOPE_ICON = {
35
38
  user: "user",
36
39
  };
37
40
  const SCOPES_BROAD_TO_DEEP = ["system", "tenant", "user"];
41
+ const audienceDescriptionKey = (scope) => `config.settings.audience.${scope}`;
42
+ const PROVIDER_PANEL_TITLE_ENTRY = {
43
+ en: "Provider",
44
+ de: "Anbieter",
45
+ es: "Proveedor",
46
+ };
47
+ const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
48
+ const SAVE_PROVIDER_LABEL_KEY = "config.settings.saveProvider";
38
49
  const audienceNavShortId = (scope) => `audience-${scope}`;
39
50
  // 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
51
  export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
@@ -212,7 +223,11 @@ function assertUniquePanelIds(ownerGroup, panels) {
212
223
  function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
213
224
  const selectionScreenId = `${ownerGroup}-tenant-selection`;
214
225
  const selectionScreen = {
215
- ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub),
226
+ ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
227
+ description: EXTENSION_SELECTOR_HINT_KEY,
228
+ sectionTitle: PROVIDER_SECTION_TITLE_KEY,
229
+ submitLabel: SAVE_PROVIDER_LABEL_KEY,
230
+ }),
216
231
  dormant: true,
217
232
  };
218
233
  const screens = [selectionScreen];
@@ -220,10 +235,8 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
220
235
  const secretsPanels = [];
221
236
  const accessRules = [ownerAccess];
222
237
  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;
238
+ translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
239
+ Object.assign(translations, providerOptionTranslations(selectionScreen, plugins, hub));
227
240
  for (const plugin of plugins) {
228
241
  const visibleWhen = {
229
242
  query: SELECTED_EXTENSIONS_QUERY,
@@ -234,7 +247,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
234
247
  const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
235
248
  const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
236
249
  screens.push({
237
- ...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub),
250
+ ...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
238
251
  dormant: true,
239
252
  });
240
253
  accessRules.push(pluginAccess);
@@ -243,6 +256,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
243
256
  id: `${plugin.feature}-config`,
244
257
  screen: pluginTenantScreenId(plugin),
245
258
  visibleWhen,
259
+ chromeless: true,
246
260
  });
247
261
  }
248
262
  if (plugin.secrets.length > 0) {
@@ -256,6 +270,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
256
270
  id: `${plugin.feature}-secrets`,
257
271
  screen: secretsScreenId,
258
272
  visibleWhen,
273
+ chromeless: true,
259
274
  });
260
275
  }
261
276
  }
@@ -263,9 +278,9 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
263
278
  const dashboard = {
264
279
  id: `${ownerGroup}-tenant`,
265
280
  type: "dashboard",
266
- description: EXTENSION_SELECTOR_HINT_KEY,
281
+ showUpdatedAt: false,
267
282
  panels: assertUniquePanelIds(ownerGroup, [
268
- { kind: "screen", id: "selection", screen: selectionScreenId },
283
+ { kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
269
284
  ...ownerPanels,
270
285
  ...configPanels,
271
286
  ...secretsPanels,
@@ -479,10 +494,12 @@ function buildSettingsWorkspace(navs) {
479
494
  navMembers,
480
495
  };
481
496
  }
482
- function buildScreen(shortId, scope, feature, keys, access, hub) {
497
+ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
483
498
  const configKeys = {};
484
499
  const fields = {};
485
500
  const fieldLabels = {};
501
+ const fieldDescriptions = {};
502
+ const requiredFields = [];
486
503
  // Field id collapses to the plain shortKey when the key stays in its own
487
504
  // feature's group (100% of today's apps — byte-identical output). Only a
488
505
  // cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
@@ -497,18 +514,16 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
497
514
  }
498
515
  seenFieldIds.set(id, k.qn);
499
516
  configKeys[id] = k.qn;
500
- fields[id] = deriveField(k.def, hub.selectorOptions.get(k.qn));
517
+ fields[id] = deriveField(k, hub.selectorOptions.get(k.qn), keys, fieldId);
501
518
  // mask is the visibility gate, so collectMaskedKeys guarantees it here.
502
519
  if (k.def.mask)
503
520
  fieldLabels[id] = k.def.mask.title;
521
+ if (k.def.mask?.description !== undefined)
522
+ fieldDescriptions[id] = k.def.mask.description;
523
+ if (k.def.required === true)
524
+ requiredFields.push(id);
504
525
  }
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
- };
526
+ const { section, screenDescription } = buildSettingsSection(feature, scope, keys.map(fieldId), hub, options);
512
527
  return {
513
528
  id: shortId,
514
529
  type: "configEdit",
@@ -516,16 +531,79 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
516
531
  configKeys,
517
532
  fields,
518
533
  fieldLabels,
519
- layout: { sections: [section], width: "full" },
534
+ ...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
535
+ ...(requiredFields.length > 0 && { requiredFields }),
536
+ ...(screenDescription !== undefined && { description: screenDescription }),
537
+ ...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
538
+ layout: { sections: [section], width: "full", variant: "settings-list" },
520
539
  access,
521
540
  };
522
541
  }
523
- function deriveField(def, selectorPluginIds) {
542
+ // `{ field: <shortKey> }` names another key of the owner feature; on the form it
543
+ // has to point at that key's generated field id, so it must sit on the same mask.
544
+ function rewriteOptionsQueryFieldRefs(key, payload, screenKeys, fieldIdOf) {
545
+ return Object.fromEntries(Object.entries(payload).map(([name, value]) => {
546
+ if (!isOptionsQueryFieldRef(value))
547
+ return [name, value];
548
+ const target = screenKeys.find((k) => k.ownerFeature === key.ownerFeature && k.shortKey === toKebab(value.field));
549
+ if (target === undefined) {
550
+ 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.`);
551
+ }
552
+ return [name, { field: fieldIdOf(target) }];
553
+ }));
554
+ }
555
+ function buildSettingsSection(feature, scope, fieldIds, hub, options) {
556
+ // translate() echoes an undeclared key, so an ungated description would render raw.
557
+ const descriptionKey = `${feature}.settings.description`;
558
+ const featureDescription = hub.declaredTranslationKeys.has(descriptionKey)
559
+ ? descriptionKey
560
+ : undefined;
561
+ const audienceDescription = options.description === null
562
+ ? undefined
563
+ : (options.description ?? audienceDescriptionKey(scope));
564
+ // The section's header column holds one sentence: the feature's own, else the
565
+ // audience one. When both exist the audience sentence stays on the screen.
566
+ const sectionDescription = featureDescription ?? audienceDescription;
567
+ const screenDescription = featureDescription !== undefined ? audienceDescription : undefined;
568
+ // A feature whose page title equals its `<feature>.settings` label would have
569
+ // the section heading dropped as a duplicate; `.settings.section` lets it
570
+ // declare a distinct heading.
571
+ const sectionKey = `${feature}.settings.section`;
572
+ const section = {
573
+ title: options.sectionTitle ??
574
+ (hub.declaredTranslationKeys.has(sectionKey) ? sectionKey : `${feature}.settings`),
575
+ ...(sectionDescription !== undefined && { description: sectionDescription }),
576
+ fields: fieldIds,
577
+ };
578
+ return { section, screenDescription };
579
+ }
580
+ // Option labels of the provider select: each plugin feature's `<feature>.settings`
581
+ // copy, under the option-label key the configEdit renderer already derives.
582
+ function providerOptionTranslations(selectionScreen, plugins, hub) {
583
+ const out = {};
584
+ for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
585
+ if (!hub.selectorOptions.has(qn))
586
+ continue;
587
+ for (const plugin of plugins) {
588
+ const label = hub.translationsByKey.get(`${plugin.feature}.settings`);
589
+ if (label === undefined)
590
+ continue;
591
+ const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${plugin.pluginId}`;
592
+ out[key] = label;
593
+ }
594
+ }
595
+ return out;
596
+ }
597
+ function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
598
+ const def = key.def;
524
599
  if (selectorPluginIds !== undefined)
525
600
  return createSelectField({ options: selectorPluginIds });
526
601
  switch (def.type) {
527
602
  case "number":
528
- return createNumberField();
603
+ return createNumberField({
604
+ ...(def.bounds?.min !== undefined && { min: def.bounds.min }),
605
+ ...(def.bounds?.max !== undefined && { max: def.bounds.max }),
606
+ });
529
607
  case "boolean":
530
608
  return createBooleanField();
531
609
  case "select":
@@ -534,7 +612,7 @@ function deriveField(def, selectorPluginIds) {
534
612
  options: [],
535
613
  optionsQuery: def.optionsQuery,
536
614
  ...(def.optionsQueryPayload !== undefined && {
537
- optionsQueryPayload: def.optionsQueryPayload,
615
+ optionsQueryPayload: rewriteOptionsQueryFieldRefs(key, def.optionsQueryPayload, screenKeys, fieldIdOf),
538
616
  }),
539
617
  });
540
618
  }
@@ -542,7 +620,13 @@ function deriveField(def, selectorPluginIds) {
542
620
  ? createSelectField({ options: def.options })
543
621
  : createTextField({ personal: false, reason: "system_metadata" });
544
622
  default:
545
- return createTextField({ personal: false, reason: "system_metadata" });
623
+ // A stored secret never goes back to the client: the form shows set/empty
624
+ // and sends only a replacement or a clear.
625
+ return createTextField({
626
+ personal: false,
627
+ reason: "system_metadata",
628
+ ...(def.type === "text" && isEncryptedAtRest(def) && { writeOnly: true }),
629
+ });
546
630
  }
547
631
  }
548
632
  function collectMaskedKeys(registry) {
@@ -54,25 +54,35 @@ function parseBlock(raw, source) {
54
54
  function proseWithoutMetadata(markdown) {
55
55
  const withoutFrontmatter = markdown.replace(/^\s*---\n[\s\S]*?\n---\s*/, "");
56
56
  return withoutFrontmatter
57
- .replace(BLOCK_RE, "")
58
57
  .split("\n")
59
58
  .map((line) => line.trim())
60
59
  .filter((line) => line !== "");
61
60
  }
61
+ function sameTitle(title, line) {
62
+ const normalize = (text) => text.replace(/[.\s]+$/, "").toLowerCase();
63
+ return line !== undefined && normalize(title) === normalize(line);
64
+ }
65
+ // BLOCK_RE closes at the first line starting with "-->"; text after it on
66
+ // that line means a field value contained one and the block ended early.
67
+ function assertBlockNotClosedEarly(markdown, match, source) {
68
+ const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
69
+ if (restOfLine.trim() !== "") {
70
+ throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
71
+ }
72
+ }
62
73
  /** Parse structured upgrade metadata embedded in a Changeset body. */
63
74
  export function parseChangesetChanges(markdown, source) {
64
75
  const changes = [];
65
- const prose = proseWithoutMetadata(markdown);
76
+ // Each block only inherits prose from the markdown between the previous block
77
+ // and itself; document-wide prose would give two features the same title.
78
+ let segmentStart = 0;
66
79
  BLOCK_RE.lastIndex = 0;
67
80
  let match = BLOCK_RE.exec(markdown);
68
81
  while (match !== null) {
69
82
  const block = match[1];
70
- // BLOCK_RE closes at the first line starting with "-->"; text after it on
71
- // that line means a field value contained one and the block ended early.
72
- const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
73
- if (restOfLine.trim() !== "") {
74
- throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
75
- }
83
+ const prose = proseWithoutMetadata(markdown.slice(segmentStart, match.index));
84
+ segmentStart = match.index + match[0].length;
85
+ assertBlockNotClosedEarly(markdown, match, source);
76
86
  if (!block)
77
87
  throw new Error(`${source}: empty kumiko-changes block`);
78
88
  const fields = parseBlock(block, source);
@@ -81,12 +91,16 @@ export function parseChangesetChanges(markdown, source) {
81
91
  if (!TYPE_VALUES.has(rawType)) {
82
92
  throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
83
93
  }
84
- const title = fields.get("title")?.trim() || prose[0];
94
+ const explicitTitle = fields.get("title")?.trim();
95
+ const title = explicitTitle || prose[0];
85
96
  if (!title)
86
97
  throw new Error(`${source}: kumiko-changes title is required`);
98
+ // The first prose line is the title's source only when no title was given or
99
+ // the explicit one restates it; otherwise it is genuine detail.
100
+ const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
87
101
  const detail = fields.get("detail")?.trim() ||
88
102
  prose
89
- .slice(prose.length > 0 ? 1 : 0)
103
+ .slice(firstLineIsTitle ? 1 : 0)
90
104
  .join("\n")
91
105
  .trim();
92
106
  const migration = fields.get("migration")?.trim();
@@ -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
@@ -61,6 +61,12 @@ function extractEventMigrationsField(node, sourceFile) {
61
61
  : undefined;
62
62
  return result && Object.keys(result).length > 0 ? result : undefined;
63
63
  }
64
+ // `{ piiFields }` shorthand is a valid stance; its name node doubles as the location.
65
+ function readPiiFieldsNode(obj) {
66
+ const prop = obj?.getProperty("piiFields");
67
+ return (prop?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer() ??
68
+ prop?.asKind(SyntaxKind.ShorthandPropertyAssignment)?.getNameNode());
69
+ }
64
70
  export function extractDefineEvent(call, sourceFile) {
65
71
  const args = call.getArguments();
66
72
  const first = args[0];
@@ -94,10 +100,7 @@ export function extractDefineEvent(call, sourceFile) {
94
100
  if (typeof v === "number")
95
101
  version = v;
96
102
  }
97
- const piiFieldsInit = obj
98
- .getProperty("piiFields")
99
- ?.asKind(SyntaxKind.PropertyAssignment)
100
- ?.getInitializer();
103
+ const piiFieldsInit = readPiiFieldsNode(obj);
101
104
  if (!piiFieldsInit) {
102
105
  return fail("defineEvent", sourceLocationFromNode(call, sourceFile), 'requires an explicit `piiFields` stance (an object literal or "none")');
103
106
  }
@@ -130,11 +133,11 @@ export function extractDefineEvent(call, sourceFile) {
130
133
  let migrations;
131
134
  const optionsArg = args[2];
132
135
  const optionsObj = optionsArg?.asKind(SyntaxKind.ObjectLiteralExpression);
133
- const piiFieldsInit = optionsObj
134
- ?.getProperty("piiFields")
135
- ?.asKind(SyntaxKind.PropertyAssignment)
136
- ?.getInitializer();
136
+ const piiFieldsInit = readPiiFieldsNode(optionsObj);
137
137
  if (!piiFieldsInit) {
138
+ if (optionsArg && !optionsObj) {
139
+ return fail("defineEvent", sourceLocationFromNode(call, sourceFile), "options must be an object literal");
140
+ }
138
141
  return fail("defineEvent", sourceLocationFromNode(call, sourceFile), 'requires an explicit `piiFields` stance (an object literal or "none")');
139
142
  }
140
143
  if (optionsObj) {
@@ -62,6 +62,28 @@ const HANDLER_KEY_KINDS = {
62
62
  function lookupClassification(map, key) {
63
63
  return key in map ? map[key] : undefined;
64
64
  }
65
+ function isStaticStringNode(node) {
66
+ const kind = node.getKind();
67
+ return kind === SyntaxKind.StringLiteral || kind === SyntaxKind.NoSubstitutionTemplateLiteral;
68
+ }
69
+ function isBooleanLiteralNode(node) {
70
+ const kind = node.getKind();
71
+ return kind === SyntaxKind.TrueKeyword || kind === SyntaxKind.FalseKeyword;
72
+ }
73
+ // The literal readers below only understand a static string `description` and
74
+ // a true/false `unsafeSkipTransitionGuard`; any other value shape is lossy.
75
+ function hasUnreadableLiteralValue(key, initializer) {
76
+ if (key === "description")
77
+ return !(initializer && isStaticStringNode(initializer));
78
+ if (key === "unsafeSkipTransitionGuard")
79
+ return !(initializer && isBooleanLiteralNode(initializer));
80
+ return false;
81
+ }
82
+ function readStaticStringProperty(obj, key) {
83
+ const initializer = obj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
84
+ return (initializer?.asKind(SyntaxKind.StringLiteral) ??
85
+ initializer?.asKind(SyntaxKind.NoSubstitutionTemplateLiteral))?.getLiteralValue();
86
+ }
65
87
  // True when the call's object body/options carry a shape the extractor
66
88
  // cannot losslessly model property-by-property (a spread, a method/accessor
67
89
  // shorthand, a computed key, or a key outside the classification map above).
@@ -76,13 +98,21 @@ function hasUnmodeledShape(obj, methodName) {
76
98
  if (propAssign) {
77
99
  if (propAssign.getNameNode().getKind() === SyntaxKind.ComputedPropertyName)
78
100
  return true;
79
- if (lookupClassification(keyKinds, readPropertyKey(propAssign)) !== "modeled")
101
+ const key = readPropertyKey(propAssign);
102
+ if (lookupClassification(keyKinds, key) !== "modeled")
103
+ return true;
104
+ const initializer = propAssign.getInitializer();
105
+ if (hasUnreadableLiteralValue(key, initializer))
80
106
  return true;
81
107
  continue;
82
108
  }
83
109
  const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
84
110
  if (shorthand) {
85
- if (lookupClassification(keyKinds, shorthand.getName()) !== "modeled")
111
+ const key = shorthand.getName();
112
+ if (lookupClassification(keyKinds, key) !== "modeled")
113
+ return true;
114
+ // A shorthand's value is invisible to the literal readers below.
115
+ if (key === "description" || key === "unsafeSkipTransitionGuard")
86
116
  return true;
87
117
  continue;
88
118
  }
@@ -197,11 +227,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
197
227
  const headerResult = readHandlerHeaderFields(obj, methodName, sourceFile);
198
228
  if (headerResult.kind === "error")
199
229
  return headerResult;
200
- const descriptionLiteral = obj
201
- .getProperty("description")
202
- ?.asKind(SyntaxKind.PropertyAssignment)
203
- ?.getInitializer()
204
- ?.asKind(SyntaxKind.StringLiteral);
230
+ const description = readStaticStringProperty(obj, "description");
205
231
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
206
232
  return ok({
207
233
  source: sourceLocationFromNode(call, sourceFile),
@@ -209,9 +235,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
209
235
  schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
210
236
  handlerBody: sourceLocationFromNode(fn, sourceFile),
211
237
  ...headerResult.pattern,
212
- ...(descriptionLiteral !== undefined && {
213
- description: descriptionLiteral.getLiteralValue(),
214
- }),
238
+ ...(description !== undefined && { description }),
215
239
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
216
240
  });
217
241
  }
@@ -244,6 +268,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
244
268
  const optionsArg = args[3];
245
269
  let headerFields = {};
246
270
  let description;
271
+ let skipTransitionGuard;
247
272
  if (optionsArg) {
248
273
  const optionsObj = unwrapObjectLiteral(optionsArg);
249
274
  if (!optionsObj || hasUnmodeledShape(optionsObj, methodName)) {
@@ -253,12 +278,8 @@ export function parseHandlerCall(call, sourceFile, methodName) {
253
278
  if (headerResult.kind === "error")
254
279
  return headerResult;
255
280
  headerFields = headerResult.pattern;
256
- description = optionsObj
257
- .getProperty("description")
258
- ?.asKind(SyntaxKind.PropertyAssignment)
259
- ?.getInitializer()
260
- ?.asKind(SyntaxKind.StringLiteral)
261
- ?.getLiteralValue();
281
+ description = readStaticStringProperty(optionsObj, "description");
282
+ skipTransitionGuard = readBooleanProperty(optionsObj, "unsafeSkipTransitionGuard");
262
283
  }
263
284
  return ok({
264
285
  source: sourceLocationFromNode(call, sourceFile),
@@ -267,6 +288,7 @@ export function parseHandlerCall(call, sourceFile, methodName) {
267
288
  handlerBody: sourceLocationFromNode(fn, sourceFile),
268
289
  ...headerFields,
269
290
  ...(description !== undefined && { description }),
291
+ ...(skipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
270
292
  });
271
293
  }
272
294
  export function extractWriteHandler(call, sourceFile) {
@@ -1,4 +1,5 @@
1
- import type { Node, SourceFile, VariableDeclaration } from "ts-morph";
1
+ import type { SourceFile, VariableDeclaration } from "ts-morph";
2
+ import { Node } from "ts-morph";
2
3
  export declare function resolveModuleFile(fromFile: SourceFile, specifier: string): SourceFile | undefined;
3
4
  export declare function findImportBindingForLocalName(sourceFile: SourceFile, localName: string): {
4
5
  readonly moduleSpecifier: string;
@@ -10,5 +11,9 @@ export declare function findImportBindingForLocalName(sourceFile: SourceFile, lo
10
11
  * the cycle guard; `depth` backstops pathological non-cyclic chains.
11
12
  */
12
13
  export declare function resolveExportedVariable(file: SourceFile, exportedName: string, visited?: Set<string>, depth?: number): VariableDeclaration | undefined;
13
- /** A function parameter of the same name shadows and stops the search — it has no statically readable initializer. */
14
+ /**
15
+ * Any binding of the same name that is not a plain `const NAME = <init>`
16
+ * (parameter, destructuring, loop variable, catch variable) shadows and ends
17
+ * the search — it has no statically readable initializer.
18
+ */
14
19
  export declare function findScopedVariableDeclaration(identifier: Node, name: string): VariableDeclaration | undefined;
@@ -1,4 +1,4 @@
1
- import { SyntaxKind, ts } from "ts-morph";
1
+ import { Node, SyntaxKind, ts } from "ts-morph";
2
2
  const MODULE_RESOLUTION_OPTIONS = {
3
3
  moduleResolution: ts.ModuleResolutionKind.Bundler,
4
4
  module: ts.ModuleKind.ESNext,
@@ -112,11 +112,14 @@ function resolveViaStarExport(file, exportedName, visited, depth) {
112
112
  }
113
113
  return undefined;
114
114
  }
115
- /** A function parameter of the same name shadows and stops the search — it has no statically readable initializer. */
115
+ /**
116
+ * Any binding of the same name that is not a plain `const NAME = <init>`
117
+ * (parameter, destructuring, loop variable, catch variable) shadows and ends
118
+ * the search — it has no statically readable initializer.
119
+ */
116
120
  export function findScopedVariableDeclaration(identifier, name) {
117
121
  for (const ancestor of [identifier, ...identifier.getAncestors()]) {
118
- const params = getShadowingParameters(ancestor);
119
- if (params?.some((p) => p.getName() === name))
122
+ if (declaresShadowingBinding(ancestor, name))
120
123
  return undefined;
121
124
  const statements = getDirectStatements(ancestor);
122
125
  if (!statements)
@@ -125,21 +128,45 @@ export function findScopedVariableDeclaration(identifier, name) {
125
128
  const varStmt = stmt.asKind(SyntaxKind.VariableStatement);
126
129
  if (!varStmt)
127
130
  continue;
128
- const decl = varStmt
129
- .getDeclarationList()
130
- .getDeclarations()
131
- .find((d) => d.getName() === name);
132
- if (decl)
133
- return decl;
131
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
132
+ const nameNode = decl.getNameNode();
133
+ if (Node.isIdentifier(nameNode)) {
134
+ if (nameNode.getText() === name)
135
+ return decl;
136
+ }
137
+ else if (bindingNames(nameNode).includes(name)) {
138
+ return undefined;
139
+ }
140
+ }
134
141
  }
135
142
  }
136
143
  return undefined;
137
144
  }
138
- function getShadowingParameters(node) {
139
- const fn = node.asKind(SyntaxKind.ArrowFunction) ??
140
- node.asKind(SyntaxKind.FunctionExpression) ??
141
- node.asKind(SyntaxKind.FunctionDeclaration);
142
- return fn?.getParameters();
145
+ function bindingNames(nameNode) {
146
+ if (Node.isIdentifier(nameNode))
147
+ return [nameNode.getText()];
148
+ if (Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) {
149
+ return nameNode
150
+ .getElements()
151
+ .flatMap((element) => Node.isBindingElement(element) ? bindingNames(element.getNameNode()) : []);
152
+ }
153
+ return [];
154
+ }
155
+ function declaresShadowingBinding(node, name) {
156
+ if (Node.isParametered(node)) {
157
+ return node.getParameters().some((p) => bindingNames(p.getNameNode()).includes(name));
158
+ }
159
+ if (Node.isForOfStatement(node) || Node.isForInStatement(node) || Node.isForStatement(node)) {
160
+ const initializer = node.getInitializer();
161
+ return (initializer !== undefined &&
162
+ Node.isVariableDeclarationList(initializer) &&
163
+ initializer.getDeclarations().some((decl) => bindingNames(decl.getNameNode()).includes(name)));
164
+ }
165
+ if (Node.isCatchClause(node)) {
166
+ const decl = node.getVariableDeclaration();
167
+ return decl !== undefined && bindingNames(decl.getNameNode()).includes(name);
168
+ }
169
+ return false;
143
170
  }
144
171
  function getDirectStatements(node) {
145
172
  const block = node.asKind(SyntaxKind.Block);
@@ -28,3 +28,4 @@ export declare function parseFeatureFile(filePath: string): ParseResult;
28
28
  * instance and avoids re-IO per parse).
29
29
  */
30
30
  export declare function parseSourceFile(sourceFile: SourceFile): ParseResult;
31
+ export declare function relativePosixPath(fromDirectory: string, filePath: string): string;
@@ -68,16 +68,31 @@ export function parseSourceFile(sourceFile) {
68
68
  const errors = [];
69
69
  walkSetupCallback(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
70
70
  walkAiStepCalls(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
71
- // Tie-break on filePath so cross-file registrar wrappers don't interleave by
72
- // foreign line numbers alone.
71
+ // Tie-break on the path relative to the entry file's directory so cross-file
72
+ // registrar wrappers don't interleave by foreign line numbers alone, without
73
+ // the order depending on where the repo is checked out (worktrees).
74
+ const parseRoot = sourceFile.getDirectoryPath();
73
75
  patterns.sort((a, b) => {
74
- const fileCmp = a.source.file.localeCompare(b.source.file);
76
+ const fileCmp = relativePosixPath(parseRoot, a.source.file).localeCompare(relativePosixPath(parseRoot, b.source.file));
75
77
  if (fileCmp !== 0)
76
78
  return fileCmp;
77
79
  return a.source.start.line - b.source.start.line;
78
80
  });
79
81
  return { featureName, patterns, errors };
80
82
  }
83
+ // node:path is avoided: the Designer runs this against an in-memory file
84
+ // system outside Node. ts-morph paths are always posix-style.
85
+ export function relativePosixPath(fromDirectory, filePath) {
86
+ const fromSegments = fromDirectory.split("/").filter(Boolean);
87
+ const fileSegments = filePath.split("/").filter(Boolean);
88
+ let common = 0;
89
+ while (common < fromSegments.length &&
90
+ common < fileSegments.length - 1 &&
91
+ fromSegments[common] === fileSegments[common]) {
92
+ common += 1;
93
+ }
94
+ return [...fromSegments.slice(common).map(() => ".."), ...fileSegments.slice(common)].join("/");
95
+ }
81
96
  // =============================================================================
82
97
  // Internal — locate defineFeature + setup callback
83
98
  // =============================================================================