@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
@@ -0,0 +1,466 @@
1
+ import { parseRefTarget } from "../parse-ref-target";
2
+ import type { FeatureDefinition } from "../types";
3
+
4
+ export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
5
+
6
+ // Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
7
+ // `userOwned` / `tenantOwned` / `allowPlaintext`-Marker → Boot-Warning.
8
+ // Lower-case compare für case-insensitive Match (displayName vs displayname).
9
+ //
10
+ // Bewusst NICHT in der Liste:
11
+ // - `name` allein — zu viele Geschäfts-Kontexte (product.name,
12
+ // tenant.name, role.name) sind kein PII. Personen-Namen werden
13
+ // ueber displayName / firstName / lastName / fullName erfasst.
14
+ //
15
+ // Quelle: docs/plans/datenschutz/crypto-shredding.md Boot-Validation-Sektion.
16
+ export const PII_DIRECT_NAME_HINTS: ReadonlySet<string> = new Set([
17
+ "email",
18
+ "phone",
19
+ "phonenumber",
20
+ "mobile",
21
+ "address",
22
+ "street",
23
+ "postalcode",
24
+ "zipcode",
25
+ "zip",
26
+ "city",
27
+ "displayname",
28
+ "firstname",
29
+ "lastname",
30
+ "fullname",
31
+ "birthday",
32
+ "birthdate",
33
+ "dateofbirth",
34
+ "dob",
35
+ "ssn",
36
+ "taxid",
37
+ "vatid",
38
+ "passport",
39
+ "iban",
40
+ "bic",
41
+ ]);
42
+
43
+ // Field-Namen die typischerweise User-Generated-Content enthalten —
44
+ // User-Forget muss diese mit Author-Subject-Key encrypten.
45
+ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
46
+ "body",
47
+ "text",
48
+ "content",
49
+ "message",
50
+ "comment",
51
+ "description",
52
+ "note",
53
+ "notes",
54
+ ]);
55
+
56
+ // --- Handler access validation ---
57
+
58
+ // Rate-limit modes that bucket per user.id. Anonymous endpoints would put
59
+ // every unauthenticated caller into a single shared bucket (id="anonymous"),
60
+ // turning the rate-limit into a global tap any caller can drain. Boot-fail
61
+ // before the misconfiguration ships.
62
+ const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user+handler"]);
63
+
64
+ // Every handler must declare access. Missing access is treated as default-deny
65
+ // at runtime, but we fail at boot to turn an easy-to-miss security regression
66
+ // into a loud configuration error.
67
+ export function validateHandlerAccess(feature: FeatureDefinition): void {
68
+ for (const [name, handler] of Object.entries(feature.writeHandlers)) {
69
+ if (!handler.access) {
70
+ throw new Error(
71
+ `Write handler "${feature.name}:write:${name}" is missing an access rule. ` +
72
+ `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
73
+ );
74
+ }
75
+ validateAnonymousRateLimit(feature.name, "write", name, handler.access, handler.rateLimit);
76
+ }
77
+ for (const [name, handler] of Object.entries(feature.queryHandlers)) {
78
+ if (!handler.access) {
79
+ throw new Error(
80
+ `Query handler "${feature.name}:query:${name}" is missing an access rule. ` +
81
+ `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
82
+ );
83
+ }
84
+ validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
85
+ }
86
+ }
87
+
88
+ export function validateAnonymousRateLimit(
89
+ featureName: string,
90
+ kind: "write" | "query",
91
+ handlerName: string,
92
+ access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
93
+ rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
94
+ ): void {
95
+ // skip: handler doesn't opt into rate-limit, no user-bucket risk
96
+ if (!rateLimit) return;
97
+ // skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
98
+ // the user-bucket footgun doesn't apply
99
+ if (!("roles" in access)) return;
100
+ // skip: handler doesn't list anonymous, regular role-rate-limit is fine
101
+ if (!access.roles.includes("anonymous")) return;
102
+ // skip: rate-limit is already keyed on something safe (ip / tenant)
103
+ if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return;
104
+ throw new Error(
105
+ `${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` +
106
+ `rateLimit.per="${rateLimit.per}" — every anonymous request shares user.id="anonymous", ` +
107
+ `so this bucket would be a single global tap any caller could drain. ` +
108
+ `Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`,
109
+ );
110
+ }
111
+
112
+ // --- MultiStreamProjection delivery-invariant ---
113
+ //
114
+ // `delivery: "per-instance"` mit einer `table` ist eine semantische Falle:
115
+ // N Dispatcher-Instanzen würden parallel die gleichen INSERT/UPDATE-Zeilen
116
+ // schreiben (Race / Duplicates), und ein Rebuild würde nur eine Zeile in
117
+ // kumiko_event_consumers anfassen (die SHARED_INSTANCE_SENTINEL-Zeile),
118
+ // während Live-Cursor in per-instance-Zeilen liegen → Cursor-Divergenz.
119
+ //
120
+ // Die Invariante ist: per-instance-Consumer sind rein side-effect (SSE,
121
+ // in-memory cache invalidation). Wer eine Tabelle materialisiert, braucht
122
+ // shared delivery — das ist exactly-once globally und gibt dem Rebuild
123
+ // einen einzigen Cursor zum zurücksetzen.
124
+ export function validateMultiStreamProjections(feature: FeatureDefinition): void {
125
+ for (const [name, msp] of Object.entries(feature.multiStreamProjections)) {
126
+ if (msp.delivery === "per-instance" && msp.table !== undefined) {
127
+ throw new Error(
128
+ `[Feature ${feature.name}] MultiStreamProjection "${name}" has delivery="per-instance" AND a backing table — ` +
129
+ `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). ` +
130
+ `Use delivery="shared" (default) for table-materializing projections, or drop the table for side-effect-only consumers (SSE, in-memory caches).`,
131
+ );
132
+ }
133
+ }
134
+ }
135
+
136
+ // --- Located-Timestamp validation ---
137
+ //
138
+ // Wenn ein Feld `type: "timestamp"` einen `locatedBy`-Marker trägt, muss das
139
+ // referenzierte Feld in derselben Entity existieren UND vom Typ `tz` sein.
140
+ // Sonst weiß weder DB-Wrapper noch JSON-Serializer welche TZ zur Wall-Clock
141
+ // gehört → silent data loss bei Reads in anderer Server-TZ.
142
+ //
143
+ // Die häufigste Quelle von Konflikten ist Hand-Konstruktion:
144
+ // { foo: { type: "timestamp", locatedBy: "fooTz" } }
145
+ // ohne das `fooTz`-Feld zu deklarieren. Der `locatedTimestamp(name)` Helper
146
+ // macht das Pair atomar — wer ihn nutzt, fliegt nicht durch diesen Validator.
147
+ export function validateLocatedTimestamps(feature: FeatureDefinition): void {
148
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
149
+ const fields = entity.fields;
150
+ for (const [fieldName, field] of Object.entries(fields)) {
151
+ if (field.type !== "timestamp" || field.locatedBy === undefined) continue;
152
+ const referenced = fields[field.locatedBy];
153
+ if (!referenced) {
154
+ throw new Error(
155
+ `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` +
156
+ `locatedBy: "${field.locatedBy}" but no field with that name exists in the entity. ` +
157
+ `Either declare the tz-field, or use the locatedTimestamp("${fieldName.replace(/At$/, "")}") helper ` +
158
+ `to create the pair atomically.`,
159
+ );
160
+ }
161
+ if (referenced.type !== "tz") {
162
+ throw new Error(
163
+ `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` +
164
+ `locatedBy: "${field.locatedBy}" but that field is type "${referenced.type}", ` +
165
+ `expected "tz". The locatedBy marker must point to a tz-field (IANA-zone slot).`,
166
+ );
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ // --- Entity-Index validation ---
173
+ //
174
+ // entity.indexes deklariert Composite-/Unique-Indices über mehrere Feld-
175
+ // Spalten. Häufige Fehler: Tippfehler im Feld-Namen, leere column-Liste,
176
+ // Index auf einem Field das die DB-Spalte gar nicht existiert (file/image
177
+ // in der multi-Variante). Catched at boot, lange bevor drizzle-kit beim
178
+ // generate-Run zickt.
179
+ //
180
+ // `tenantId` als einzige Spalte ist redundant — buildDrizzleTable legt
181
+ // den Index sowieso automatisch an. Wir lassen die Composite-Form erlaubt
182
+ // (`["tenantId", "key"]` ist sinnvoll), nur die rein-tenantId-Single-
183
+ // column-Form blockieren wir.
184
+ export function validateEntityIndexes(feature: FeatureDefinition): void {
185
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
186
+ if (!entity.indexes) continue;
187
+ const fieldNames = new Set(Object.keys(entity.fields));
188
+ for (const [idx, def] of entity.indexes.entries()) {
189
+ const where = `Feature "${feature.name}", entity "${entityName}", indexes[${idx}]`;
190
+ if (def.columns.length === 0) {
191
+ throw new Error(`${where}: empty columns list. An index needs at least one column.`);
192
+ }
193
+ for (const col of def.columns) {
194
+ if (col === "tenantId" || col === "id" || col === "version") continue; // base columns
195
+ if (!fieldNames.has(col)) {
196
+ throw new Error(
197
+ `${where}: column "${col}" does not match any field in the entity. ` +
198
+ `Available fields: ${[...fieldNames].join(", ")}.`,
199
+ );
200
+ }
201
+ const field = entity.fields[col];
202
+ if (
203
+ field &&
204
+ (field.type === "files" ||
205
+ field.type === "images" ||
206
+ (field.type === "reference" && field.multiple === true))
207
+ ) {
208
+ throw new Error(
209
+ `${where}: column "${col}" is a multi-value field (${field.type}) — ` +
210
+ `these have no DB column to index on. Use a single-value field or remove from the index.`,
211
+ );
212
+ }
213
+ if (field && field.type === "longText") {
214
+ // longText ist semantisch "potentially-megabytes content" — ein
215
+ // BTREE-Index auf einer 1-MB-Spalte ist Performance-Disaster
216
+ // (PG würde in TOAST-pages dereferenzieren müssen für jeden
217
+ // Index-Lookup). Konsistent mit der type-level-decision dass
218
+ // longText kein sortable/searchable/filterable hat. Wer
219
+ // wirklich indexieren will, nimmt `text` mit den
220
+ // entsprechenden Skalierungs-Trade-offs.
221
+ throw new Error(
222
+ `${where}: column "${col}" is a longText field — these cannot be indexed. ` +
223
+ `Use \`text\` if you need indexing, or rely on the SearchAdapter (Meilisearch) for full-text search on long content.`,
224
+ );
225
+ }
226
+ }
227
+ if (def.columns.length === 1 && def.columns[0] === "tenantId") {
228
+ throw new Error(
229
+ `${where}: single-column index on "tenantId" is redundant — ` +
230
+ `buildDrizzleTable always creates one automatically. Remove this entry.`,
231
+ );
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ // --- Encrypted field validation ---
238
+
239
+ export function validateEncryptedFields(feature: FeatureDefinition): boolean {
240
+ let found = false;
241
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
242
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
243
+ // Beide string-typed fields können encrypted sein. Die
244
+ // searchable/sortable-Konflikt-Checks gelten nur für `text`
245
+ // (longText hat diese flags type-level nicht).
246
+ if (field.type !== "text" && field.type !== "longText") continue;
247
+ if (!field.encrypted) continue;
248
+ found = true;
249
+
250
+ if (field.type === "text") {
251
+ if (field.searchable) {
252
+ throw new Error(
253
+ `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable`,
254
+ );
255
+ }
256
+ if (field.sortable) {
257
+ throw new Error(
258
+ `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable`,
259
+ );
260
+ }
261
+ }
262
+ }
263
+ }
264
+ return found;
265
+ }
266
+
267
+ // --- File field detection ---
268
+
269
+ export function validateFileFields(feature: FeatureDefinition): boolean {
270
+ for (const entity of Object.values(feature.entities)) {
271
+ for (const field of Object.values(entity.fields)) {
272
+ if (FILE_FIELD_TYPES.has(field.type)) return true;
273
+ }
274
+ }
275
+ return false;
276
+ }
277
+
278
+ // --- Embedded field validation ---
279
+
280
+ const VALID_EMBEDDED_SUB_TYPES = new Set(["text", "number", "boolean", "date"]);
281
+
282
+ // Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef-Validation.
283
+ // 1) referenced entity existiert (same-feature OR cross-feature
284
+ // qualifiziert per "<feature>:<entity>"). Same-feature ist
285
+ // Default; cross-feature verlangt expliziten ":"-Prefix.
286
+ // 2) labelField (wenn gesetzt) existiert auf der referenced Entity.
287
+ // 3) Self-Reference erlaubt (entity → entity).
288
+ // 4) Audit-Fix: Query-Handler `<feature>:query:<entity>:list` muss
289
+ // registriert sein — der Renderer feuert den beim Combobox-
290
+ // Open. Ohne Handler crasht die Combobox zur Laufzeit.
291
+ export function validateReferenceFields(
292
+ feature: FeatureDefinition,
293
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
294
+ ): void {
295
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
296
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
297
+ if (field.type !== "reference") continue;
298
+
299
+ const target = parseRefTarget(field.entity, feature.name);
300
+ const targetFeature = featureMap.get(target.featureName);
301
+ if (!targetFeature) {
302
+ const knownFeatures = [...featureMap.keys()].sort().join(", ");
303
+ throw new Error(
304
+ `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
305
+ `targets unknown feature "${target.featureName}" via "${field.entity}". ` +
306
+ `Known features: ${knownFeatures}.`,
307
+ );
308
+ }
309
+ const targetEntity = targetFeature.entities[target.entityName];
310
+ if (!targetEntity) {
311
+ const known = Object.keys(targetFeature.entities).sort().join(", ") || "(none)";
312
+ const where =
313
+ target.featureName === feature.name
314
+ ? `in this feature`
315
+ : `in feature "${target.featureName}"`;
316
+ throw new Error(
317
+ `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
318
+ `targets unknown entity "${target.entityName}" ${where}. ` +
319
+ `Known entities: ${known}.`,
320
+ );
321
+ }
322
+ if (field.labelField !== undefined) {
323
+ const knownFields = Object.keys(targetEntity.fields);
324
+ // "id" ist immer da, auch ohne Field-Definition (PK).
325
+ if (field.labelField !== "id" && !knownFields.includes(field.labelField)) {
326
+ throw new Error(
327
+ `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
328
+ `references labelField "${field.labelField}" which does not exist on entity ` +
329
+ `"${target.entityName}". Known fields: ${[...knownFields, "id"].sort().join(", ")}.`,
330
+ );
331
+ }
332
+ }
333
+ // Audit-Fix #2: Query-Handler-Existenz pinnen. Renderer feuert
334
+ // `<targetFeature>:query:<targetEntity>:list` beim Combobox-Open
335
+ // (use-reference-lookup, ReferenceInput); ohne Handler kommt
336
+ // beim ersten Klick ein 404. defaultEntityQueryHandler-Names
337
+ // sind als kurz "<entity>:list" in feature.queryHandlers gespeichert.
338
+ const expectedHandlerShortName = `${target.entityName}:list`;
339
+ if (targetFeature.queryHandlers[expectedHandlerShortName] === undefined) {
340
+ throw new Error(
341
+ `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
342
+ `targets entity "${target.entityName}" but no list-query-handler is registered ` +
343
+ `there. Add r.queryHandler(defineEntityListHandler("${target.entityName}", ` +
344
+ `${target.entityName}Entity)) to feature "${target.featureName}", or pick a ` +
345
+ `different label/entity.`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ export function validateEmbeddedFields(feature: FeatureDefinition): void {
353
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
354
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
355
+ if (field.type !== "embedded") continue;
356
+
357
+ if (!field.schema || Object.keys(field.schema).length === 0) {
358
+ throw new Error(
359
+ `Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema`,
360
+ );
361
+ }
362
+
363
+ for (const [subName, subField] of Object.entries(field.schema)) {
364
+ if (!VALID_EMBEDDED_SUB_TYPES.has(subField.type)) {
365
+ throw new Error(
366
+ `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid type "${subField.type}". Allowed: ${[...VALID_EMBEDDED_SUB_TYPES].join(", ")}`,
367
+ );
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ // --- MultiSelect field validation ---
375
+ //
376
+ // options muss non-empty sein (sonst wäre das Feld nicht benutzbar) und
377
+ // default — wenn gesetzt — ist eine Teilmenge der options. Beides würde
378
+ // auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
379
+ // die früheste Stelle für klare Fehlermeldungen.
380
+ export function validateMultiSelectFields(feature: FeatureDefinition): void {
381
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
382
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
383
+ if (field.type !== "multiSelect") continue;
384
+
385
+ if (field.options.length === 0) {
386
+ throw new Error(
387
+ `MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options`,
388
+ );
389
+ }
390
+
391
+ if (field.default !== undefined) {
392
+ const validOptions = new Set<string>(field.options);
393
+ for (const value of field.default) {
394
+ if (!validOptions.has(value)) {
395
+ throw new Error(
396
+ `MultiSelect default "${value}" on "${entityName}.${fieldName}" is not a valid option. Valid: ${field.options.join(", ")}`,
397
+ );
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ // --- Transition validation ---
406
+
407
+ export function validateTransitions(feature: FeatureDefinition): void {
408
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
409
+ if (!entity.transitions) continue;
410
+
411
+ for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
412
+ const field = entity.fields[fieldName];
413
+
414
+ if (!field) {
415
+ throw new Error(
416
+ `Transitions defined for unknown field "${fieldName}" on entity "${entityName}" in feature "${feature.name}"`,
417
+ );
418
+ }
419
+
420
+ if (field.type !== "select") {
421
+ throw new Error(
422
+ `Transitions defined for field "${fieldName}" on entity "${entityName}" but field type is "${field.type}" (must be "select")`,
423
+ );
424
+ }
425
+
426
+ const validOptions = new Set(field.options);
427
+
428
+ // Check all states in the transition map
429
+ for (const [from, targets] of Object.entries(transitionMap)) {
430
+ if (!validOptions.has(from)) {
431
+ throw new Error(
432
+ `Transition state "${from}" on "${entityName}.${fieldName}" is not a valid option. Valid: ${[...validOptions].join(", ")}`,
433
+ );
434
+ }
435
+ for (const to of targets) {
436
+ if (!validOptions.has(to)) {
437
+ throw new Error(
438
+ `Transition target "${to}" (from "${from}") on "${entityName}.${fieldName}" is not a valid option. Valid: ${[...validOptions].join(", ")}`,
439
+ );
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ }
446
+
447
+ // --- extendSchema column collision detection ---
448
+
449
+ export function validateExtendSchemaCollisions(feature: FeatureDefinition): void {
450
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
451
+ const existingFields = new Set(Object.keys(entity.fields));
452
+
453
+ // Check if any registered extension would collide with existing fields
454
+ for (const ext of Object.values(feature.registrarExtensions)) {
455
+ if (!ext.extendSchema) continue;
456
+ const extraFields = ext.extendSchema(entityName);
457
+ for (const fieldName of Object.keys(extraFields)) {
458
+ if (existingFields.has(fieldName)) {
459
+ throw new Error(
460
+ `extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}"`,
461
+ );
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
@@ -0,0 +1,159 @@
1
+ import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
2
+ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
3
+ import {
4
+ validateCircularDeps,
5
+ validateConfigKeyAllowPerRequest,
6
+ validateConfigKeyBounds,
7
+ validateConfigKeyComputed,
8
+ validateConfigReads,
9
+ warnOnToggleableDependencies,
10
+ } from "./config-deps";
11
+ import {
12
+ validateEmbeddedFields,
13
+ validateEncryptedFields,
14
+ validateEntityIndexes,
15
+ validateExtendSchemaCollisions,
16
+ validateFileFields,
17
+ validateHandlerAccess,
18
+ validateLocatedTimestamps,
19
+ validateMultiSelectFields,
20
+ validateMultiStreamProjections,
21
+ validateReferenceFields,
22
+ validateTransitions,
23
+ } from "./entity-handler";
24
+ import { validateOwnershipRules } from "./ownership";
25
+ import { validatePiiAndRetention } from "./pii-retention";
26
+ import {
27
+ collectKnownRoles,
28
+ collectNavQns,
29
+ collectScreenQns,
30
+ collectWorkspaceQns,
31
+ collectWriteHandlerQns,
32
+ validateDefaultWorkspaceUniqueness,
33
+ validateNavCycles,
34
+ validateNavs,
35
+ validateScreens,
36
+ validateWorkspaces,
37
+ } from "./screens-nav";
38
+
39
+ /**
40
+ * Validates all feature configurations at boot time.
41
+ * Throws on the first error found — fail fast.
42
+ */
43
+ export function validateBoot(features: readonly FeatureDefinition[]): void {
44
+ const featureMap = new Map<string, FeatureDefinition>();
45
+ for (const f of features) {
46
+ featureMap.set(f.name, f);
47
+ }
48
+
49
+ // Collect all extension names and their schema extensions
50
+ const extensionProviders = new Map<string, string>();
51
+ for (const f of features) {
52
+ for (const extName of Object.keys(f.registrarExtensions)) {
53
+ extensionProviders.set(extName, f.name);
54
+ }
55
+ }
56
+
57
+ // Collect all config keys across features (for cross-feature reference validation)
58
+ const allConfigKeys = new Set<string>();
59
+ // Qualified config-key set für ConfigEditScreen-Validation. Format
60
+ // wie in registry.ts: `<feature>:config:<short>`. allConfigKeys oben
61
+ // nutzt das ältere `feature.short`-Format für validateConfigReads.
62
+ const allConfigKeyQns = new Set<string>();
63
+ for (const f of features) {
64
+ for (const key of Object.keys(f.configKeys)) {
65
+ allConfigKeys.add(`${f.name}.${key}`);
66
+ allConfigKeyQns.add(`${f.name}:config:${key}`);
67
+ }
68
+ }
69
+
70
+ // Collect all claim keys — the ownership-rule validator below resolves
71
+ // `from("claim:<feature>:<key>")` strings against this map. Qualified name
72
+ // is how the resolver / readClaim / ownership system all reference claims,
73
+ // so we key on the qualifiedName here too.
74
+ const allClaimKeys = new Map<string, ClaimKeyDefinition>();
75
+ for (const f of features) {
76
+ for (const def of Object.values(f.claimKeys)) {
77
+ allClaimKeys.set(def.qualifiedName, def);
78
+ }
79
+ }
80
+
81
+ // Cross-feature role set — derived from handler-access rules + framework
82
+ // built-ins ("all", "system"). We don't have a dedicated role-registry
83
+ // (r.defineRoles is a type-level helper, not a runtime export), so we
84
+ // use "referenced in any handler access rule" as the corpus of known
85
+ // roles. The ownership-validator checks OwnershipMap keys + legacy
86
+ // string[] field-access entries against this set — typos like "Admi"
87
+ // instead of "Admin" fail at boot if nothing else ever mentions "Admi".
88
+ const knownRoles = collectKnownRoles(features);
89
+
90
+ // Cross-feature screen + nav registry — built once up front so per-feature
91
+ // validators can check nav-ref targets + parent chains without re-scanning
92
+ // every feature's navs map.
93
+ const allScreenQns = collectScreenQns(features);
94
+ const allNavQns = collectNavQns(features);
95
+ const allWorkspaceQns = collectWorkspaceQns(features);
96
+ const allWriteHandlerQns = collectWriteHandlerQns(features);
97
+
98
+ // Cross-feature API exposure-map — jedes Feature deklariert Marker via
99
+ // r.exposesApi(name). Per-feature validateApiExposureMatching walkt
100
+ // usedApis-Set und checkt dass jeder Eintrag hier einen Match findet.
101
+ // Verhindert dass typo-getroffene oder gedroppte QN-Aufrufe zu
102
+ // Runtime-Crash statt Boot-Fail werden.
103
+ const allExposedApis = new Map<string, string>(); // apiName → providerFeature
104
+ for (const f of features) {
105
+ for (const apiName of f.exposedApis) {
106
+ const existing = allExposedApis.get(apiName);
107
+ if (existing && existing !== f.name) {
108
+ throw new Error(
109
+ `Cross-feature API "${apiName}" exposed by both "${existing}" and "${f.name}" — API names must be globally unique.`,
110
+ );
111
+ }
112
+ allExposedApis.set(apiName, f.name);
113
+ }
114
+ }
115
+
116
+ let hasEncryptedFields = false;
117
+ let hasFileFields = false;
118
+
119
+ for (const feature of features) {
120
+ validateCircularDeps(feature.name, featureMap);
121
+ if (validateEncryptedFields(feature)) hasEncryptedFields = true;
122
+ if (validateFileFields(feature)) hasFileFields = true;
123
+ validatePiiAndRetention(feature);
124
+ validateApiExposureMatching(feature, allExposedApis, featureMap);
125
+ validateEmbeddedFields(feature);
126
+ validateMultiSelectFields(feature);
127
+ validateReferenceFields(feature, featureMap);
128
+ validateTransitions(feature);
129
+ validateExtensionUsages(feature, extensionProviders);
130
+ validateExtendSchemaCollisions(feature);
131
+ validateHandlerAccess(feature);
132
+ validateLocatedTimestamps(feature);
133
+ validateEntityIndexes(feature);
134
+ validateConfigKeyBounds(feature);
135
+ validateConfigKeyComputed(feature);
136
+ validateConfigKeyAllowPerRequest(feature);
137
+ validateOwnershipRules(feature, allClaimKeys, knownRoles);
138
+ validateMultiStreamProjections(feature);
139
+ validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns);
140
+ validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
141
+ validateWorkspaces(feature, allNavQns);
142
+ }
143
+
144
+ validateNavCycles(allNavQns);
145
+ validateDefaultWorkspaceUniqueness(allWorkspaceQns);
146
+
147
+ if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
148
+ throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
149
+ }
150
+
151
+ if (hasFileFields && !process.env["FILE_STORAGE_PROVIDER"]) {
152
+ throw new Error(
153
+ "FILE_STORAGE_PROVIDER environment variable is required (file/image fields in use)",
154
+ );
155
+ }
156
+
157
+ validateConfigReads(features, allConfigKeys);
158
+ warnOnToggleableDependencies(features, featureMap);
159
+ }