@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
|
@@ -1,1804 +1 @@
|
|
|
1
|
-
|
|
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";
|