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