@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.
- package/CHANGELOG.md +93 -0
- package/package.json +124 -39
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/compliance/profiles.ts +8 -8
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +3 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1804
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +88 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/factories.ts +12 -12
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +7 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +49 -1
- package/src/engine/feature-ast/render.ts +17 -1
- package/src/engine/index.ts +44 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
- package/src/engine/pattern-library/library.ts +42 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +2 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +93 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/index.ts +11 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +132 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/file-routes.ts +1 -1
- package/src/files/types.ts +2 -2
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- 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
|
+
}
|