@cosmicdrift/kumiko-framework 0.2.2 → 0.3.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 (191) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +124 -38
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/auth/__tests__/roles.test.ts +24 -0
  10. package/src/auth/index.ts +7 -0
  11. package/src/auth/roles.ts +42 -0
  12. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  13. package/src/compliance/__tests__/profiles.test.ts +308 -0
  14. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  15. package/src/compliance/duration-spec.ts +44 -0
  16. package/src/compliance/index.ts +31 -0
  17. package/src/compliance/override-schema.ts +136 -0
  18. package/src/compliance/profiles.ts +427 -0
  19. package/src/compliance/sub-processors.ts +152 -0
  20. package/src/db/__tests__/big-int-field.test.ts +131 -0
  21. package/src/db/assert-exists-in.ts +2 -2
  22. package/src/db/cursor.ts +3 -3
  23. package/src/db/event-store-executor.ts +19 -13
  24. package/src/db/located-timestamp.ts +1 -1
  25. package/src/db/money.ts +12 -2
  26. package/src/db/pg-error.ts +1 -1
  27. package/src/db/row-helpers.ts +1 -1
  28. package/src/db/table-builder.ts +20 -5
  29. package/src/db/tenant-db.ts +9 -9
  30. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  31. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  32. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  33. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  34. package/src/engine/__tests__/build-target.test.ts +135 -0
  35. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  36. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  37. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  38. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  39. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  40. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  41. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  42. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  43. package/src/engine/__tests__/raw-table.test.ts +2 -2
  44. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  45. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  46. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  47. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  48. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  49. package/src/engine/__tests__/steps-read.test.ts +142 -0
  50. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  51. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  52. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  53. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  54. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  55. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  56. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  57. package/src/engine/boot-validator/api-ext.ts +77 -0
  58. package/src/engine/boot-validator/config-deps.ts +163 -0
  59. package/src/engine/boot-validator/entity-handler.ts +466 -0
  60. package/src/engine/boot-validator/index.ts +159 -0
  61. package/src/engine/boot-validator/ownership.ts +198 -0
  62. package/src/engine/boot-validator/pii-retention.ts +155 -0
  63. package/src/engine/boot-validator/screens-nav.ts +624 -0
  64. package/src/engine/boot-validator.ts +1 -1528
  65. package/src/engine/build-app-schema.ts +1 -1
  66. package/src/engine/build-target.ts +99 -0
  67. package/src/engine/codemod/index.ts +15 -0
  68. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  69. package/src/engine/config-helpers.ts +9 -19
  70. package/src/engine/constants.ts +1 -1
  71. package/src/engine/define-feature.ts +127 -9
  72. package/src/engine/define-handler.ts +89 -3
  73. package/src/engine/define-roles.ts +2 -2
  74. package/src/engine/define-step.ts +28 -0
  75. package/src/engine/define-workflow.ts +110 -0
  76. package/src/engine/entity-handlers.ts +10 -9
  77. package/src/engine/event-helpers.ts +4 -4
  78. package/src/engine/extension-names.ts +105 -0
  79. package/src/engine/extensions/user-data.ts +106 -0
  80. package/src/engine/factories.ts +26 -16
  81. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  82. package/src/engine/feature-ast/extractors/index.ts +74 -0
  83. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  84. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  85. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  86. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  87. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  88. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  89. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  90. package/src/engine/feature-ast/parse.ts +13 -0
  91. package/src/engine/feature-ast/patch.ts +9 -1
  92. package/src/engine/feature-ast/patcher.ts +10 -3
  93. package/src/engine/feature-ast/patterns.ts +71 -1
  94. package/src/engine/feature-ast/render.ts +31 -1
  95. package/src/engine/index.ts +66 -2
  96. package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
  97. package/src/engine/pattern-library/library.ts +78 -2
  98. package/src/engine/pipeline.ts +88 -0
  99. package/src/engine/projection-helpers.ts +1 -1
  100. package/src/engine/read-claim.ts +1 -1
  101. package/src/engine/registry.ts +30 -2
  102. package/src/engine/resolve-config-or-param.ts +4 -0
  103. package/src/engine/run-pipeline.ts +162 -0
  104. package/src/engine/schema-builder.ts +10 -4
  105. package/src/engine/state-machine.ts +1 -1
  106. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  107. package/src/engine/steps/_duration-utils.ts +33 -0
  108. package/src/engine/steps/_no-return-guard.ts +21 -0
  109. package/src/engine/steps/_resolver-utils.ts +42 -0
  110. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  111. package/src/engine/steps/aggregate-append-event.ts +56 -0
  112. package/src/engine/steps/aggregate-create.ts +56 -0
  113. package/src/engine/steps/aggregate-update.ts +68 -0
  114. package/src/engine/steps/branch.ts +84 -0
  115. package/src/engine/steps/call-feature.ts +49 -0
  116. package/src/engine/steps/compute.ts +41 -0
  117. package/src/engine/steps/for-each.ts +111 -0
  118. package/src/engine/steps/mail-send.ts +44 -0
  119. package/src/engine/steps/read-find-many.ts +51 -0
  120. package/src/engine/steps/read-find-one.ts +58 -0
  121. package/src/engine/steps/retry.ts +87 -0
  122. package/src/engine/steps/return.ts +34 -0
  123. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  124. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  125. package/src/engine/steps/wait-for-event.ts +71 -0
  126. package/src/engine/steps/wait.ts +69 -0
  127. package/src/engine/steps/webhook-send.ts +71 -0
  128. package/src/engine/system-user.ts +1 -1
  129. package/src/engine/types/feature.ts +143 -1
  130. package/src/engine/types/fields.ts +134 -10
  131. package/src/engine/types/handlers.ts +18 -10
  132. package/src/engine/types/identifiers.ts +1 -0
  133. package/src/engine/types/index.ts +15 -1
  134. package/src/engine/types/step.ts +334 -0
  135. package/src/engine/types/target-ref.ts +21 -0
  136. package/src/engine/types/tree-node.ts +130 -0
  137. package/src/engine/types/workspace.ts +7 -0
  138. package/src/engine/validate-projection-allowlist.ts +161 -0
  139. package/src/event-store/snapshot.ts +1 -1
  140. package/src/event-store/upcaster-dead-letter.ts +1 -1
  141. package/src/event-store/upcaster.ts +1 -1
  142. package/src/files/__tests__/read-stream.test.ts +105 -0
  143. package/src/files/__tests__/write-stream.test.ts +233 -0
  144. package/src/files/__tests__/zip-stream.test.ts +357 -0
  145. package/src/files/file-routes.ts +1 -1
  146. package/src/files/in-memory-provider.ts +38 -0
  147. package/src/files/index.ts +3 -0
  148. package/src/files/local-provider.ts +58 -1
  149. package/src/files/types.ts +36 -8
  150. package/src/files/zip-stream.ts +251 -0
  151. package/src/jobs/job-runner.ts +10 -10
  152. package/src/lifecycle/lifecycle.ts +0 -3
  153. package/src/logging/index.ts +1 -0
  154. package/src/logging/pino-logger.ts +11 -7
  155. package/src/logging/utils.ts +24 -0
  156. package/src/observability/prometheus-meter.ts +7 -5
  157. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  158. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  159. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  160. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  161. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  162. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  163. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  164. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  165. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  166. package/src/pipeline/append-event-core.ts +22 -6
  167. package/src/pipeline/dispatcher-utils.ts +188 -0
  168. package/src/pipeline/dispatcher.ts +63 -283
  169. package/src/pipeline/distributed-lock.ts +1 -1
  170. package/src/pipeline/entity-cache.ts +2 -2
  171. package/src/pipeline/event-consumer-state.ts +0 -13
  172. package/src/pipeline/event-dispatcher.ts +4 -4
  173. package/src/pipeline/index.ts +0 -2
  174. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  175. package/src/pipeline/msp-rebuild.ts +5 -5
  176. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  177. package/src/pipeline/projection-rebuild.ts +2 -2
  178. package/src/pipeline/projection-state.ts +0 -12
  179. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  180. package/src/rate-limit/resolver.ts +1 -1
  181. package/src/search/in-memory-adapter.ts +1 -1
  182. package/src/search/meilisearch-adapter.ts +3 -3
  183. package/src/search/types.ts +1 -1
  184. package/src/secrets/leak-guard.ts +2 -2
  185. package/src/stack/request-helper.ts +9 -5
  186. package/src/stack/test-stack.ts +1 -1
  187. package/src/testing/handler-context.ts +4 -4
  188. package/src/testing/http-cookies.ts +1 -1
  189. package/src/time/tz-context.ts +1 -2
  190. package/src/ui-types/index.ts +4 -0
  191. package/src/engine/feature-ast/extractors.ts +0 -2562
@@ -1,1528 +1 @@
1
- import type { OwnershipMap, OwnershipRule } from "./ownership";
2
- import { parseRefTarget } from "./parse-ref-target";
3
- import { qualifyEntityName } from "./qualified-name";
4
- import { getAllowedFilterOps, isFieldFilterable } from "./screen-filter-ops";
5
- import type {
6
- ClaimKeyDefinition,
7
- FeatureDefinition,
8
- NavDefinition,
9
- WorkspaceDefinition,
10
- } from "./types";
11
- import { normalizeEditField, normalizeListColumn } from "./types/screen";
12
-
13
- const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
14
-
15
- /**
16
- * Validates all feature configurations at boot time.
17
- * Throws on the first error found — fail fast.
18
- */
19
- export function validateBoot(features: readonly FeatureDefinition[]): void {
20
- const featureMap = new Map<string, FeatureDefinition>();
21
- for (const f of features) {
22
- featureMap.set(f.name, f);
23
- }
24
-
25
- // Collect all extension names and their schema extensions
26
- const extensionProviders = new Map<string, string>();
27
- for (const f of features) {
28
- for (const extName of Object.keys(f.registrarExtensions)) {
29
- extensionProviders.set(extName, f.name);
30
- }
31
- }
32
-
33
- // Collect all config keys across features (for cross-feature reference validation)
34
- const allConfigKeys = new Set<string>();
35
- // Qualified config-key set für ConfigEditScreen-Validation. Format
36
- // wie in registry.ts: `<feature>:config:<short>`. allConfigKeys oben
37
- // nutzt das ältere `feature.short`-Format für validateConfigReads.
38
- const allConfigKeyQns = new Set<string>();
39
- for (const f of features) {
40
- for (const key of Object.keys(f.configKeys)) {
41
- allConfigKeys.add(`${f.name}.${key}`);
42
- allConfigKeyQns.add(`${f.name}:config:${key}`);
43
- }
44
- }
45
-
46
- // Collect all claim keys — the ownership-rule validator below resolves
47
- // `from("claim:<feature>:<key>")` strings against this map. Qualified name
48
- // is how the resolver / readClaim / ownership system all reference claims,
49
- // so we key on the qualifiedName here too.
50
- const allClaimKeys = new Map<string, ClaimKeyDefinition>();
51
- for (const f of features) {
52
- for (const def of Object.values(f.claimKeys)) {
53
- allClaimKeys.set(def.qualifiedName, def);
54
- }
55
- }
56
-
57
- // Cross-feature role set — derived from handler-access rules + framework
58
- // built-ins ("all", "system"). We don't have a dedicated role-registry
59
- // (r.defineRoles is a type-level helper, not a runtime export), so we
60
- // use "referenced in any handler access rule" as the corpus of known
61
- // roles. The ownership-validator checks OwnershipMap keys + legacy
62
- // string[] field-access entries against this set — typos like "Admi"
63
- // instead of "Admin" fail at boot if nothing else ever mentions "Admi".
64
- const knownRoles = collectKnownRoles(features);
65
-
66
- // Cross-feature screen + nav registry — built once up front so per-feature
67
- // validators can check nav-ref targets + parent chains without re-scanning
68
- // every feature's navs map.
69
- const allScreenQns = collectScreenQns(features);
70
- const allNavQns = collectNavQns(features);
71
- const allWorkspaceQns = collectWorkspaceQns(features);
72
- const allWriteHandlerQns = collectWriteHandlerQns(features);
73
-
74
- let hasEncryptedFields = false;
75
- let hasFileFields = false;
76
-
77
- for (const feature of features) {
78
- validateCircularDeps(feature.name, featureMap);
79
- if (validateEncryptedFields(feature)) hasEncryptedFields = true;
80
- if (validateFileFields(feature)) hasFileFields = true;
81
- validateEmbeddedFields(feature);
82
- validateMultiSelectFields(feature);
83
- validateReferenceFields(feature, featureMap);
84
- validateTransitions(feature);
85
- validateExtensionUsages(feature, extensionProviders);
86
- validateExtendSchemaCollisions(feature);
87
- validateHandlerAccess(feature);
88
- validateLocatedTimestamps(feature);
89
- validateEntityIndexes(feature);
90
- validateConfigKeyBounds(feature);
91
- validateConfigKeyComputed(feature);
92
- validateConfigKeyAllowPerRequest(feature);
93
- validateOwnershipRules(feature, allClaimKeys, knownRoles);
94
- validateMultiStreamProjections(feature);
95
- validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns);
96
- validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
97
- validateWorkspaces(feature, allNavQns);
98
- }
99
-
100
- validateNavCycles(allNavQns);
101
- validateDefaultWorkspaceUniqueness(allWorkspaceQns);
102
-
103
- if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
104
- throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
105
- }
106
-
107
- if (hasFileFields && !process.env["FILE_STORAGE_PROVIDER"]) {
108
- throw new Error(
109
- "FILE_STORAGE_PROVIDER environment variable is required (file/image fields in use)",
110
- );
111
- }
112
-
113
- validateConfigReads(features, allConfigKeys);
114
- warnOnToggleableDependencies(features, featureMap);
115
- }
116
-
117
- // --- Toggleable-dependency warnings ---
118
- //
119
- // When feature A declares r.requires("B") and B is toggleable with
120
- // default=false, A is effectively disabled out-of-the-box until someone
121
- // flips B on globally. That's usually an oversight — the dev either meant
122
- // optionalRequires, or forgot to ship B with default=true. We warn (not
123
- // fail) because the combination is legal: an app might intentionally
124
- // require an opt-in feature to make it explicit that B must be activated.
125
- function warnOnToggleableDependencies(
126
- features: readonly FeatureDefinition[],
127
- featureMap: ReadonlyMap<string, FeatureDefinition>,
128
- ): void {
129
- for (const f of features) {
130
- for (const dep of f.requires) {
131
- const depFeature = featureMap.get(dep);
132
- if (!depFeature) continue; // requires-target-missing is handled elsewhere
133
- if (depFeature.toggleableDefault === false) {
134
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
135
- console.warn(
136
- `[kumiko:boot] Feature "${f.name}" requires "${dep}", which is toggleable(default=false). ` +
137
- `"${f.name}" will be effectively disabled until "${dep}" is enabled globally via the feature-toggles feature. ` +
138
- `If this is intentional, ignore this warning; otherwise consider r.optionalRequires() or default=true.`,
139
- );
140
- }
141
- }
142
- }
143
- }
144
-
145
- // --- Config key bounds consistency ---
146
-
147
- function validateConfigKeyBounds(feature: FeatureDefinition): void {
148
- for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
149
- const bounds = keyDef.bounds;
150
- // skip: no bounds declared, nothing to validate
151
- if (!bounds) continue;
152
-
153
- // Bounds on non-number keys are nonsensical — the call-site type-guard
154
- // already rejects this, but catch it at boot as defence in depth (e.g.
155
- // a hand-rolled key definition that bypasses createTenantConfig).
156
- if (keyDef.type !== "number") {
157
- throw new Error(
158
- `[Feature ${feature.name}] Config key "${keyName}" has bounds but type is "${keyDef.type}" — bounds are only valid for type="number"`,
159
- );
160
- }
161
-
162
- const { min, max } = bounds;
163
-
164
- if (min !== undefined && max !== undefined && min > max) {
165
- throw new Error(
166
- `[Feature ${feature.name}] Config key "${keyName}" has bounds.min (${min}) > bounds.max (${max})`,
167
- );
168
- }
169
-
170
- if (keyDef.default !== undefined) {
171
- const defaultNum = keyDef.default as number;
172
- if (min !== undefined && defaultNum < min) {
173
- throw new Error(
174
- `[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is below bounds.min (${min})`,
175
- );
176
- }
177
- if (max !== undefined && defaultNum > max) {
178
- throw new Error(
179
- `[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is above bounds.max (${max})`,
180
- );
181
- }
182
- }
183
- }
184
- }
185
-
186
- // --- Config key computed + encrypted exclusivity ---
187
-
188
- function validateConfigKeyComputed(feature: FeatureDefinition): void {
189
- for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
190
- if (!keyDef.computed) continue;
191
-
192
- // computed + encrypted mix two paradigms that shouldn't meet: computed
193
- // returns a plain value, encrypted expects cipher-text in the row. The
194
- // cascade doesn't know which one to prefer on write. Rejecting at boot
195
- // is cheaper than surprising behaviour at runtime.
196
- if (keyDef.encrypted) {
197
- throw new Error(
198
- `[Feature ${feature.name}] Config key "${keyName}" has both encrypted=true and a computed resolver — these are mutually exclusive paradigms`,
199
- );
200
- }
201
- }
202
- }
203
-
204
- // --- Config key allowPerRequest compatibility ---
205
-
206
- function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): void {
207
- for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
208
- if (!keyDef.allowPerRequest) continue;
209
-
210
- // text is hard-locked against per-request — the helper refuses
211
- // anyway, but declaring allowPerRequest on a text key is a
212
- // misconfiguration that should fail loudly at boot.
213
- if (keyDef.type === "text") {
214
- throw new Error(
215
- `[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but type="text" — text keys are permanently ineligible for per-request overrides (XSS/injection risk)`,
216
- );
217
- }
218
-
219
- // encrypted + per-request would expose a cipher-text interpretation
220
- // to query-strings. The secret-value shouldn't be transported this
221
- // way — reject as a paradigm-mismatch.
222
- if (keyDef.encrypted) {
223
- throw new Error(
224
- `[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but encrypted=true — secret values may not be set via query-params`,
225
- );
226
- }
227
- }
228
- }
229
-
230
- // --- Config key cross-feature reference validation ---
231
-
232
- function validateConfigReads(
233
- features: readonly FeatureDefinition[],
234
- allConfigKeys: ReadonlySet<string>,
235
- ): void {
236
- for (const feature of features) {
237
- for (const key of feature.configReads) {
238
- if (!allConfigKeys.has(key)) {
239
- throw new Error(
240
- `Feature "${feature.name}" reads config "${key}" but no feature defines that key`,
241
- );
242
- }
243
- }
244
- }
245
- }
246
-
247
- // --- Circular dependency detection ---
248
-
249
- function validateCircularDeps(
250
- featureName: string,
251
- featureMap: ReadonlyMap<string, FeatureDefinition>,
252
- ): void {
253
- const visited = new Set<string>();
254
- const stack = new Set<string>();
255
-
256
- function visit(name: string, path: string[]): void {
257
- if (stack.has(name)) {
258
- throw new Error(`Circular dependency: ${[...path, name].join(" → ")}`);
259
- }
260
- // skip: node already visited in DFS traversal
261
- if (visited.has(name)) return;
262
-
263
- visited.add(name);
264
- stack.add(name);
265
-
266
- const feature = featureMap.get(name);
267
- if (feature) {
268
- for (const dep of feature.requires) {
269
- visit(dep, [...path, name]);
270
- }
271
- }
272
-
273
- stack.delete(name);
274
- }
275
-
276
- visit(featureName, []);
277
- }
278
-
279
- // --- Handler access validation ---
280
-
281
- // Rate-limit modes that bucket per user.id. Anonymous endpoints would put
282
- // every unauthenticated caller into a single shared bucket (id="anonymous"),
283
- // turning the rate-limit into a global tap any caller can drain. Boot-fail
284
- // before the misconfiguration ships.
285
- const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user+handler"]);
286
-
287
- // Every handler must declare access. Missing access is treated as default-deny
288
- // at runtime, but we fail at boot to turn an easy-to-miss security regression
289
- // into a loud configuration error.
290
- function validateHandlerAccess(feature: FeatureDefinition): void {
291
- for (const [name, handler] of Object.entries(feature.writeHandlers)) {
292
- if (!handler.access) {
293
- throw new Error(
294
- `Write handler "${feature.name}:write:${name}" is missing an access rule. ` +
295
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
296
- );
297
- }
298
- validateAnonymousRateLimit(feature.name, "write", name, handler.access, handler.rateLimit);
299
- }
300
- for (const [name, handler] of Object.entries(feature.queryHandlers)) {
301
- if (!handler.access) {
302
- throw new Error(
303
- `Query handler "${feature.name}:query:${name}" is missing an access rule. ` +
304
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
305
- );
306
- }
307
- validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
308
- }
309
- }
310
-
311
- function validateAnonymousRateLimit(
312
- featureName: string,
313
- kind: "write" | "query",
314
- handlerName: string,
315
- access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
316
- rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
317
- ): void {
318
- // skip: handler doesn't opt into rate-limit, no user-bucket risk
319
- if (!rateLimit) return;
320
- // skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
321
- // the user-bucket footgun doesn't apply
322
- if (!("roles" in access)) return;
323
- // skip: handler doesn't list anonymous, regular role-rate-limit is fine
324
- if (!access.roles.includes("anonymous")) return;
325
- // skip: rate-limit is already keyed on something safe (ip / tenant)
326
- if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return;
327
- throw new Error(
328
- `${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` +
329
- `rateLimit.per="${rateLimit.per}" — every anonymous request shares user.id="anonymous", ` +
330
- `so this bucket would be a single global tap any caller could drain. ` +
331
- `Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`,
332
- );
333
- }
334
-
335
- // --- MultiStreamProjection delivery-invariant ---
336
- //
337
- // `delivery: "per-instance"` mit einer `table` ist eine semantische Falle:
338
- // N Dispatcher-Instanzen würden parallel die gleichen INSERT/UPDATE-Zeilen
339
- // schreiben (Race / Duplicates), und ein Rebuild würde nur eine Zeile in
340
- // kumiko_event_consumers anfassen (die SHARED_INSTANCE_SENTINEL-Zeile),
341
- // während Live-Cursor in per-instance-Zeilen liegen → Cursor-Divergenz.
342
- //
343
- // Die Invariante ist: per-instance-Consumer sind rein side-effect (SSE,
344
- // in-memory cache invalidation). Wer eine Tabelle materialisiert, braucht
345
- // shared delivery — das ist exactly-once globally und gibt dem Rebuild
346
- // einen einzigen Cursor zum zurücksetzen.
347
- function validateMultiStreamProjections(feature: FeatureDefinition): void {
348
- for (const [name, msp] of Object.entries(feature.multiStreamProjections)) {
349
- if (msp.delivery === "per-instance" && msp.table !== undefined) {
350
- throw new Error(
351
- `[Feature ${feature.name}] MultiStreamProjection "${name}" has delivery="per-instance" AND a backing table — ` +
352
- `that combination would make every dispatcher-instance write the same rows (duplicate INSERTs), and rebuild would reset only the shared cursor while live cursors live per-instance (cursor divergence). ` +
353
- `Use delivery="shared" (default) for table-materializing projections, or drop the table for side-effect-only consumers (SSE, in-memory caches).`,
354
- );
355
- }
356
- }
357
- }
358
-
359
- // --- Located-Timestamp validation ---
360
- //
361
- // Wenn ein Feld `type: "timestamp"` einen `locatedBy`-Marker trägt, muss das
362
- // referenzierte Feld in derselben Entity existieren UND vom Typ `tz` sein.
363
- // Sonst weiß weder DB-Wrapper noch JSON-Serializer welche TZ zur Wall-Clock
364
- // gehört → silent data loss bei Reads in anderer Server-TZ.
365
- //
366
- // Die häufigste Quelle von Konflikten ist Hand-Konstruktion:
367
- // { foo: { type: "timestamp", locatedBy: "fooTz" } }
368
- // ohne das `fooTz`-Feld zu deklarieren. Der `locatedTimestamp(name)` Helper
369
- // macht das Pair atomar — wer ihn nutzt, fliegt nicht durch diesen Validator.
370
- function validateLocatedTimestamps(feature: FeatureDefinition): void {
371
- for (const [entityName, entity] of Object.entries(feature.entities)) {
372
- const fields = entity.fields;
373
- for (const [fieldName, field] of Object.entries(fields)) {
374
- if (field.type !== "timestamp" || field.locatedBy === undefined) continue;
375
- const referenced = fields[field.locatedBy];
376
- if (!referenced) {
377
- throw new Error(
378
- `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` +
379
- `locatedBy: "${field.locatedBy}" but no field with that name exists in the entity. ` +
380
- `Either declare the tz-field, or use the locatedTimestamp("${fieldName.replace(/At$/, "")}") helper ` +
381
- `to create the pair atomically.`,
382
- );
383
- }
384
- if (referenced.type !== "tz") {
385
- throw new Error(
386
- `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` +
387
- `locatedBy: "${field.locatedBy}" but that field is type "${referenced.type}", ` +
388
- `expected "tz". The locatedBy marker must point to a tz-field (IANA-zone slot).`,
389
- );
390
- }
391
- }
392
- }
393
- }
394
-
395
- // --- Entity-Index validation ---
396
- //
397
- // entity.indexes deklariert Composite-/Unique-Indices über mehrere Feld-
398
- // Spalten. Häufige Fehler: Tippfehler im Feld-Namen, leere column-Liste,
399
- // Index auf einem Field das die DB-Spalte gar nicht existiert (file/image
400
- // in der multi-Variante). Catched at boot, lange bevor drizzle-kit beim
401
- // generate-Run zickt.
402
- //
403
- // `tenantId` als einzige Spalte ist redundant — buildDrizzleTable legt
404
- // den Index sowieso automatisch an. Wir lassen die Composite-Form erlaubt
405
- // (`["tenantId", "key"]` ist sinnvoll), nur die rein-tenantId-Single-
406
- // column-Form blockieren wir.
407
- function validateEntityIndexes(feature: FeatureDefinition): void {
408
- for (const [entityName, entity] of Object.entries(feature.entities)) {
409
- if (!entity.indexes) continue;
410
- const fieldNames = new Set(Object.keys(entity.fields));
411
- for (const [idx, def] of entity.indexes.entries()) {
412
- const where = `Feature "${feature.name}", entity "${entityName}", indexes[${idx}]`;
413
- if (def.columns.length === 0) {
414
- throw new Error(`${where}: empty columns list. An index needs at least one column.`);
415
- }
416
- for (const col of def.columns) {
417
- if (col === "tenantId" || col === "id" || col === "version") continue; // base columns
418
- if (!fieldNames.has(col)) {
419
- throw new Error(
420
- `${where}: column "${col}" does not match any field in the entity. ` +
421
- `Available fields: ${[...fieldNames].join(", ")}.`,
422
- );
423
- }
424
- const field = entity.fields[col];
425
- if (
426
- field &&
427
- (field.type === "files" ||
428
- field.type === "images" ||
429
- (field.type === "reference" && field.multiple === true))
430
- ) {
431
- throw new Error(
432
- `${where}: column "${col}" is a multi-value field (${field.type}) — ` +
433
- `these have no DB column to index on. Use a single-value field or remove from the index.`,
434
- );
435
- }
436
- if (field && field.type === "longText") {
437
- // longText ist semantisch "potentially-megabytes content" — ein
438
- // BTREE-Index auf einer 1-MB-Spalte ist Performance-Disaster
439
- // (PG würde in TOAST-pages dereferenzieren müssen für jeden
440
- // Index-Lookup). Konsistent mit der type-level-decision dass
441
- // longText kein sortable/searchable/filterable hat. Wer
442
- // wirklich indexieren will, nimmt `text` mit den
443
- // entsprechenden Skalierungs-Trade-offs.
444
- throw new Error(
445
- `${where}: column "${col}" is a longText field — these cannot be indexed. ` +
446
- `Use \`text\` if you need indexing, or rely on the SearchAdapter (Meilisearch) for full-text search on long content.`,
447
- );
448
- }
449
- }
450
- if (def.columns.length === 1 && def.columns[0] === "tenantId") {
451
- throw new Error(
452
- `${where}: single-column index on "tenantId" is redundant — ` +
453
- `buildDrizzleTable always creates one automatically. Remove this entry.`,
454
- );
455
- }
456
- }
457
- }
458
- }
459
-
460
- // --- Encrypted field validation ---
461
-
462
- function validateEncryptedFields(feature: FeatureDefinition): boolean {
463
- let found = false;
464
- for (const [entityName, entity] of Object.entries(feature.entities)) {
465
- for (const [fieldName, field] of Object.entries(entity.fields)) {
466
- // Beide string-typed fields können encrypted sein. Die
467
- // searchable/sortable-Konflikt-Checks gelten nur für `text`
468
- // (longText hat diese flags type-level nicht).
469
- if (field.type !== "text" && field.type !== "longText") continue;
470
- if (!field.encrypted) continue;
471
- found = true;
472
-
473
- if (field.type === "text") {
474
- if (field.searchable) {
475
- throw new Error(
476
- `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable`,
477
- );
478
- }
479
- if (field.sortable) {
480
- throw new Error(
481
- `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable`,
482
- );
483
- }
484
- }
485
- }
486
- }
487
- return found;
488
- }
489
-
490
- // --- File field detection ---
491
-
492
- function validateFileFields(feature: FeatureDefinition): boolean {
493
- for (const entity of Object.values(feature.entities)) {
494
- for (const field of Object.values(entity.fields)) {
495
- if (FILE_FIELD_TYPES.has(field.type)) return true;
496
- }
497
- }
498
- return false;
499
- }
500
-
501
- // --- Extension usage validation ---
502
-
503
- function validateExtensionUsages(
504
- feature: FeatureDefinition,
505
- extensionProviders: ReadonlyMap<string, string>,
506
- ): void {
507
- for (const usage of feature.extensionUsages) {
508
- const providerFeature = extensionProviders.get(usage.extensionName);
509
- if (!providerFeature) {
510
- throw new Error(
511
- `Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension`,
512
- );
513
- }
514
-
515
- const allDeps = [...feature.requires, ...feature.optionalRequires];
516
- if (!allDeps.includes(providerFeature)) {
517
- throw new Error(
518
- `Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}")`,
519
- );
520
- }
521
- }
522
- }
523
-
524
- // --- Embedded field validation ---
525
-
526
- const VALID_EMBEDDED_SUB_TYPES = new Set(["text", "number", "boolean", "date"]);
527
-
528
- // Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef-Validation.
529
- // 1) referenced entity existiert (same-feature OR cross-feature
530
- // qualifiziert per "<feature>:<entity>"). Same-feature ist
531
- // Default; cross-feature verlangt expliziten ":"-Prefix.
532
- // 2) labelField (wenn gesetzt) existiert auf der referenced Entity.
533
- // 3) Self-Reference erlaubt (entity → entity).
534
- // 4) Audit-Fix: Query-Handler `<feature>:query:<entity>:list` muss
535
- // registriert sein — der Renderer feuert den beim Combobox-
536
- // Open. Ohne Handler crasht die Combobox zur Laufzeit.
537
- function validateReferenceFields(
538
- feature: FeatureDefinition,
539
- featureMap: ReadonlyMap<string, FeatureDefinition>,
540
- ): void {
541
- for (const [entityName, entity] of Object.entries(feature.entities)) {
542
- for (const [fieldName, field] of Object.entries(entity.fields)) {
543
- if (field.type !== "reference") continue;
544
-
545
- const target = parseRefTarget(field.entity, feature.name);
546
- const targetFeature = featureMap.get(target.featureName);
547
- if (!targetFeature) {
548
- const knownFeatures = [...featureMap.keys()].sort().join(", ");
549
- throw new Error(
550
- `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
551
- `targets unknown feature "${target.featureName}" via "${field.entity}". ` +
552
- `Known features: ${knownFeatures}.`,
553
- );
554
- }
555
- const targetEntity = targetFeature.entities[target.entityName];
556
- if (!targetEntity) {
557
- const known = Object.keys(targetFeature.entities).sort().join(", ") || "(none)";
558
- const where =
559
- target.featureName === feature.name
560
- ? `in this feature`
561
- : `in feature "${target.featureName}"`;
562
- throw new Error(
563
- `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
564
- `targets unknown entity "${target.entityName}" ${where}. ` +
565
- `Known entities: ${known}.`,
566
- );
567
- }
568
- if (field.labelField !== undefined) {
569
- const knownFields = Object.keys(targetEntity.fields);
570
- // "id" ist immer da, auch ohne Field-Definition (PK).
571
- if (field.labelField !== "id" && !knownFields.includes(field.labelField)) {
572
- throw new Error(
573
- `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
574
- `references labelField "${field.labelField}" which does not exist on entity ` +
575
- `"${target.entityName}". Known fields: ${[...knownFields, "id"].sort().join(", ")}.`,
576
- );
577
- }
578
- }
579
- // Audit-Fix #2: Query-Handler-Existenz pinnen. Renderer feuert
580
- // `<targetFeature>:query:<targetEntity>:list` beim Combobox-Open
581
- // (use-reference-lookup, ReferenceInput); ohne Handler kommt
582
- // beim ersten Klick ein 404. defaultEntityQueryHandler-Names
583
- // sind als kurz "<entity>:list" in feature.queryHandlers gespeichert.
584
- const expectedHandlerShortName = `${target.entityName}:list`;
585
- if (targetFeature.queryHandlers[expectedHandlerShortName] === undefined) {
586
- throw new Error(
587
- `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
588
- `targets entity "${target.entityName}" but no list-query-handler is registered ` +
589
- `there. Add r.queryHandler(defineEntityListHandler("${target.entityName}", ` +
590
- `${target.entityName}Entity)) to feature "${target.featureName}", or pick a ` +
591
- `different label/entity.`,
592
- );
593
- }
594
- }
595
- }
596
- }
597
-
598
- function validateEmbeddedFields(feature: FeatureDefinition): void {
599
- for (const [entityName, entity] of Object.entries(feature.entities)) {
600
- for (const [fieldName, field] of Object.entries(entity.fields)) {
601
- if (field.type !== "embedded") continue;
602
-
603
- if (!field.schema || Object.keys(field.schema).length === 0) {
604
- throw new Error(
605
- `Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema`,
606
- );
607
- }
608
-
609
- for (const [subName, subField] of Object.entries(field.schema)) {
610
- if (!VALID_EMBEDDED_SUB_TYPES.has(subField.type)) {
611
- throw new Error(
612
- `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid type "${subField.type}". Allowed: ${[...VALID_EMBEDDED_SUB_TYPES].join(", ")}`,
613
- );
614
- }
615
- }
616
- }
617
- }
618
- }
619
-
620
- // --- MultiSelect field validation ---
621
- //
622
- // options muss non-empty sein (sonst wäre das Feld nicht benutzbar) und
623
- // default — wenn gesetzt — ist eine Teilmenge der options. Beides würde
624
- // auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
625
- // die früheste Stelle für klare Fehlermeldungen.
626
- function validateMultiSelectFields(feature: FeatureDefinition): void {
627
- for (const [entityName, entity] of Object.entries(feature.entities)) {
628
- for (const [fieldName, field] of Object.entries(entity.fields)) {
629
- if (field.type !== "multiSelect") continue;
630
-
631
- if (field.options.length === 0) {
632
- throw new Error(
633
- `MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options`,
634
- );
635
- }
636
-
637
- if (field.default !== undefined) {
638
- const validOptions = new Set<string>(field.options);
639
- for (const value of field.default) {
640
- if (!validOptions.has(value)) {
641
- throw new Error(
642
- `MultiSelect default "${value}" on "${entityName}.${fieldName}" is not a valid option. Valid: ${field.options.join(", ")}`,
643
- );
644
- }
645
- }
646
- }
647
- }
648
- }
649
- }
650
-
651
- // --- Transition validation ---
652
-
653
- function validateTransitions(feature: FeatureDefinition): void {
654
- for (const [entityName, entity] of Object.entries(feature.entities)) {
655
- if (!entity.transitions) continue;
656
-
657
- for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
658
- const field = entity.fields[fieldName];
659
-
660
- if (!field) {
661
- throw new Error(
662
- `Transitions defined for unknown field "${fieldName}" on entity "${entityName}" in feature "${feature.name}"`,
663
- );
664
- }
665
-
666
- if (field.type !== "select") {
667
- throw new Error(
668
- `Transitions defined for field "${fieldName}" on entity "${entityName}" but field type is "${field.type}" (must be "select")`,
669
- );
670
- }
671
-
672
- const validOptions = new Set(field.options);
673
-
674
- // Check all states in the transition map
675
- for (const [from, targets] of Object.entries(transitionMap)) {
676
- if (!validOptions.has(from)) {
677
- throw new Error(
678
- `Transition state "${from}" on "${entityName}.${fieldName}" is not a valid option. Valid: ${[...validOptions].join(", ")}`,
679
- );
680
- }
681
- for (const to of targets) {
682
- if (!validOptions.has(to)) {
683
- throw new Error(
684
- `Transition target "${to}" (from "${from}") on "${entityName}.${fieldName}" is not a valid option. Valid: ${[...validOptions].join(", ")}`,
685
- );
686
- }
687
- }
688
- }
689
- }
690
- }
691
- }
692
-
693
- // --- extendSchema column collision detection ---
694
-
695
- function validateExtendSchemaCollisions(feature: FeatureDefinition): void {
696
- for (const [entityName, entity] of Object.entries(feature.entities)) {
697
- const existingFields = new Set(Object.keys(entity.fields));
698
-
699
- // Check if any registered extension would collide with existing fields
700
- for (const ext of Object.values(feature.registrarExtensions)) {
701
- if (!ext.extendSchema) continue;
702
- const extraFields = ext.extendSchema(entityName);
703
- for (const fieldName of Object.keys(extraFields)) {
704
- if (existingFields.has(fieldName)) {
705
- throw new Error(
706
- `extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}"`,
707
- );
708
- }
709
- }
710
- }
711
- }
712
- }
713
-
714
- // --- Ownership rule validation (H.2) ---
715
- //
716
- // Walks every entity.access and every field.access map, resolves each
717
- // FromRule against the cross-feature claim registry, and confirms the
718
- // referenced column exists on the entity. Catches typos, renames, and
719
- // cross-feature-claim-removal scenarios at boot instead of at request time.
720
-
721
- function validateOwnershipRules(
722
- feature: FeatureDefinition,
723
- allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>,
724
- knownRoles: ReadonlySet<string>,
725
- ): void {
726
- for (const [entityName, entity] of Object.entries(feature.entities)) {
727
- const columnNames = new Set<string>(Object.keys(entity.fields));
728
- // Framework-managed columns that rules are allowed to reference too.
729
- // These are the base columns buildDrizzleTable adds unconditionally.
730
- const frameworkColumns = ["id", "tenantId", "version", "insertedAt", "modifiedAt"];
731
- for (const col of frameworkColumns) columnNames.add(col);
732
-
733
- // Entity-level access
734
- if (entity.access?.read) {
735
- checkOwnershipMap({
736
- map: entity.access.read,
737
- columnNames,
738
- allClaimKeys,
739
- knownRoles,
740
- scope: `entity "${entityName}".access.read`,
741
- featureName: feature.name,
742
- });
743
- }
744
- if (entity.access?.write) {
745
- checkOwnershipMap({
746
- map: entity.access.write,
747
- columnNames,
748
- allClaimKeys,
749
- knownRoles,
750
- scope: `entity "${entityName}".access.write`,
751
- featureName: feature.name,
752
- });
753
- }
754
-
755
- // Field-level access — OwnershipMap form goes through checkOwnershipMap,
756
- // legacy string[] through checkLegacyRoleList. Both enforce role-name
757
- // existence against knownRoles so typos fail loud.
758
- for (const [fieldName, field] of Object.entries(entity.fields)) {
759
- checkFieldAccess({
760
- access: field.access?.read,
761
- columnNames,
762
- allClaimKeys,
763
- knownRoles,
764
- scope: `${entityName}.${fieldName}.access.read`,
765
- featureName: feature.name,
766
- });
767
- checkFieldAccess({
768
- access: field.access?.write,
769
- columnNames,
770
- allClaimKeys,
771
- knownRoles,
772
- scope: `${entityName}.${fieldName}.access.write`,
773
- featureName: feature.name,
774
- });
775
- }
776
- }
777
- }
778
-
779
- function checkFieldAccess(args: {
780
- readonly access: OwnershipMap | readonly string[] | undefined;
781
- readonly columnNames: ReadonlySet<string>;
782
- readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
783
- readonly knownRoles: ReadonlySet<string>;
784
- readonly scope: string;
785
- readonly featureName: string;
786
- }): void {
787
- // skip: no access rules on this field, nothing to validate
788
- if (!args.access) return;
789
- if (Array.isArray(args.access)) {
790
- // Legacy string[] form — every entry is a role name. Ref/column
791
- // validation is n/a here (no claim refs in this shape), but the
792
- // role-existence check applies.
793
- checkLegacyRoleList(
794
- args.access as readonly string[],
795
- args.knownRoles,
796
- args.scope,
797
- args.featureName,
798
- );
799
- // skip: legacy form validated, OwnershipMap check below doesn't apply
800
- return;
801
- }
802
- checkOwnershipMap({
803
- map: args.access as OwnershipMap,
804
- columnNames: args.columnNames,
805
- allClaimKeys: args.allClaimKeys,
806
- knownRoles: args.knownRoles,
807
- scope: args.scope,
808
- featureName: args.featureName,
809
- });
810
- }
811
-
812
- function checkLegacyRoleList(
813
- roles: readonly string[],
814
- knownRoles: ReadonlySet<string>,
815
- scope: string,
816
- featureName: string,
817
- ): void {
818
- // skip: no handler-declared roles in this app, role-validation disabled
819
- if (!shouldValidateRoles(knownRoles)) return;
820
- for (const roleName of roles) {
821
- if (!knownRoles.has(roleName)) {
822
- throw new Error(buildUnknownRoleMessage(roleName, knownRoles, scope, featureName));
823
- }
824
- }
825
- }
826
-
827
- // Only validate role-existence when at least one handler in the system has
828
- // declared a non-builtin role. Apps that run entirely on openToAll +
829
- // system-role handlers don't benefit from role-typo detection and would
830
- // otherwise get false-positive errors on every OwnershipMap — their
831
- // knownRoles corpus is empty beyond "all"/"system", so any app-defined
832
- // role would flag as unknown.
833
- function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
834
- for (const r of knownRoles) {
835
- if (r !== "all" && r !== "system") return true;
836
- }
837
- return false;
838
- }
839
-
840
- function checkOwnershipMap(args: {
841
- readonly map: OwnershipMap;
842
- readonly columnNames: ReadonlySet<string>;
843
- readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
844
- readonly knownRoles: ReadonlySet<string>;
845
- readonly scope: string;
846
- readonly featureName: string;
847
- }): void {
848
- for (const [roleName, rawRule] of Object.entries(args.map)) {
849
- // Role-existence check — typos like `{"Admi": "all"}` where no handler
850
- // or other map mentions "Admi" would otherwise silently grant nothing.
851
- // Skip when no app-defined roles exist anywhere (handler-less or
852
- // system-only apps — shouldValidateRoles returns false there).
853
- if (shouldValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
854
- throw new Error(
855
- buildUnknownRoleMessage(roleName, args.knownRoles, args.scope, args.featureName),
856
- );
857
- }
858
-
859
- // @cast-boundary schema-walk — extracted from feature-config inspection
860
- const rule = rawRule as OwnershipRule;
861
- if (rule === "all") continue;
862
- if (rule.kind === "where") continue; // escape hatch — feature author owns the SQL
863
-
864
- // FromRule — validate ref + column.
865
- if (rule.refKind === "claim") {
866
- // refPath is the qualified claim name ("feature:shortName").
867
- const claim = args.allClaimKeys.get(rule.refPath);
868
- if (!claim) {
869
- const known = [...args.allClaimKeys.keys()].sort().join(", ") || "(none)";
870
- throw new Error(
871
- `[Kumiko Ownership] ${args.scope} references unknown claim "${rule.refPath}" ` +
872
- `(role: "${roleName}", feature: "${args.featureName}"). ` +
873
- `Declare it via r.claimKey("...", { type: "..." }) in the owning feature. ` +
874
- `Known claims: ${known}`,
875
- );
876
- }
877
- // String-compatible columns accept string and string[] claims equally
878
- // (array → inArray). For other claim types we rely on the author
879
- // knowing the row-column shape; we can't introspect PG types without
880
- // the schema built. This is a best-effort ref-existence check.
881
- }
882
-
883
- if (!args.columnNames.has(rule.column)) {
884
- const known = [...args.columnNames].sort().join(", ");
885
- throw new Error(
886
- `[Kumiko Ownership] ${args.scope} references column "${rule.column}" ` +
887
- `which does not exist on the entity (role: "${roleName}", feature: ` +
888
- `"${args.featureName}"). Available columns: ${known}`,
889
- );
890
- }
891
- }
892
- }
893
-
894
- function buildUnknownRoleMessage(
895
- roleName: string,
896
- knownRoles: ReadonlySet<string>,
897
- scope: string,
898
- featureName: string,
899
- ): string {
900
- const known = [...knownRoles].sort().join(", ");
901
- return (
902
- `[Kumiko Ownership] ${scope} references unknown role "${roleName}" ` +
903
- `(feature: "${featureName}"). Roles are collected from handler access ` +
904
- `rules across all features plus the "all" and "system" built-ins; if ` +
905
- `"${roleName}" is real, make sure at least one handler declares ` +
906
- `access.roles: ["${roleName}"]. Known roles: ${known}`
907
- );
908
- }
909
-
910
- // --- Screen validation ---
911
- //
912
- // For every r.screen() declaration check what's locally knowable at boot:
913
- // - entityList / entityEdit: the referenced entity must exist in the
914
- // feature (cross-feature entity-refs aren't allowed — a feature owns
915
- // the screens over its own entities) and every column/field ref must
916
- // name a real field on that entity
917
- // - custom: the renderer must at least have one platform component set
918
- // (react OR native), otherwise the screen is structurally empty
919
- //
920
- // Field-level renderer QN strings (cross-feature `component:` references)
921
- // are NOT validated here — the r.uiComponent registry that would resolve
922
- // them ships in M4/M5. Until then those are kept opaque on purpose.
923
- function validateScreens(
924
- feature: FeatureDefinition,
925
- featureMap: ReadonlyMap<string, FeatureDefinition>,
926
- allWriteHandlerQns: ReadonlySet<string>,
927
- allScreenQns: ReadonlySet<string>,
928
- allConfigKeyQns: ReadonlySet<string>,
929
- ): void {
930
- for (const [screenId, screen] of Object.entries(feature.screens)) {
931
- if (screen.type === "custom") {
932
- if (!screen.renderer.react && !screen.renderer.native) {
933
- throw new Error(
934
- `[Feature ${feature.name}] Screen "${screenId}" has type="custom" but the renderer ` +
935
- `declares neither a react nor a native component — at least one platform must be set.`,
936
- );
937
- }
938
- continue;
939
- }
940
-
941
- if (screen.type === "configEdit") {
942
- // configEdit: layout/fields wie actionForm validieren, plus
943
- // Cross-Check dass jeder qualifizierte Config-Key registriert
944
- // ist und der scope mit dem Key matcht.
945
- const fieldNames = new Set(Object.keys(screen.fields));
946
- if (fieldNames.size === 0) {
947
- throw new Error(
948
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has empty fields map — ` +
949
- `declare at least one field.`,
950
- );
951
- }
952
- for (const [fname, fdef] of Object.entries(screen.fields)) {
953
- // @cast-boundary schema-walk — feature-config inspection
954
- const ftype = (fdef as { type?: unknown }).type;
955
- if (typeof ftype !== "string" || ftype.length === 0) {
956
- throw new Error(
957
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
958
- `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
959
- );
960
- }
961
- }
962
- if (screen.layout.sections.length === 0) {
963
- throw new Error(
964
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
965
- `declare at least one section.`,
966
- );
967
- }
968
- for (const section of screen.layout.sections) {
969
- if (section.fields.length === 0) {
970
- throw new Error(
971
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
972
- `with zero fields — drop the section or add fields to it.`,
973
- );
974
- }
975
- for (const fieldSpec of section.fields) {
976
- const normalized = normalizeEditField(fieldSpec);
977
- if (!fieldNames.has(normalized.field)) {
978
- throw new Error(
979
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) layout references unknown ` +
980
- `field "${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
981
- );
982
- }
983
- }
984
- }
985
- // configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
986
- // jeder qualifizierte Key muss in der Registry existieren.
987
- for (const fname of fieldNames) {
988
- const qualified = screen.configKeys[fname];
989
- if (qualified === undefined) {
990
- throw new Error(
991
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" hat ` +
992
- `keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
993
- `einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`,
994
- );
995
- }
996
- if (!allConfigKeyQns.has(qualified)) {
997
- throw new Error(
998
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
999
- `Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
1000
- `Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${
1001
- [...allConfigKeyQns].sort().join(", ") || "(keine)"
1002
- }`,
1003
- );
1004
- }
1005
- }
1006
- continue;
1007
- }
1008
-
1009
- if (screen.type === "actionForm") {
1010
- // Tier 2.7d: Action-Form-Screens haben keinen entity-Link, nur
1011
- // einen Write-Handler-QN + Inline-Fields. Sechs Author-Code-
1012
- // Checks am Boot:
1013
- // 1) handler ist non-empty String.
1014
- // 2) handler ist als Write-Handler registriert (cross-feature-
1015
- // Lookup gegen die collected QN-Map). Tippfehler/umbenannte
1016
- // Handler fallen sonst erst beim ersten Klick als 404 auf.
1017
- // 3) fields-Map ist non-empty.
1018
- // 4) Jeder Field-Eintrag hat einen `type`-Discriminator
1019
- // (Tippfehler in Schema → Renderer crasht stumm sonst).
1020
- // 5) layout.sections + jedes referenced field existiert in
1021
- // fields.
1022
- // 6) redirect (wenn gesetzt) verweist auf einen registrierten
1023
- // Screen-QN (Cross-Feature ok).
1024
- if (!screen.handler || typeof screen.handler !== "string") {
1025
- throw new Error(
1026
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has empty or non-string handler.`,
1027
- );
1028
- }
1029
- if (!allWriteHandlerQns.has(screen.handler)) {
1030
- throw new Error(
1031
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) handler "${screen.handler}" ` +
1032
- `is not a registered write-handler. Check the QN spelling (expected ` +
1033
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
1034
- );
1035
- }
1036
- const fieldNames = new Set(Object.keys(screen.fields));
1037
- if (fieldNames.size === 0) {
1038
- throw new Error(
1039
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has empty fields map — ` +
1040
- `declare at least one field.`,
1041
- );
1042
- }
1043
- // Jeder Field-Eintrag muss einen `type`-Discriminator haben.
1044
- // Author-Tippfehler (`title: { required: true }` ohne type) →
1045
- // RenderField fällt zur Laufzeit auf den Default-Renderer und
1046
- // schickt einen leeren String — silent broken. Boot-Fail ist
1047
- // klarer. `type as unknown` weil FieldDefinition als Union nur
1048
- // bekannte Strings erlaubt; wir prüfen Author-Code, der ggf.
1049
- // den Type-Check umgangen hat.
1050
- for (const [fname, fdef] of Object.entries(screen.fields)) {
1051
- // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
1052
- const ftype = (fdef as { type?: unknown }).type;
1053
- if (typeof ftype !== "string" || ftype.length === 0) {
1054
- throw new Error(
1055
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) field "${fname}" has no ` +
1056
- `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
1057
- );
1058
- }
1059
- }
1060
- if (screen.layout.sections.length === 0) {
1061
- throw new Error(
1062
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has an empty sections list — ` +
1063
- `declare at least one section.`,
1064
- );
1065
- }
1066
- for (const section of screen.layout.sections) {
1067
- if (section.fields.length === 0) {
1068
- throw new Error(
1069
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
1070
- `with zero fields — drop the section or add fields to it.`,
1071
- );
1072
- }
1073
- for (const fieldSpec of section.fields) {
1074
- const normalized = normalizeEditField(fieldSpec);
1075
- if (!fieldNames.has(normalized.field)) {
1076
- throw new Error(
1077
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) layout references unknown field ` +
1078
- `"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
1079
- );
1080
- }
1081
- }
1082
- }
1083
- if (screen.redirect !== undefined) {
1084
- // redirect ist die kurze Screen-ID (z.B. "item-list"); der
1085
- // nav-Router resolved sie beim Mount gegen die Schema-Map.
1086
- // Cross-Feature-Redirect ist nicht supported — der nav-Router
1087
- // baut die URL aus screenId direkt, eine voll-QN würde als
1088
- // `/shop:screen:foo/` landen und nirgendwo greifen.
1089
- const candidateQn = qualifyEntityName(feature.name, "screen", screen.redirect);
1090
- if (!allScreenQns.has(candidateQn)) {
1091
- throw new Error(
1092
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect "${screen.redirect}" ` +
1093
- `does not resolve to a registered screen in this feature. Known screens: ${
1094
- [...Object.keys(feature.screens)].sort().join(", ") || "(none)"
1095
- }.`,
1096
- );
1097
- }
1098
- }
1099
- continue;
1100
- }
1101
-
1102
- // entityList / entityEdit: entity-refs are feature-local.
1103
- const entityDef = feature.entities[screen.entity];
1104
- if (!entityDef) {
1105
- const known = Object.keys(feature.entities).sort().join(", ") || "(none)";
1106
- const crossFeature = findEntityFeature(screen.entity, featureMap);
1107
- const hint = crossFeature
1108
- ? ` Entity "${screen.entity}" is owned by feature "${crossFeature}" — cross-feature screen ownership is not supported.`
1109
- : "";
1110
- throw new Error(
1111
- `[Feature ${feature.name}] Screen "${screenId}" references entity "${screen.entity}" ` +
1112
- `which is not declared in this feature (known: ${known}).${hint}`,
1113
- );
1114
- }
1115
-
1116
- const fieldNames = new Set(Object.keys(entityDef.fields));
1117
- if (screen.type === "entityList") {
1118
- // Empty column list would render as a blank table — almost always the
1119
- // sign of an in-progress screen the author forgot to fill in. Fail
1120
- // loud: ui-core's computeListViewModel can't do anything useful with
1121
- // zero columns either.
1122
- if (screen.columns.length === 0) {
1123
- throw new Error(
1124
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) has an empty columns list — ` +
1125
- `declare at least one column.`,
1126
- );
1127
- }
1128
- for (const col of screen.columns) {
1129
- const normalized = normalizeListColumn(col);
1130
- if (!fieldNames.has(normalized.field)) {
1131
- throw new Error(
1132
- buildUnknownFieldMessage(
1133
- feature.name,
1134
- screenId,
1135
- normalized.field,
1136
- screen.entity,
1137
- fieldNames,
1138
- ),
1139
- );
1140
- }
1141
- validateColumnRendererForm(feature.name, screenId, normalized);
1142
- }
1143
- // Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
1144
- // fangen, damit kein "warum kommt die Liste leer / falsch
1145
- // sortiert"-Debug-Cycle zur Laufzeit losgeht.
1146
- if (screen.pageSize !== undefined && screen.pageSize <= 0) {
1147
- throw new Error(
1148
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) has pageSize=${screen.pageSize} — ` +
1149
- `must be a positive integer.`,
1150
- );
1151
- }
1152
- if (screen.defaultSort !== undefined) {
1153
- const sortField = screen.defaultSort.field;
1154
- if (!fieldNames.has(sortField)) {
1155
- throw new Error(
1156
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) defaultSort references unknown ` +
1157
- `field "${sortField}". Known fields: ${[...fieldNames].sort().join(", ")}`,
1158
- );
1159
- }
1160
- // sortable: true Pflicht — verhindert dass das UI auf einer
1161
- // Spalte sortiert, die Server-Side gar keinen DB-Index hat
1162
- // oder im Schema absichtlich nicht sortiert werden soll
1163
- // (Audit-Felder, Computed-Werte). `sortable` lebt heute nur
1164
- // auf TextFieldDef; "in"-narrow lässt das auch für andere
1165
- // Field-Types ohne explizites Flag durchfallen, was ok ist:
1166
- // Number/Date sind natürlich sortierbar, der Author kann sie
1167
- // im Author-Code als sortable markieren wenn das Field-Type
1168
- // es trägt (Erweiterung folgt).
1169
- const fieldDef = entityDef.fields[sortField];
1170
- const isSortable =
1171
- fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
1172
- if (!isSortable) {
1173
- throw new Error(
1174
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) defaultSort.field "${sortField}" ` +
1175
- `is not sortable. Set sortable: true on the field definition or pick another field.`,
1176
- );
1177
- }
1178
- }
1179
- // Screen-Filter (Tier 2.7c) — drei Layer Author-Code-Check:
1180
- // 1) Field existiert auf der Entity (Tippfehler = leere Liste
1181
- // statt Crash; Boot-Fail ist deutlich besser).
1182
- // 2) Field hat `filterable: true` (Author opt-in, analog zu
1183
- // `sortable`). Verhindert dass Audit-/Computed-/encrypted-
1184
- // Felder unbeabsichtigt filterbar werden.
1185
- // 3) Op passt zum Field-Type. Lt/gt auf text-Feldern → Boot-
1186
- // Fail mit Hinweis statt String-Sort-Surprise zur Laufzeit.
1187
- // Außerdem: "in" verlangt readonly Array.
1188
- if (screen.filter !== undefined) {
1189
- const filterField = screen.filter.field;
1190
- if (!fieldNames.has(filterField)) {
1191
- throw new Error(
1192
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references unknown ` +
1193
- `field "${filterField}". Known fields: ${[...fieldNames].sort().join(", ")}`,
1194
- );
1195
- }
1196
- const fieldDef = entityDef.fields[filterField];
1197
- if (fieldDef !== undefined && !isFieldFilterable(fieldDef)) {
1198
- throw new Error(
1199
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references field ` +
1200
- `"${filterField}" which is not filterable. Set filterable: true on the field ` +
1201
- `definition or pick another field.`,
1202
- );
1203
- }
1204
- if (fieldDef !== undefined) {
1205
- const allowedOps = getAllowedFilterOps(fieldDef);
1206
- if (!allowedOps.includes(screen.filter.op)) {
1207
- throw new Error(
1208
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) filter.op ` +
1209
- `"${screen.filter.op}" is not allowed on field "${filterField}" ` +
1210
- `(type "${fieldDef.type}"). Allowed ops: ${allowedOps.join(", ") || "(none)"}.`,
1211
- );
1212
- }
1213
- }
1214
- if (screen.filter.op === "in" && !Array.isArray(screen.filter.value)) {
1215
- throw new Error(
1216
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) filter.op "in" requires ` +
1217
- `filter.value to be a readonly array.`,
1218
- );
1219
- }
1220
- }
1221
- // Tier 2.7e-1: rowActions mit kind:"navigate" pinst dass das
1222
- // referenced screen tatsächlich existiert (selbes Feature). Ein
1223
- // typo'd target landet sonst beim Klick als "Screen not found"-
1224
- // Banner.
1225
- if (screen.rowActions !== undefined) {
1226
- for (const action of screen.rowActions) {
1227
- if (action.kind !== "navigate") continue;
1228
- const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
1229
- if (!allScreenQns.has(candidateQn)) {
1230
- throw new Error(
1231
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
1232
- `navigate-target "${action.screen}" does not resolve to a registered screen in this feature.`,
1233
- );
1234
- }
1235
- }
1236
- }
1237
- } else {
1238
- // Same rationale as the columns check: an entityEdit layout with zero
1239
- // sections (or sections without any fields) renders as nothing — reject
1240
- // at boot so the author sees it before the blank form surprises them.
1241
- if (screen.layout.sections.length === 0) {
1242
- throw new Error(
1243
- `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has an empty sections list — ` +
1244
- `declare at least one section.`,
1245
- );
1246
- }
1247
- for (const section of screen.layout.sections) {
1248
- if (section.fields.length === 0) {
1249
- throw new Error(
1250
- `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
1251
- `with zero fields — drop the section or add fields to it.`,
1252
- );
1253
- }
1254
- for (const fieldSpec of section.fields) {
1255
- const normalized = normalizeEditField(fieldSpec);
1256
- if (!fieldNames.has(normalized.field)) {
1257
- throw new Error(
1258
- buildUnknownFieldMessage(
1259
- feature.name,
1260
- screenId,
1261
- normalized.field,
1262
- screen.entity,
1263
- fieldNames,
1264
- ),
1265
- );
1266
- }
1267
- }
1268
- }
1269
- }
1270
- }
1271
- }
1272
-
1273
- // Form-check für ListColumn-Renderer in der PlatformComponent-Form
1274
- // (`{ react: { __component: "Name" } }`). Der Server kennt die client-
1275
- // seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
1276
- // stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
1277
- // nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
1278
- // nur eine Warnung aus, kein Boot-Fail.
1279
- function validateColumnRendererForm(
1280
- featureName: string,
1281
- screenId: string,
1282
- column: { readonly field: string; readonly renderer?: unknown },
1283
- ): void {
1284
- const renderer = column.renderer;
1285
- // skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
1286
- // wird strukturell validiert. Funktions-, String-QN- und null/undefined-
1287
- // Renderer sind alle gültige andere Formen — kein Form-Fehler.
1288
- if (renderer === null || typeof renderer !== "object") return;
1289
- // @cast-boundary schema-walk — feature-config renderer-shape introspection
1290
- const react = (renderer as { react?: unknown }).react;
1291
- // skip: kein react-Branch → entweder native-only oder kein
1292
- // PlatformComponent — beides außerhalb dieses Checks.
1293
- if (react === undefined || react === null) return;
1294
- if (typeof react !== "object") {
1295
- throw new Error(
1296
- `[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
1297
- `a non-object \`react\` branch — expected \`{ react: { __component: "Name" } }\`.`,
1298
- );
1299
- }
1300
- // @cast-boundary schema-walk — feature-config react-branch introspection
1301
- const component = (react as { __component?: unknown }).__component;
1302
- // skip: ohne __component-Schlüssel ist das keine String-Key-Form
1303
- // (z.B. ein zukünftiger direkter Component-Ref); nicht unsere Domäne.
1304
- if (component === undefined) return;
1305
- if (typeof component !== "string" || component.length === 0) {
1306
- throw new Error(
1307
- `[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
1308
- `\`react.__component\` = ${JSON.stringify(component)} — expected a non-empty string identifying ` +
1309
- `a client-side columnRenderers entry.`,
1310
- );
1311
- }
1312
- }
1313
-
1314
- function findEntityFeature(
1315
- entityName: string,
1316
- featureMap: ReadonlyMap<string, FeatureDefinition>,
1317
- ): string | undefined {
1318
- for (const [name, feature] of featureMap) {
1319
- if (feature.entities[entityName]) return name;
1320
- }
1321
- return undefined;
1322
- }
1323
-
1324
- function buildUnknownFieldMessage(
1325
- featureName: string,
1326
- screenId: string,
1327
- fieldName: string,
1328
- entityName: string,
1329
- knownFields: ReadonlySet<string>,
1330
- ): string {
1331
- const known = [...knownFields].sort().join(", ");
1332
- return (
1333
- `[Feature ${featureName}] Screen "${screenId}" references field "${fieldName}" ` +
1334
- `which does not exist on entity "${entityName}" (known: ${known}).`
1335
- );
1336
- }
1337
-
1338
- // --- Nav validation ---
1339
- //
1340
- // The boot-validator runs BEFORE createRegistry builds the final maps, so we
1341
- // pre-build the qualified name sets for screens + navs here. `qualifyEntityName`
1342
- // is the shared helper with the registry — changing the qualification rule
1343
- // in one place flows through both ingest paths.
1344
-
1345
- function collectScreenQns(features: readonly FeatureDefinition[]): Set<string> {
1346
- const set = new Set<string>();
1347
- for (const f of features) {
1348
- for (const screenId of Object.keys(f.screens)) {
1349
- set.add(qualifyEntityName(f.name, "screen", screenId));
1350
- }
1351
- }
1352
- return set;
1353
- }
1354
-
1355
- // Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
1356
- // Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
1357
- // im Schema deklarierte handler tatsächlich registriert ist —
1358
- // Tippfehler/umbenannte Handler fallen sonst erst zur Laufzeit auf.
1359
- function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
1360
- const set = new Set<string>();
1361
- for (const f of features) {
1362
- for (const handlerName of Object.keys(f.writeHandlers)) {
1363
- set.add(qualifyEntityName(f.name, "write", handlerName));
1364
- }
1365
- }
1366
- return set;
1367
- }
1368
-
1369
- function collectNavQns(
1370
- features: readonly FeatureDefinition[],
1371
- ): Map<string, NavDefinition & { readonly featureName: string }> {
1372
- const map = new Map<string, NavDefinition & { readonly featureName: string }>();
1373
- for (const f of features) {
1374
- for (const [navId, navDef] of Object.entries(f.navs)) {
1375
- const qualified = qualifyEntityName(f.name, "nav", navId);
1376
- map.set(qualified, { ...navDef, featureName: f.name });
1377
- }
1378
- }
1379
- return map;
1380
- }
1381
-
1382
- // Per-feature ref validation: screen + parent refs point at real QNs. Cycle
1383
- // detection runs once globally afterwards (it's cheaper to do a single DFS
1384
- // over the merged graph than restart it per feature).
1385
- function validateNavs(
1386
- feature: FeatureDefinition,
1387
- allScreenQns: ReadonlySet<string>,
1388
- allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
1389
- allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
1390
- ): void {
1391
- for (const [navId, navDef] of Object.entries(feature.navs)) {
1392
- if (navDef.screen !== undefined && !allScreenQns.has(navDef.screen)) {
1393
- throw new Error(
1394
- `[Feature ${feature.name}] Nav entry "${navId}" references screen "${navDef.screen}" ` +
1395
- `which is not registered. Expected a qualified name of the form ` +
1396
- `"<feature>:screen:<id>" pointing at an r.screen() declaration.`,
1397
- );
1398
- }
1399
- if (navDef.parent !== undefined && !allNavQns.has(navDef.parent)) {
1400
- throw new Error(
1401
- `[Feature ${feature.name}] Nav entry "${navId}" references parent "${navDef.parent}" ` +
1402
- `which is not a registered nav entry. Expected a qualified name of the form ` +
1403
- `"<feature>:nav:<id>".`,
1404
- );
1405
- }
1406
- if (navDef.workspaces !== undefined) {
1407
- for (const wsQn of navDef.workspaces) {
1408
- if (!allWorkspaceQns.has(wsQn)) {
1409
- throw new Error(
1410
- `[Feature ${feature.name}] Nav entry "${navId}" self-assigns to workspace "${wsQn}" ` +
1411
- `which is not registered. Expected a qualified name of the form ` +
1412
- `"<feature>:workspace:<id>" pointing at an r.workspace() declaration.`,
1413
- );
1414
- }
1415
- }
1416
- }
1417
- }
1418
- }
1419
-
1420
- // Walks parent-refs across ALL nav entries (cross-feature). A cycle here
1421
- // would crash client-side tree assembly — easier to fail loud at boot than
1422
- // to debug a React "Maximum update depth exceeded" stack trace.
1423
- function validateNavCycles(
1424
- allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
1425
- ): void {
1426
- const visited = new Set<string>();
1427
- const stack = new Set<string>();
1428
-
1429
- function visit(qualified: string, path: string[]): void {
1430
- if (stack.has(qualified)) {
1431
- throw new Error(
1432
- `[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}`,
1433
- );
1434
- }
1435
- // skip: already visited — cycle-detection only needs to traverse each
1436
- // node once, and the `stack` check above catches any actual cycles
1437
- // reached via a different path.
1438
- if (visited.has(qualified)) return;
1439
- visited.add(qualified);
1440
- stack.add(qualified);
1441
- const navDef = allNavQns.get(qualified);
1442
- if (navDef?.parent) {
1443
- visit(navDef.parent, [...path, qualified]);
1444
- }
1445
- stack.delete(qualified);
1446
- }
1447
-
1448
- for (const qualified of allNavQns.keys()) {
1449
- visit(qualified, []);
1450
- }
1451
- }
1452
-
1453
- // Roles we recognise at boot time. The framework has no explicit
1454
- // role-registry (r.defineRoles is a type helper only), so we synthesise
1455
- // one from every handler-access rule plus the "all"/"system" built-ins.
1456
- function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string> {
1457
- const roles = new Set<string>(["all", "system"]);
1458
- for (const f of features) {
1459
- for (const def of Object.values(f.writeHandlers)) {
1460
- if (def.access && "roles" in def.access) {
1461
- for (const r of def.access.roles) roles.add(r);
1462
- }
1463
- }
1464
- for (const def of Object.values(f.queryHandlers)) {
1465
- if (def.access && "roles" in def.access) {
1466
- for (const r of def.access.roles) roles.add(r);
1467
- }
1468
- }
1469
- }
1470
- return roles;
1471
- }
1472
-
1473
- // --- Workspace validation ---
1474
- //
1475
- // Per-app workspace registry, built once up front. Carries `featureName`
1476
- // alongside the definition so error messages can point at the offending
1477
- // feature without a parallel reverse index.
1478
-
1479
- function collectWorkspaceQns(
1480
- features: readonly FeatureDefinition[],
1481
- ): Map<string, WorkspaceDefinition & { readonly featureName: string }> {
1482
- const map = new Map<string, WorkspaceDefinition & { readonly featureName: string }>();
1483
- for (const f of features) {
1484
- for (const [wsId, wsDef] of Object.entries(f.workspaces)) {
1485
- const qualified = qualifyEntityName(f.name, "workspace", wsId);
1486
- map.set(qualified, { ...wsDef, featureName: f.name });
1487
- }
1488
- }
1489
- return map;
1490
- }
1491
-
1492
- function validateWorkspaces(
1493
- feature: FeatureDefinition,
1494
- allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
1495
- ): void {
1496
- for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
1497
- if (wsDef.nav !== undefined) {
1498
- for (const navQn of wsDef.nav) {
1499
- if (!allNavQns.has(navQn)) {
1500
- throw new Error(
1501
- `[Feature ${feature.name}] Workspace "${wsId}" references nav "${navQn}" ` +
1502
- `which is not registered. Expected a qualified name of the form ` +
1503
- `"<feature>:nav:<id>" pointing at an r.nav() declaration.`,
1504
- );
1505
- }
1506
- }
1507
- }
1508
- }
1509
- }
1510
-
1511
- // Single-default rule across the entire app. Mirrors how createApp validates
1512
- // roles up front — a second `default: true` is a configuration error, not a
1513
- // runtime fallback. Apps without any default fall back to "first workspace
1514
- // the user has access to" at render time (handled by shellWorkspaces).
1515
- function validateDefaultWorkspaceUniqueness(
1516
- allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
1517
- ): void {
1518
- const defaults: string[] = [];
1519
- for (const [qn, ws] of allWorkspaceQns) {
1520
- if (ws.default === true) defaults.push(qn);
1521
- }
1522
- if (defaults.length > 1) {
1523
- throw new Error(
1524
- `[Kumiko Workspaces] Multiple workspaces declare default: true — ` +
1525
- `${defaults.join(", ")}. At most one workspace per app may be the default.`,
1526
- );
1527
- }
1528
- }
1
+ export { validateBoot } from "./boot-validator/index";