@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
@@ -81,19 +81,28 @@ function validatePersonalDataValue(feature, handlerName, access) {
81
81
  `access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`);
82
82
  }
83
83
  // skip: roles include "anonymous", which personalData: "public-intake" requires
84
- if (Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE))
84
+ if (accessAllowsAnonymous(access))
85
85
  return;
86
86
  throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
87
87
  'access.personalData: "public-intake" but its roles do not include ' +
88
88
  `"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
89
89
  "that allow anonymous callers.");
90
90
  }
91
+ function handlerInputKeys(feature, handlerName, handler) {
92
+ try {
93
+ return collectZodObjectKeys(handler.schema);
94
+ }
95
+ catch (error) {
96
+ const reason = error instanceof Error ? error.message : String(error);
97
+ throw new Error(`[Feature ${feature.name}] write handler "${handlerName}": ${reason}`);
98
+ }
99
+ }
91
100
  function validateOpenToAllPersonalData(feature, handlerName, handler) {
92
101
  const access = handler.access;
93
102
  // skip: no openToAll declared, or the handler declares tenant members may write personal data
94
103
  if (!hasOpenToAll(access) || declaresTenantMembersPersonalData(access))
95
104
  return;
96
- const inputKeys = collectZodObjectKeys(handler.schema);
105
+ const inputKeys = handlerInputKeys(feature, handlerName, handler);
97
106
  const personalNames = candidatePersonalFieldNames(feature, handlerName, handler);
98
107
  const offending = [...inputKeys].filter((key) => personalNames.has(key));
99
108
  // skip: no personal-data fields in the handler's input
@@ -114,7 +123,7 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
114
123
  // skip: not reachable by an anonymous caller, or already declares public-intake
115
124
  if (!accessAllowsAnonymous(access) || declaredPersonalData(access) === "public-intake")
116
125
  return;
117
- const inputKeys = collectZodObjectKeys(handler.schema);
126
+ const inputKeys = handlerInputKeys(feature, handlerName, handler);
118
127
  const personalNames = candidatePersonalFieldNames(feature, handlerName, handler, false);
119
128
  const offending = [...inputKeys].filter((key) => personalNames.has(key));
120
129
  // skip: no personal-data fields in the handler's input
@@ -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 ---
@@ -1,5 +1,6 @@
1
1
  import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
3
+ import { access as accessPresets } from "../config-helpers.js";
3
4
  import { parseRefTarget } from "../parse-ref-target.js";
4
5
  export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
5
6
  // Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
@@ -150,7 +151,7 @@ export function validateNoAllRoleInHandlerAccess(featureName, kind, handlerName,
150
151
  if (!("roles" in access))
151
152
  return;
152
153
  // skip: no "all" in the roles list, nothing unreachable to report
153
- if (!access.roles.includes("all"))
154
+ if (!accessPresets.all.some((role) => access.roles.includes(role)))
154
155
  return;
155
156
  throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" declares access: { roles: ["all"] } — ` +
156
157
  `no session ever carries the role "all", so this handler is unreachable by any caller. ` +
@@ -578,8 +579,19 @@ function validateEmbeddedListMetadata(fieldName, entityName, field, entity) {
578
579
  // default — wenn gesetzt — ist eine Teilmenge der options. Beides würde
579
580
  // auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
580
581
  // die früheste Stelle für klare Fehlermeldungen.
582
+ // Exhaustive over MultiSelectFieldDef["columns"] (the compiler flags a union
583
+ // change); the TS union is gone at runtime for JS/JSON-sourced definitions.
584
+ const CHECKBOX_COLUMN_COUNTS = {
585
+ 1: true,
586
+ 2: true,
587
+ 3: true,
588
+ 4: true,
589
+ };
581
590
  function validateCheckboxDisplayOptions(field, fieldName, entityName) {
582
591
  if (field.display === "checkboxes") {
592
+ if (field.columns !== undefined && !(field.columns in CHECKBOX_COLUMN_COUNTS)) {
593
+ throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" has invalid columns "${field.columns}" — must be ${Object.keys(CHECKBOX_COLUMN_COUNTS).join(", ")}.`);
594
+ }
583
595
  if (field.maxRows !== undefined && (!Number.isInteger(field.maxRows) || field.maxRows < 1)) {
584
596
  throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" has invalid maxRows "${field.maxRows}" — must be a positive integer.`);
585
597
  }
@@ -1,4 +1,5 @@
1
1
  import type { FeatureDefinition } from "../types/index.js";
2
2
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
3
3
  export declare const SEARCHABLE_FALSE_WHITELIST: Set<string>;
4
+ export declare const LIST_ROW_META_COLUMN_NAMES: Set<string>;
4
5
  export declare function validateEntityListScreens(features: readonly FeatureDefinition[]): void;
@@ -1,13 +1,13 @@
1
1
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
2
2
  import { normalizeListColumn } from "../screen-helpers.js";
3
3
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
4
- export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
4
+ export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list", "cap-list"]);
5
5
  // Same set computeListViewModel (headless) can actually render — NOT
6
6
  // db/table-builder's rowMetaFieldNames(softDelete), which also carries
7
7
  // isDeleted/deletedAt/deletedById. Accepting those here would let a
8
8
  // softDelete column pass the boot gate and then throw at render-time
9
9
  // because the renderer's LIST_ROW_META_COLUMNS doesn't know them.
10
- const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
10
+ export const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
11
11
  function hasFilterableFields(feature, entityName) {
12
12
  const entities = feature.entities;
13
13
  if (!entities)
@@ -60,11 +60,11 @@ export function validateBoot(rawFeatures, options) {
60
60
  // `…:config:branding-title`; ohne toKebab failt jeder configEdit-Screen mit
61
61
  // multi-word Config-Key fälschlich. allConfigKeys oben nutzt das ältere
62
62
  // `feature.short`-Format für validateConfigReads.
63
- const allConfigKeyQns = new Set();
63
+ const configKeyDefsByQn = new Map();
64
64
  for (const f of features) {
65
- for (const key of Object.keys(f.configKeys)) {
65
+ for (const [key, def] of Object.entries(f.configKeys)) {
66
66
  allConfigKeys.add(`${f.name}.${key}`);
67
- allConfigKeyQns.add(qualifyEntityName(f.name, QnTypes.config, key));
67
+ configKeyDefsByQn.set(qualifyEntityName(f.name, QnTypes.config, key), def);
68
68
  }
69
69
  }
70
70
  // Collect all claim keys — the ownership-rule validator below resolves
@@ -153,7 +153,7 @@ export function validateBoot(rawFeatures, options) {
153
153
  // Function-Check hier überhaupt liefe.
154
154
  validateActionWiring(feature);
155
155
  validateFieldWiring(feature);
156
- validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns, screensByShortId, detailForScreens);
156
+ validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens);
157
157
  validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
158
158
  validateWorkspaces(feature, allNavQns);
159
159
  }
@@ -161,7 +161,7 @@ export function validateBoot(rawFeatures, options) {
161
161
  warnOnNavAccessInversion(allNavQns);
162
162
  const effectiveNavAllowlist = resolveNavAllowlist(options?.navAllowlist, allNavQns, allWorkspaceQns);
163
163
  if (effectiveNavAllowlist !== undefined) {
164
- warnOnUnreachableNavScreens(allNavQns, effectiveNavAllowlist, options?.navAllowlistExempt);
164
+ warnOnUnreachableNavScreens(allNavQns, effectiveNavAllowlist, options?.navAllowlistExempt, options?.navAllowlist === undefined ? "workspaces" : "explicit");
165
165
  }
166
166
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
167
167
  validateI18nSurfaceKeys(features);
@@ -188,7 +188,7 @@ export function validateBoot(rawFeatures, options) {
188
188
  // custom KMS provider) satisfies the requirement without env keys.
189
189
  validateEntityFieldEncryptionAvailable(options?.env);
190
190
  }
191
- if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
191
+ if (hasFileFields && !(options?.env ?? process.env)[FILE_STORAGE_PROVIDER_ENV]) {
192
192
  throw new Error(`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`);
193
193
  }
194
194
  validateConfigReads(features, allConfigKeys);
@@ -14,7 +14,8 @@ export declare function validateNavCycles(allNavQns: ReadonlyMap<string, NavDefi
14
14
  export declare function warnOnNavAccessInversion(allNavQns: ReadonlyMap<string, NavDefinition & {
15
15
  readonly featureName: string;
16
16
  }>): void;
17
+ export type NavAllowlistSource = "explicit" | "workspaces";
17
18
  export declare function warnOnUnreachableNavScreens(allNavQns: ReadonlyMap<string, NavDefinition & {
18
19
  readonly featureName: string;
19
- }>, allowedNavQns: ReadonlySet<string>, navAllowlistExempt?: ReadonlySet<string>): void;
20
+ }>, allowedNavQns: ReadonlySet<string>, navAllowlistExempt?: ReadonlySet<string>, allowlistSource?: NavAllowlistSource): void;
20
21
  export declare function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string>;
@@ -125,6 +125,8 @@ export function warnOnNavAccessInversion(allNavQns) {
125
125
  const leafRoles = navViewerRoles(navDef.access);
126
126
  if (parentRoles === undefined || leafRoles === undefined)
127
127
  continue;
128
+ if (leafRoles.length === 0)
129
+ continue; // leaf visible to nobody (empty roles or invalid openToAll) — not an inversion
128
130
  if (parentRoles.length === 0)
129
131
  continue; // parent already visible to nobody — a different problem
130
132
  if (leafRoles.some((role) => parentRoles.includes(role)))
@@ -136,41 +138,29 @@ export function warnOnNavAccessInversion(allNavQns) {
136
138
  `otherwise align the role sets.`);
137
139
  }
138
140
  }
139
- // `NavDefinition.screen` is authored already-qualified ("<feature>:screen:
140
- // <id>", see packages/types/src/nav.ts) — this only guards a short id
141
- // slipping through, since a wrong qualification here would make every nav
142
- // look orphaned (28 false positives on the real offlot-app schema instead
143
- // of the intended 8, see fw#3019 measurement).
144
- function qualifyNavScreenQn(featureName, screen) {
145
- return screen.includes(":screen:") ? screen : `${featureName}:screen:${screen}`;
146
- }
147
- // fw#3019: warn (never throw — a screen deliberately left out of the app's
148
- // sidebar is legitimate) when a declared nav's screen isn't reachable from
149
- // any allowlisted nav entry. Checking "nav QN not in allowlist" directly
150
- // warned 28x on the real offlot-app schema (58 navs, 30 allowed) — most of
151
- // them app-shell leaves that intentionally re-target a screen another,
152
- // allowlisted nav already reaches. Reachability warns only for a screen no
153
- // allowlisted nav points at, which is the actual "unreachable via sidebar"
154
- // bug (solon#113, offlot's VIN screen in pilot).
155
- export function warnOnUnreachableNavScreens(allNavQns, allowedNavQns, navAllowlistExempt = new Set()) {
141
+ export function warnOnUnreachableNavScreens(allNavQns, allowedNavQns, navAllowlistExempt = new Set(), allowlistSource = "explicit") {
156
142
  const reachableScreenQns = new Set();
157
143
  for (const [qn, navDef] of allNavQns) {
158
144
  if (!allowedNavQns.has(qn) || navDef.screen === undefined)
159
145
  continue;
160
- reachableScreenQns.add(qualifyNavScreenQn(navDef.featureName, navDef.screen));
146
+ reachableScreenQns.add(navDef.screen);
161
147
  }
162
148
  for (const [qn, navDef] of allNavQns) {
163
149
  if (allowedNavQns.has(qn) || navDef.screen === undefined || navAllowlistExempt.has(qn)) {
164
150
  continue;
165
151
  }
166
- const screenQn = qualifyNavScreenQn(navDef.featureName, navDef.screen);
152
+ // validateNavs already guarantees a registered, qualified screen QN
153
+ const screenQn = navDef.screen;
167
154
  if (reachableScreenQns.has(screenQn))
168
155
  continue;
156
+ const remedy = allowlistSource === "workspaces"
157
+ ? "add it to a workspace's `nav` array or set `workspaces` on the nav"
158
+ : "add it to the allowlist";
169
159
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
170
160
  console.warn(`[kumiko:boot] Nav entry "${qn}" declared by feature "${navDef.featureName}" points at ` +
171
161
  `screen "${screenQn}", which no allowlisted nav entry reaches — it is unreachable via ` +
172
162
  `the sidebar. If this is intentional (e.g. reachable through a generated hub), add ` +
173
- `"${qn}" to navAllowlistExempt; otherwise add it to the allowlist.`);
163
+ `"${qn}" to navAllowlistExempt; otherwise ${remedy}.`);
174
164
  }
175
165
  }
176
166
  // Roles we recognise at boot time. The framework has no explicit
@@ -1,4 +1,4 @@
1
- import type { OwnershipMap, WhereRule } from "../ownership.js";
1
+ import { type OwnershipMap, type WhereRule } from "../ownership.js";
2
2
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types/index.js";
3
3
  export type WhereRuleProbe = {
4
4
  readonly table: unknown;
@@ -1,4 +1,5 @@
1
1
  import { buildEntityTable } from "../../db/table-builder.js";
2
+ import { tableNameOf, } from "../ownership.js";
2
3
  import { SYSTEM_USER_ID } from "../system-user.js";
3
4
  import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
4
5
  import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule-lint.js";
@@ -68,7 +69,7 @@ export function validateOwnershipRules(feature, allClaimKeys, knownRoles) {
68
69
  const table = buildEntityTable(entityName, entity);
69
70
  const columns = tableColumnSqlNames(table);
70
71
  if (columns.size > 0) {
71
- probe = { table, tableName: entity.table ?? entityName, columns };
72
+ probe = { table, tableName: tableNameOf(table), columns };
72
73
  }
73
74
  }
74
75
  catch {
@@ -1,2 +1,4 @@
1
+ import type { EntityDefinition } from "../types/fields.js";
1
2
  import type { FeatureDefinition } from "../types/index.js";
3
+ export declare function entityHasAnonymizableSubjectField(fields: EntityDefinition["fields"]): boolean;
2
4
  export declare function validatePiiAndRetention(feature: FeatureDefinition): void;
@@ -17,6 +17,9 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
17
17
  function hasAnonymizableSubjectField(annot) {
18
18
  return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
19
19
  }
20
+ export function entityHasAnonymizableSubjectField(fields) {
21
+ return Object.values(fields).some((f) => hasAnonymizableSubjectField(f));
22
+ }
20
23
  function ownerFieldNamesReferencedByPersonalOf(fieldsByName) {
21
24
  const ownerFieldNames = new Set();
22
25
  for (const field of Object.values(fieldsByName)) {
@@ -26,6 +29,42 @@ function ownerFieldNamesReferencedByPersonalOf(fieldsByName) {
26
29
  }
27
30
  return ownerFieldNames;
28
31
  }
32
+ const WRITE_ONLY_FORBIDDEN_FLAGS = [
33
+ "default",
34
+ "searchable",
35
+ "sortable",
36
+ "filterable",
37
+ "lookupable",
38
+ ];
39
+ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
40
+ const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
41
+ if (field.type === "embedded") {
42
+ for (const [subName, subField] of Object.entries(field.schema)) {
43
+ if (subField.writeOnly === true) {
44
+ // @cast-boundary schema-walk
45
+ throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields.`);
46
+ }
47
+ }
48
+ // skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
49
+ return;
50
+ }
51
+ const flags = field; // @cast-boundary schema-walk
52
+ // skip: only writeOnly fields have constraints to validate
53
+ if (flags.writeOnly !== true)
54
+ return;
55
+ if (field.type !== "text") {
56
+ throw new Error(`${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields.`);
57
+ }
58
+ if (field.sensitive !== true) {
59
+ throw new Error(`${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`);
60
+ }
61
+ for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
62
+ const value = flags[flag];
63
+ if (value !== undefined && value !== false) {
64
+ throw new Error(`${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up.`);
65
+ }
66
+ }
67
+ }
29
68
  // --- PII / Subject-Key Annotations + Retention validation ---
30
69
  //
31
70
  // Drei Klassen von Checks:
@@ -92,19 +131,20 @@ export function validatePiiAndRetention(feature) {
92
131
  sensitiveFlags.encrypted !== true) {
93
132
  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
133
  }
134
+ validateWriteOnlyField(feature.name, entityName, fieldName, field);
95
135
  // Sorting reads the projection column — that stays ciphertext, so
96
136
  // sortable + subject annotation stays a boot-fail. searchable has
97
137
  // been allowed since #1610: the search consumer decrypts into the
98
138
  // derived index and forget purges those docs (see
99
139
  // createSearchEventConsumer). sensitive + searchable stays forbidden
100
- // (nobody-may-read-back).
140
+ // (never searchable).
101
141
  {
102
142
  const flags = field; // @cast-boundary schema-walk
103
143
  if (annotCount > 0 && flags.sortable === true) {
104
144
  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
145
  }
106
146
  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.`);
147
+ 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
148
  }
109
149
  }
110
150
  if (annot.userOwned) {
@@ -179,12 +219,12 @@ export function validatePiiAndRetention(feature) {
179
219
  // blockDelete with no field that can carry `anonymize` is fine;
180
220
  // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
181
221
  // instead (#1622, #2336).
182
- const entityHasAnonymizableSubjectField = Object.values(fieldsByName).some((f) => hasAnonymizableSubjectField(f));
222
+ const entityHasAnonymizableField = entityHasAnonymizableSubjectField(fieldsByName);
183
223
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
184
224
  const a = f; // @cast-boundary schema-walk
185
225
  return Boolean(a.anonymize);
186
226
  });
187
- if (entityHasAnonymizableSubjectField && !hasAnonymize) {
227
+ if (entityHasAnonymizableField && !hasAnonymize) {
188
228
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
189
229
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
190
230
  }
@@ -88,6 +88,9 @@ const RESERVED_LIST_PAYLOAD_KEYS = new Set([
88
88
  "filters",
89
89
  ]);
90
90
  function validateProjectionListDateRangeFacets(prefix, screen, schema) {
91
+ // Shared params overwrite each other in the payload, silently dropping the first pick.
92
+ // (Two facets on one field are already rejected by the generic duplicate-facet check.)
93
+ const usedParams = new Set();
91
94
  for (const facet of screen.facets ?? []) {
92
95
  if (facet.type !== "dateRange")
93
96
  continue;
@@ -100,6 +103,11 @@ function validateProjectionListDateRangeFacets(prefix, screen, schema) {
100
103
  throw new Error(`${prefix}: dateRange facet on "${facet.field}" names "${param}" as a bound, ` +
101
104
  `which is a reserved list-payload key — pick the query's own time-bound param names.`);
102
105
  }
106
+ if (usedParams.has(param)) {
107
+ throw new Error(`${prefix}: dateRange facet on "${facet.field}" names "${param}" which another dateRange ` +
108
+ `facet on this screen already uses — the facets would overwrite each other's bound.`);
109
+ }
110
+ usedParams.add(param);
103
111
  if (schemaAccepts(schema, param))
104
112
  continue;
105
113
  throw new Error(`${prefix}: dateRange facet on "${facet.field}" sends "${param}" but query ` +
@@ -1,5 +1,5 @@
1
1
  import { isPagedQueryHandler } from "../define-handler.js";
2
- import { normalizeListColumn } from "../screen-helpers.js";
2
+ import { normalizeListColumn, sectionFieldSpecs } from "../screen-helpers.js";
3
3
  import { metricField } from "../types/index.js";
4
4
  import { buildQueryHandlerMap } from "./projection-list-screens.js";
5
5
  import { validateActionFieldRefs } from "./screens.js";
@@ -37,8 +37,23 @@ function checkPagedHandlerOutputSchemaShape(queryHandlers) {
37
37
  throw new Error(`Query handler "${qn}" is a paged handler (definePagedQueryHandler) but its outputSchema does not describe the paged envelope { rows: [...], nextCursor, total? } — it has no "rows" field. Did you pass the row schema instead of wrapping it in the envelope?`);
38
38
  }
39
39
  }
40
+ // Usage-site twin of checkPagedHandlerOutputSchemaShape: a plain
41
+ // r.queryHandler/defineQueryHandler feeding a list surface has no paged
42
+ // brand, so declaring the row shape instead of the `{ rows, ... }` envelope
43
+ // would otherwise make every column check below a silent no-op.
44
+ function resolveListRowShape(queryHandlers, queryQn, usage) {
45
+ const outputSchema = queryHandlers.get(queryQn)?.outputSchema;
46
+ const rowShape = getZodRowShape(outputSchema);
47
+ if (rowShape !== undefined)
48
+ return rowShape;
49
+ const envelopeShape = getZodObjectShape(outputSchema);
50
+ // skip: no introspectable object schema (absent / union) — capability absent per module policy
51
+ if (envelopeShape === undefined || "rows" in envelopeShape)
52
+ return undefined;
53
+ throw new Error(`${usage} uses query "${queryQn}" as a list source, but its outputSchema does not describe the paged envelope { rows: [...], nextCursor, total? } — it has no "rows" field. Did you pass the row schema instead of wrapping it in the envelope?`);
54
+ }
40
55
  function checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen) {
41
- const rowShape = getZodRowShape(queryHandlers.get(screen.query)?.outputSchema);
56
+ const rowShape = resolveListRowShape(queryHandlers, screen.query, `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
42
57
  for (const column of screen.columns) {
43
58
  checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (projectionList) column "${field}" is not present in query "${screen.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
44
59
  }
@@ -47,7 +62,7 @@ function checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, scre
47
62
  for (const section of layout.sections) {
48
63
  if (section.kind !== "relatedList")
49
64
  continue;
50
- const rowShape = getZodRowShape(queryHandlers.get(section.query)?.outputSchema);
65
+ const rowShape = resolveListRowShape(queryHandlers, section.query, `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`);
51
66
  for (const column of section.columns) {
52
67
  checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}" column "${field}" is not present in query "${section.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
53
68
  }
@@ -77,6 +92,9 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
77
92
  }
78
93
  }
79
94
  }
95
+ if (screen.header.subtitleHref !== undefined) {
96
+ checkHeaderField("subtitleHref", screen.header.subtitleHref);
97
+ }
80
98
  if (screen.header.status !== undefined)
81
99
  checkHeaderField("status", screen.header.status);
82
100
  }
@@ -88,8 +106,26 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
88
106
  const field = metricField(metric);
89
107
  checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
90
108
  }
109
+ checkDetailSectionFields(recordShape, prefix, screen);
91
110
  checkRelatedListToolbarActionFields(recordShape, featureName, screenId, screen);
92
111
  }
112
+ // Field-grid sections read the enclosing record (the query result), so a typo
113
+ // is a silently-empty cell. writeForm sections are skipped on purpose: their
114
+ // fields are form inputs defined by `fieldDefs`, not necessarily record keys.
115
+ function checkDetailSectionFields(recordShape, prefix, screen) {
116
+ for (const section of screen.layout.sections) {
117
+ const countField = "countField" in section ? section.countField : undefined;
118
+ if (countField !== undefined) {
119
+ checkFieldExists(recordShape, countField, () => `${prefix} section "${section.title ?? "(untitled)"}" countField references field "${countField}" which is not present in query "${screen.query}"'s outputSchema.`);
120
+ }
121
+ if (section.kind !== undefined && section.kind !== "fields")
122
+ continue;
123
+ for (const entry of sectionFieldSpecs(section)) {
124
+ const field = typeof entry === "string" ? entry : entry.field;
125
+ checkFieldExists(recordShape, field, () => `${prefix} section "${section.title ?? section.id ?? "(untitled)"}" references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
126
+ }
127
+ }
128
+ }
93
129
  // relatedList toolbarActions' visible/params evaluate against the ENCLOSING
94
130
  // projectionDetail's record, not the relatedList's own row — reuses the
95
131
  // same per-action field-ref check as rowActions/toolbarActions elsewhere
@@ -139,7 +175,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
139
175
  }
140
176
  }
141
177
  else if (panel.kind === "list") {
142
- const rowShape = getZodRowShape(queryHandlers.get(panel.query)?.outputSchema);
178
+ const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
143
179
  for (const column of panel.columns) {
144
180
  checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" column "${field}" is not present in query "${panel.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
145
181
  }
@@ -154,7 +190,7 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
154
190
  }
155
191
  else if (screen.type === "entityList") {
156
192
  if (screen.expandableRow !== undefined) {
157
- const rowShape = getZodRowShape(queryHandlers.get(screen.expandableRow.query)?.outputSchema);
193
+ const rowShape = resolveListRowShape(queryHandlers, screen.expandableRow.query, `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow.title}"`);
158
194
  for (const column of screen.expandableRow.columns) {
159
195
  checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}" column "${field}" is not present in query "${screen.expandableRow?.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
160
196
  }
@@ -1,5 +1,5 @@
1
1
  import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
2
- import type { FeatureDefinition } from "../types/index.js";
2
+ import type { ConfigKeyDefinition, FeatureDefinition } from "../types/index.js";
3
3
  import type { DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
4
4
  export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
5
5
  export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
@@ -13,7 +13,7 @@ export declare function validateScreenShortIdCollisions(screensByShortId: Readon
13
13
  readonly screen: ScreenDefinition;
14
14
  }>>): void;
15
15
  export declare function resolveScreenTargetQn(featureName: string, target: string): string;
16
- export declare function validateScreens(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, allWriteHandlerQns: ReadonlySet<string>, allScreenQns: ReadonlySet<string>, allConfigKeyQns: ReadonlySet<string>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
16
+ export declare function validateScreens(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, allWriteHandlerQns: ReadonlySet<string>, allScreenQns: ReadonlySet<string>, configKeyDefsByQn: ReadonlyMap<string, ConfigKeyDefinition>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
17
17
  readonly featureName: string;
18
18
  readonly screen: ScreenDefinition;
19
19
  }>>, detailForScreens: ReadonlyMap<string, {