@cosmicdrift/kumiko-framework 0.399.0 → 0.401.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/AGENTS.md +4 -3
  2. package/agents/api/framework-engine.md +16 -8
  3. package/agents/api/framework-ui-types.md +10 -5
  4. package/agents/api/renderer-web.md +2 -1
  5. package/agents/api/testing-stack.md +1 -0
  6. package/agents/api/types.md +256 -80
  7. package/agents/api.md +3 -3
  8. package/agents/examples/derived-fields-sql.md +251 -0
  9. package/agents/examples/documents.md +161 -0
  10. package/agents/examples/inspection-checklist.md +807 -0
  11. package/agents/examples/relations.md +13 -3
  12. package/agents/examples.md +3 -0
  13. package/agents/features-index.md +2 -0
  14. package/agents/features.md +8 -0
  15. package/dist/api/entity-read-access.d.ts +2 -0
  16. package/dist/api/entity-read-access.js +46 -2
  17. package/dist/api/server-boot-checks.js +2 -2
  18. package/dist/api/server-routes.js +3 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/db/derived-field-sql.d.ts +23 -0
  21. package/dist/db/derived-field-sql.js +115 -0
  22. package/dist/db/derived-related-sql.d.ts +31 -0
  23. package/dist/db/derived-related-sql.js +137 -0
  24. package/dist/db/driver-row-coercion.d.ts +1 -0
  25. package/dist/db/driver-row-coercion.js +13 -0
  26. package/dist/db/eagerload.d.ts +3 -1
  27. package/dist/db/eagerload.js +4 -6
  28. package/dist/db/event-store-executor-context.js +1 -1
  29. package/dist/db/event-store-executor-read-paging.js +1 -1
  30. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  31. package/dist/db/event-store-executor-read-search.js +17 -9
  32. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  33. package/dist/db/event-store-executor-read-sort.js +27 -14
  34. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  35. package/dist/db/event-store-executor-read-where.js +3 -9
  36. package/dist/db/event-store-executor-read.js +23 -4
  37. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  38. package/dist/db/event-store-executor-write-steps.js +20 -1
  39. package/dist/db/event-store-executor-write.js +8 -8
  40. package/dist/db/money.d.ts +4 -0
  41. package/dist/db/money.js +34 -29
  42. package/dist/db/parent-ref-clause.d.ts +1 -0
  43. package/dist/db/parent-ref-clause.js +18 -8
  44. package/dist/db/read-gate-clause.d.ts +7 -0
  45. package/dist/db/read-gate-clause.js +201 -0
  46. package/dist/db/read-ownership.d.ts +9 -0
  47. package/dist/db/read-ownership.js +12 -0
  48. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  49. package/dist/engine/boot-validator/boot-check.js +7 -11
  50. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  51. package/dist/engine/boot-validator/boot-context.js +57 -0
  52. package/dist/engine/boot-validator/boot-options.d.ts +38 -0
  53. package/dist/engine/boot-validator/boot-options.js +1 -0
  54. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  55. package/dist/engine/boot-validator/boot-phases-app.js +107 -0
  56. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  57. package/dist/engine/boot-validator/boot-phases.js +140 -0
  58. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  59. package/dist/engine/boot-validator/boot-report.js +47 -0
  60. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  61. package/dist/engine/boot-validator/boot-rule.js +14 -0
  62. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  63. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  64. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  65. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  66. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  67. package/dist/engine/boot-validator/entity-handler.js +6 -3
  68. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  69. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  70. package/dist/engine/boot-validator/field-conditions.js +196 -0
  71. package/dist/engine/boot-validator/i18n-keys.d.ts +3 -2
  72. package/dist/engine/boot-validator/i18n-keys.js +18 -4
  73. package/dist/engine/boot-validator/index.d.ts +5 -34
  74. package/dist/engine/boot-validator/index.js +24 -204
  75. package/dist/engine/boot-validator/pii-retention.js +15 -9
  76. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  77. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  78. package/dist/engine/boot-validator/read-gate.js +59 -0
  79. package/dist/engine/boot-validator/screens-forms.js +19 -6
  80. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  81. package/dist/engine/boot-validator/screens-lists.js +8 -0
  82. package/dist/engine/boot-validator/zod-shape.js +4 -2
  83. package/dist/engine/build-app-schema.js +11 -5
  84. package/dist/engine/embedded-derived.d.ts +8 -6
  85. package/dist/engine/embedded-derived.js +25 -7
  86. package/dist/engine/entity-handlers.d.ts +13 -6
  87. package/dist/engine/entity-handlers.js +17 -18
  88. package/dist/engine/entity-list-runtime-options.d.ts +2 -1
  89. package/dist/engine/entity-list-runtime-options.js +86 -5
  90. package/dist/engine/factories.d.ts +18 -6
  91. package/dist/engine/factories.js +21 -5
  92. package/dist/engine/field-access.d.ts +2 -0
  93. package/dist/engine/field-access.js +48 -1
  94. package/dist/engine/field-state.d.ts +12 -0
  95. package/dist/engine/field-state.js +67 -0
  96. package/dist/engine/handler-helpers.d.ts +3 -0
  97. package/dist/engine/handler-helpers.js +7 -1
  98. package/dist/engine/index.d.ts +3 -3
  99. package/dist/engine/index.js +3 -3
  100. package/dist/engine/ownership.d.ts +1 -1
  101. package/dist/engine/ownership.js +9 -5
  102. package/dist/engine/registry-state.d.ts +1 -0
  103. package/dist/engine/registry-state.js +1 -1
  104. package/dist/engine/schema-builder.js +3 -0
  105. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  106. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  107. package/dist/engine/user-entity-reference.d.ts +2 -0
  108. package/dist/engine/user-entity-reference.js +7 -0
  109. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  110. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  111. package/dist/files/bind-pending-file-refs.js +11 -12
  112. package/dist/files/bind-referenced-files.write.js +5 -5
  113. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  114. package/dist/files/create-file-ref-from-bytes.js +2 -0
  115. package/dist/files/create-file-ref.d.ts +2 -0
  116. package/dist/files/create-file-ref.js +39 -22
  117. package/dist/files/dispose-removed-file-refs.js +5 -8
  118. package/dist/files/file-field-slots.d.ts +10 -0
  119. package/dist/files/file-field-slots.js +53 -0
  120. package/dist/files/file-ref-entity.d.ts +1 -1
  121. package/dist/files/file-ref-table.d.ts +1 -1
  122. package/dist/files/file-routes.js +12 -3
  123. package/dist/files/index.d.ts +3 -0
  124. package/dist/files/index.js +2 -0
  125. package/dist/files/load-save-file-refs.js +3 -4
  126. package/dist/files/rebind-file-ref.d.ts +41 -0
  127. package/dist/files/rebind-file-ref.js +170 -0
  128. package/dist/files/record-read-check.js +2 -30
  129. package/dist/files/record-write-check.d.ts +2 -0
  130. package/dist/files/record-write-check.js +18 -3
  131. package/dist/files/upload-field-resolution.d.ts +2 -1
  132. package/dist/files/upload-field-resolution.js +33 -13
  133. package/dist/i18n/required-surface-keys.d.ts +6 -0
  134. package/dist/i18n/required-surface-keys.js +61 -0
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-boot-input.js +2 -0
  139. package/dist/schema-cli/validate-command.js +46 -12
  140. package/dist/schema-cli.js +1 -1
  141. package/dist/ui-types/index.d.ts +2 -1
  142. package/dist/ui-types/index.js +2 -1
  143. package/package.json +3 -3
  144. package/src/changes.json +68 -0
@@ -0,0 +1,196 @@
1
+ import { isFileSubField } from "../../files/file-field-slots.js";
2
+ import { parseMaxSize } from "../../files/types.js";
3
+ import { conditionComparisonValues, LOCKABLE_FIELD_TYPES } from "./entity-handler.js";
4
+ const STATE_KEYS = ["visibleWhen", "requiredWhen"];
5
+ const SUB_CONDITION_TYPES = new Set([
6
+ "text",
7
+ "number",
8
+ "decimal",
9
+ "money",
10
+ "boolean",
11
+ "date",
12
+ "select",
13
+ "reference",
14
+ ]);
15
+ const NUMERIC_SUB_TYPES = new Set(["number", "decimal", "money"]);
16
+ function assertNoRequiredConflict(where, def, required) {
17
+ // skip: a field that is not statically required cannot conflict with requiredWhen
18
+ if (!required)
19
+ return;
20
+ if (def.requiredWhen !== undefined) {
21
+ throw new Error(`${where} sets required: true together with requiredWhen — drop required (requiredWhen covers the conditional case).`);
22
+ }
23
+ if (def.visibleWhen !== undefined) {
24
+ throw new Error(`${where} sets required: true together with visibleWhen — a hidden field cannot be required; use requiredWhen with the same condition.`);
25
+ }
26
+ }
27
+ function assertComparisonValuesAreOptions(where, key, condition, options) {
28
+ for (const value of conditionComparisonValues(condition)) {
29
+ if (typeof value !== "string" || !options.includes(value)) {
30
+ throw new Error(`${where} has ${key} value "${String(value)}" which is not an option of "${condition.field}" (${options.join(", ")}).`);
31
+ }
32
+ }
33
+ }
34
+ function assertTopLevelCondition(where, key, fieldName, condition, entity) {
35
+ const target = entity.fields[condition.field];
36
+ if (condition.field === fieldName) {
37
+ throw new Error(`${where} has ${key} on its own field. Make the condition depend on a different field.`);
38
+ }
39
+ if (target === undefined) {
40
+ throw new Error(`${where} has ${key}.field "${condition.field}" which is not a field of the entity. Use an existing field of the entity.`);
41
+ }
42
+ if (!LOCKABLE_FIELD_TYPES.has(target.type)) {
43
+ throw new Error(`${where} has ${key}.field "${condition.field}" of type "${target.type}", which does not store a plain scalar the condition can compare. Use a text, select, boolean, number or reference field.`);
44
+ }
45
+ if (target.type === "select") {
46
+ assertComparisonValuesAreOptions(where, key, condition, target.options);
47
+ }
48
+ }
49
+ function assertSubFieldCondition(where, key, subName, condition, embedded) {
50
+ const target = embedded.schema[condition.field];
51
+ if (condition.field === subName) {
52
+ throw new Error(`${where} has ${key} on its own sub-field. Make the condition depend on a sibling.`);
53
+ }
54
+ if (target === undefined) {
55
+ throw new Error(`${where} has ${key}.field "${condition.field}" which is not a sub-field of the same row. Use a sibling sub-field.`);
56
+ }
57
+ if (!SUB_CONDITION_TYPES.has(target.type)) {
58
+ throw new Error(`${where} has ${key}.field "${condition.field}" of type "${target.type}", which cannot be compared. Use a text, number, decimal, money, boolean, date, select or reference sub-field.`);
59
+ }
60
+ if (target.type === "select") {
61
+ const stateKey = key === "when" ? "visibleWhen" : key;
62
+ assertComparisonValuesAreOptions(where, stateKey, condition, target.options);
63
+ }
64
+ }
65
+ function assertFileSubField(where, sub) {
66
+ if (sub.searchable === true) {
67
+ throw new Error(`${where} is a ${sub.type} sub-field and cannot be searchable.`);
68
+ }
69
+ if (sub.maxCount !== undefined && !(Number.isInteger(sub.maxCount) && sub.maxCount > 0)) {
70
+ throw new Error(`${where} has invalid maxCount ${sub.maxCount}. Must be a positive integer.`);
71
+ }
72
+ if (sub.maxSize !== undefined) {
73
+ try {
74
+ parseMaxSize(sub.maxSize);
75
+ }
76
+ catch {
77
+ throw new Error(`${where} has invalid maxSize "${sub.maxSize}". Use e.g. "10mb", "500kb".`);
78
+ }
79
+ }
80
+ assertAcceptEntries(where, sub.accept);
81
+ }
82
+ // validateFile compares accept entries with the lower-cased last file-name segment, so only
83
+ // bare lower-case extensions can ever match.
84
+ const BARE_EXTENSION = /^[a-z0-9]+$/;
85
+ function assertAcceptEntries(where, accept) {
86
+ if (accept?.some((ext) => ext.length === 0)) {
87
+ throw new Error(`${where} has an empty entry in accept. List file extensions like "pdf".`);
88
+ }
89
+ const offender = accept?.find((entry) => !BARE_EXTENSION.test(entry));
90
+ if (offender !== undefined) {
91
+ throw new Error(`${where} has accept entry "${offender}", which can never match an uploaded file. accept takes bare lower-case file extensions without dot, MIME type or wildcard: use "pdf" instead of ".pdf" or "application/pdf".`);
92
+ }
93
+ }
94
+ function assertSelectSubFieldTones(where, sub) {
95
+ // skip: only select sub-fields with declared tones have tones to check
96
+ if (sub.type !== "select" || sub.optionTones === undefined)
97
+ return;
98
+ for (const option of Object.keys(sub.optionTones)) {
99
+ if (!sub.options.includes(option)) {
100
+ throw new Error(`${where} has optionTones["${option}"] which is not an option (${sub.options.join(", ")}).`);
101
+ }
102
+ }
103
+ }
104
+ function assertSubFields(where, embedded) {
105
+ for (const [subName, sub] of Object.entries(embedded.schema)) {
106
+ const subWhere = `${where}.${subName}`;
107
+ assertNoRequiredConflict(subWhere, sub, sub.required === true);
108
+ for (const key of STATE_KEYS) {
109
+ const condition = sub[key];
110
+ if (condition !== undefined)
111
+ assertSubFieldCondition(subWhere, key, subName, condition, embedded);
112
+ }
113
+ assertSelectSubFieldTones(subWhere, sub);
114
+ if (isFileSubField(sub))
115
+ assertFileSubField(subWhere, sub);
116
+ }
117
+ }
118
+ function assertNumericSources(where, derivedName, embedded, from) {
119
+ for (const source of from) {
120
+ const sourceType = embedded.schema[source]?.type;
121
+ if (sourceType === undefined || !NUMERIC_SUB_TYPES.has(sourceType)) {
122
+ throw new Error(`${where} has a derived cell "${derivedName}" reading "${source}", which is not a number, decimal or money sub-field.`);
123
+ }
124
+ }
125
+ }
126
+ function assertDerivedCells(where, embedded) {
127
+ for (const [name, cell] of Object.entries(embedded.derived ?? {})) {
128
+ const target = embedded.schema[name];
129
+ assertNumericSources(where, name, embedded, cell.from);
130
+ if (cell.op === "inRange") {
131
+ if (cell.from.length !== 3) {
132
+ throw new Error(`${where} has a derived cell "${name}" with op "inRange", which needs exactly three sources (value, min, max).`);
133
+ }
134
+ if (target?.type !== "select" ||
135
+ !target.options.includes(cell.inside) ||
136
+ !target.options.includes(cell.outside)) {
137
+ throw new Error(`${where} has a derived cell "${name}" with op "inRange", whose target must be a select sub-field with the options "${cell.inside}" and "${cell.outside}".`);
138
+ }
139
+ }
140
+ else if (target === undefined || !NUMERIC_SUB_TYPES.has(target.type)) {
141
+ throw new Error(`${where} has a derived cell "${name}" with op "${cell.op}", whose target must be a number, decimal or money sub-field.`);
142
+ }
143
+ if (cell.when !== undefined) {
144
+ assertSubFieldCondition(`${where}.${name}`, "when", undefined, cell.when, embedded);
145
+ if (cell.when.field in (embedded.derived ?? {})) {
146
+ throw new Error(`${where} has a derived cell "${name}" whose when reads the derived cell "${cell.when.field}". Read a stored sub-field.`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ function assertFileSubFieldsAreNotWired(where, embedded) {
152
+ const fileSubs = new Set(Object.entries(embedded.schema).flatMap(([name, sub]) => (isFileSubField(sub) ? [name] : [])));
153
+ // skip: without file sub-fields there is nothing that could be wired into derived or conditions
154
+ if (fileSubs.size === 0)
155
+ return;
156
+ const wired = [
157
+ ...Object.entries(embedded.derived ?? {}).flatMap(([name, cell]) => [name, ...cell.from]),
158
+ ...Object.keys(embedded.totalsMatch ?? {}),
159
+ ...(embedded.totals ?? []).flatMap((total) => typeof total === "string" ? [total] : total.from),
160
+ ];
161
+ const offender = wired.find((name) => fileSubs.has(name));
162
+ if (offender !== undefined) {
163
+ throw new Error(`${where} uses file sub-field "${offender}" in derived or totals, which only take number, decimal or money sub-fields.`);
164
+ }
165
+ }
166
+ function assertEmbeddedField(where, embedded) {
167
+ assertSubFields(where, embedded);
168
+ assertFileSubFieldsAreNotWired(where, embedded);
169
+ assertDerivedCells(where, embedded);
170
+ }
171
+ function assertField(feature, entityName, entity, fieldName, field) {
172
+ const where = `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}"`;
173
+ assertNoRequiredConflict(where, field, "required" in field && field.required === true);
174
+ for (const key of STATE_KEYS) {
175
+ const condition = field[key];
176
+ if (condition !== undefined)
177
+ assertTopLevelCondition(where, key, fieldName, condition, entity);
178
+ }
179
+ if (field.type === "embedded")
180
+ assertEmbeddedField(where, field);
181
+ if (isFileAcceptField(field))
182
+ assertAcceptEntries(where, field.accept);
183
+ }
184
+ function isFileAcceptField(field) {
185
+ return (field.type === "file" ||
186
+ field.type === "files" ||
187
+ field.type === "image" ||
188
+ field.type === "images");
189
+ }
190
+ export function validateFieldConditions(feature) {
191
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
192
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
193
+ assertField(feature, entityName, entity, fieldName, field);
194
+ }
195
+ }
196
+ }
@@ -1,4 +1,5 @@
1
- import type { FeatureDefinition } from "../types/index.ts";
2
- export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void;
1
+ import type { FeatureDefinition, Registry } from "../types/index.ts";
2
+ export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[], registry: Registry): void;
3
3
  export declare function validateTranslationLocaleConsistency(features: readonly FeatureDefinition[]): void;
4
4
  export declare function validateRequiredLocales(features: readonly FeatureDefinition[], requiredLocales: readonly string[] | undefined): void;
5
+ export declare function validateFieldValueTranslations(features: readonly FeatureDefinition[], enabled: boolean | undefined): void;
@@ -1,7 +1,6 @@
1
- import { buildEffectiveTranslationKeys, featureHasI18nSurface, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
1
+ import { buildEffectiveTranslationKeys, featureHasI18nSurface, fieldValueTranslationKeysFromFeature, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
2
2
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "../build-config-feature-schema.js";
3
3
  import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins.js";
4
- import { createRegistry } from "../registry.js";
5
4
  function isDormantSecretsScreen(screen) {
6
5
  return screen.type === "secretsEdit" && screen.dormant === true;
7
6
  }
@@ -60,9 +59,8 @@ function hasDefinedTranslation(defined, key) {
60
59
  }
61
60
  return false;
62
61
  }
63
- export function validateI18nSurfaceKeys(features) {
62
+ export function validateI18nSurfaceKeys(features, registry) {
64
63
  const defined = buildEffectiveTranslationKeys(features);
65
- const registry = createRegistry(features);
66
64
  const generatedConfigHub = buildConfigFeatureSchema(registry);
67
65
  // The secrets screen's labels/hints come from the `r.secret()` declaration
68
66
  // itself, not from r.translations — without this, every app with a
@@ -102,3 +100,19 @@ export function validateRequiredLocales(features, requiredLocales) {
102
100
  throw new Error(`${lines.join("\n")}\nThe app requires locales [${requiredLocales.join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
103
101
  }
104
102
  }
103
+ export function validateFieldValueTranslations(features, enabled) {
104
+ // skip: opt-in rule, off unless the app enables requireFieldValueTranslations
105
+ if (enabled !== true)
106
+ return;
107
+ const defined = buildEffectiveTranslationKeys(features);
108
+ const missing = [];
109
+ for (const feature of features) {
110
+ for (const key of fieldValueTranslationKeysFromFeature(feature)) {
111
+ if (!hasDefinedTranslation(defined, key))
112
+ missing.push(` - ${feature.name}: ${key}`);
113
+ }
114
+ }
115
+ if (missing.length > 0) {
116
+ throw new Error(`[i18n] ${missing.length} select option / embedded-cell translation key(s) missing (requireFieldValueTranslations):\n${missing.join("\n")}\nAdd them via r.translations(...) in the owning feature.`);
117
+ }
118
+ }
@@ -1,42 +1,11 @@
1
- import type { EnvelopeCipher } from "../../secrets/envelope-cipher.ts";
2
1
  import type { FeatureDefinition } from "../types/index.ts";
2
+ import type { ValidateBootOptions } from "./boot-options.ts";
3
+ export type { ValidateBootOptions } from "./boot-options.ts";
3
4
  export { type CardMetaOverflow, collectCardMetaOverflow } from "./card-meta-overflow.ts";
4
5
  export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.ts";
5
6
  export { collectWriteHandlerQns } from "./nav.ts";
6
7
  export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline.ts";
7
8
  export { MAX_TRANSFER_DEPTH } from "./transfer-graph.ts";
8
- export type ValidateBootOptions = {
9
- /** Warn when an access role is used by exactly one handler/config-key/
10
- * field across the whole boot scan — often a typo, but also the normal
11
- * shape of a legitimate fine-grained role (one role scoped to one admin
12
- * endpoint on purpose). Opt-in (default false): a default-on
13
- * prod warning that nobody can silence per-role isn't worth the noise
14
- * it generates on every boot. */
15
- readonly warnOnUniqueAccessRoles?: boolean;
16
- /** QNs the app's sidebar allowlist admits. When set, every declared nav
17
- * whose screen isn't reachable from any allowlisted nav entry gets a
18
- * boot-time warning — that screen would otherwise be silently
19
- * unreachable via the sidebar. When omitted and the app has
20
- * workspaces, the allowlist is derived from `r.workspace({ nav })` and
21
- * `r.nav({ workspaces })` assignments instead. */
22
- readonly navAllowlist?: ReadonlySet<string>;
23
- /** Nav QNs deliberately left out of the sidebar allowlist because their
24
- * screen is reachable another way (e.g. as a sub-page of a generated
25
- * settings hub). Suppresses the navAllowlist warning for these QNs. */
26
- readonly navAllowlistExempt?: ReadonlySet<string>;
27
- /** Env the encrypted-field master-key probe reads. Defaults to
28
- * `process.env`; runners pass their resolved env so a ciphertext-only
29
- * key (already decrypted at boot) is visible to the probe. */
30
- readonly env?: Readonly<Record<string, string | undefined>>;
31
- /** The cipher the runner injects after validation (built from the app's
32
- * `masterKey` or the env keyring). Satisfies the availability check for
33
- * `encrypted: true` fields without an env KEK. */
34
- readonly entityFieldCipher?: EnvelopeCipher;
35
- /** Locales every translation key must carry across all mounted features
36
- * (locale packages count). Without it a key can ship in one language and
37
- * silently fall back at runtime for users of the others. */
38
- readonly requiredLocales?: readonly string[];
39
- };
40
9
  /**
41
10
  * Subset-safe slice of validateBoot for test stacks: nav references (screen,
42
11
  * parent, workspace, tree actions), workspaces and nav cycles. The checks that
@@ -45,6 +14,8 @@ export type ValidateBootOptions = {
45
14
  export declare function validateNavBootSubset(rawFeatures: readonly FeatureDefinition[]): void;
46
15
  /**
47
16
  * Validates all feature configurations at boot time.
48
- * Throws on the first error found — fail fast.
17
+ * Collects violations per phase and throws them as one BootValidationError, so a
18
+ * single run reports everything fixable at once. Structural violations (phase 1)
19
+ * abort before the later phases, whose checks would only echo them as follow-ups.
49
20
  */
50
21
  export declare function validateBoot(rawFeatures: readonly FeatureDefinition[], options?: ValidateBootOptions): void;
@@ -1,36 +1,12 @@
1
- import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption.js";
2
1
  import { dedupeFeatures } from "../dedupe-features.js";
3
- import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names.js";
4
- import { QnTypes, qualifyEntityName } from "../qualified-name.js";
5
- import { validateProjectionAllowlist } from "../validate-projection-allowlist.js";
6
- import { validateAccessDeclarations } from "./access-declarations.js";
7
- import { warnOnUniqueAccessRoles } from "./access-roles.js";
8
- import { validateActionWiring, validateFieldWiring } from "./action-wiring.js";
9
- import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.js";
10
- import { validateFeatureBootChecks } from "./boot-check.js";
11
- import { validateCardMetaOverflow } from "./card-meta-overflow.js";
12
- import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
13
- import { collectDetailForScreens } from "./detail-screens.js";
14
- import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectConditionalOptions, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateLockedWhen, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
15
- import { validateEntityListScreens } from "./entity-list-screens.js";
16
- import { validateGdprStoragePersistence } from "./gdpr-storage.js";
17
- import { validateGlobalTenancyEntities } from "./global-tenancy.js";
18
- import { validateHandlerFieldReferences } from "./handler-field-references.js";
19
- import { validateI18nSurfaceKeys, validateRequiredLocales, validateTranslationLocaleConsistency, } from "./i18n-keys.js";
20
- import { collectKnownRoles, collectNavQns, collectWriteHandlerQns, validateNavCycles, validateNavs, warnOnNavAccessInversion, warnOnUnreachableNavScreens, } from "./nav.js";
21
- import { collectClaimKeys, validateOwnershipRules } from "./ownership.js";
22
- import { validateParentRefs } from "./parent-ref.js";
23
- import { validatePiiAndRetention } from "./pii-retention.js";
24
- import { buildQueryHandlerMap, validateProjectionListScreens, validateRelatedListSectionQueries, } from "./projection-list-screens.js";
25
- import { validateQueryOutputColumns } from "./query-output-columns.js";
26
- import { validateQueryRefs } from "./query-refs.js";
27
- import { validateRecordOwnedSubjects } from "./record-owned.js";
28
- import { collectScreenQns, collectScreensByShortId, validateScreenShortIdCollisions, validateScreens, } from "./screens.js";
29
- import { warnOnMissingSecurityBaseline } from "./security-baseline.js";
30
- import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
31
- import { validateTransferGraph } from "./transfer-graph.js";
32
- import { validateSubmitPrefilledHiddenFields } from "./url-prefill-fields.js";
33
- import { collectWorkspaceQns, resolveNavAllowlist, validateDefaultWorkspaceUniqueness, validateWorkspaces, } from "./workspaces.js";
2
+ import { buildBootContext } from "./boot-context.js";
3
+ import { runFeaturePhase, runStructurePhase } from "./boot-phases.js";
4
+ import { runAppWidePhase, runEnvironmentPhase } from "./boot-phases-app.js";
5
+ import { createBootReport } from "./boot-report.js";
6
+ import { emitBootWarnings } from "./boot-warnings.js";
7
+ import { collectNavQns, validateNavCycles, validateNavs } from "./nav.js";
8
+ import { collectScreenQns } from "./screens.js";
9
+ import { collectWorkspaceQns, validateWorkspaces } from "./workspaces.js";
34
10
  // Re-export: run-dev-app.ts needs this to pass write-handler QNs
35
11
  // to codegen. It is not part of validateBoot, but uses the same
36
12
  // extraction logic.
@@ -77,183 +53,27 @@ export function validateNavBootSubset(rawFeatures) {
77
53
  const tolerantScreens = new PrefixTolerantSet(collectScreenQns(features), mounted);
78
54
  const tolerantNavs = new PrefixTolerantMap(allNavQns, mounted);
79
55
  const tolerantWorkspaces = new PrefixTolerantMap(collectWorkspaceQns(features), mounted);
56
+ const report = createBootReport();
80
57
  for (const feature of features) {
81
- validateNavs(feature, tolerantScreens, tolerantNavs, tolerantWorkspaces);
82
- validateWorkspaces(feature, tolerantNavs);
58
+ report.check("validateNavs", feature.name, () => validateNavs(feature, tolerantScreens, tolerantNavs, tolerantWorkspaces));
59
+ report.check("validateWorkspaces", feature.name, () => validateWorkspaces(feature, tolerantNavs));
83
60
  }
84
- validateNavCycles(allNavQns);
61
+ report.check("validateNavCycles", undefined, () => validateNavCycles(allNavQns));
62
+ report.finish();
85
63
  }
86
64
  /**
87
65
  * Validates all feature configurations at boot time.
88
- * Throws on the first error found — fail fast.
66
+ * Collects violations per phase and throws them as one BootValidationError, so a
67
+ * single run reports everything fixable at once. Structural violations (phase 1)
68
+ * abort before the later phases, whose checks would only echo them as follow-ups.
89
69
  */
90
70
  export function validateBoot(rawFeatures, options) {
91
- const features = dedupeFeatures(rawFeatures);
92
- const featureMap = new Map();
93
- for (const f of features) {
94
- featureMap.set(f.name, f);
95
- }
96
- // Collect all extension names and their schema extensions
97
- const extensionProviders = new Map();
98
- for (const f of features) {
99
- for (const extName of Object.keys(f.registrarExtensions)) {
100
- extensionProviders.set(extName, f.name);
101
- }
102
- }
103
- // Collect all config keys across features (for cross-feature reference validation)
104
- const allConfigKeys = new Set();
105
- // Qualified config-key set for ConfigEditScreen validation. MUST be
106
- // canonicalized via qualifyEntityName (toKebab on feature + key), exactly
107
- // as define-feature/registry builds the QN. The raw f.configKeys key is
108
- // the camelCase object key (`brandingTitle`), but the actual QN is
109
- // `…:config:branding-title`; without toKebab, every configEdit screen
110
- // with a multi-word config key would fail incorrectly. allConfigKeys above
111
- // uses the older `feature.short` format for validateConfigReads.
112
- const configKeyDefsByQn = new Map();
113
- for (const f of features) {
114
- for (const [key, def] of Object.entries(f.configKeys)) {
115
- allConfigKeys.add(`${f.name}.${key}`);
116
- configKeyDefsByQn.set(qualifyEntityName(f.name, QnTypes.config, key), def);
117
- }
118
- }
119
- // Collect all claim keys — the ownership-rule validator below resolves
120
- // `from("claim:<feature>:<key>")` strings against this map. Qualified name
121
- // is how the resolver / readClaim / ownership system all reference claims,
122
- // so we key on the qualifiedName here too.
123
- const allClaimKeys = collectClaimKeys(features);
124
- // Cross-feature role set — derived from handler-access rules + framework
125
- // built-ins ("all", "system"). We don't have a dedicated role-registry
126
- // (r.defineRoles is a type-level helper, not a runtime export), so we
127
- // use "referenced in any handler access rule" as the corpus of known
128
- // roles. The ownership-validator checks OwnershipMap keys + legacy
129
- // string[] field-access entries against this set — typos like "Admi"
130
- // instead of "Admin" fail at boot if nothing else ever mentions "Admi".
131
- const knownRoles = collectKnownRoles(features);
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
- const screensByShortId = collectScreensByShortId(features);
140
- validateScreenShortIdCollisions(screensByShortId);
141
- // Cross-feature entity → detailFor-screen map — built (+ uniqueness-
142
- // validated) up front so per-feature rowAction entity-targets
143
- // resolve against it inside the loop below, instead of at the very end
144
- // where this used to run.
145
- const detailForScreens = collectDetailForScreens(features, featureMap);
146
- // Cross-feature API exposure map — each feature declares markers via
147
- // r.exposesApi(name). Per-feature validateApiExposureMatching walks the
148
- // usedApis set and checks that every entry has a match here.
149
- // This prevents typoed or removed QN calls from causing a runtime crash
150
- // instead of a boot failure.
151
- const allExposedApis = new Map(); // apiName → providerFeature
152
- for (const f of features) {
153
- for (const apiName of f.exposedApis) {
154
- const existing = allExposedApis.get(apiName);
155
- if (existing && existing !== f.name) {
156
- throw new Error(`Cross-feature API "${apiName}" exposed by both "${existing}" and "${f.name}" — API names must be globally unique.`);
157
- }
158
- allExposedApis.set(apiName, f.name);
159
- }
160
- }
161
- // Registered query-handler QNs, built once for the per-feature reference
162
- // checks below (a reference field's `optionsQuery` may target any feature).
163
- const queryHandlerQns = buildQueryHandlerMap(features);
164
- let hasEncryptedFields = false;
165
- let hasFileFields = false;
166
- for (const feature of features) {
167
- validateCircularDeps(feature.name, featureMap);
168
- if (validateEncryptedFields(feature))
169
- hasEncryptedFields = true;
170
- validateSearchableFieldReadAccess(feature);
171
- if (validateFileFields(feature))
172
- hasFileFields = true;
173
- validatePiiAndRetention(feature);
174
- validateRecordOwnedSubjects(feature);
175
- validateApiExposureMatching(feature, allExposedApis, featureMap);
176
- validateEntitySelectOptionsQuery(feature);
177
- validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
178
- validateEntitySelectConditionalOptions(feature);
179
- validateEmbeddedFields(feature, featureMap, queryHandlerQns);
180
- validateMultiSelectFields(feature);
181
- validateImageVariants(feature);
182
- validateReferenceFields(feature, featureMap, queryHandlerQns);
183
- validateHandlerFieldReferences(feature, featureMap);
184
- validateTransitions(feature);
185
- validateLockedWhen(feature);
186
- validateExtensionUsages(feature, extensionProviders);
187
- validateExtendSchemaCollisions(feature);
188
- validateDerivedFieldCollisions(feature);
189
- validateHandlerAccess(feature);
190
- validateAccessDeclarations(feature);
191
- validateLocatedTimestamps(feature);
192
- validateEntityIndexes(feature);
193
- validateConfigKeyBounds(feature);
194
- validateConfigKeyRequired(feature);
195
- validateConfigKeyComputed(feature);
196
- validateConfigKeyAllowPerRequest(feature);
197
- validateConfigKeyBacking(feature);
198
- validateConfigKeyOptionsQuery(feature);
199
- validateConfigKeyPiiEncrypted(feature);
200
- validateOwnershipRules(feature, allClaimKeys, knownRoles);
201
- validateParentRefs(feature, featureMap);
202
- validateTransferGraph(feature, featureMap);
203
- validateMultiStreamProjections(feature);
204
- // Before validateScreens: its visible/entityId field-reference checks throw
205
- // for a Function value first, with the confusing "unknown field undefined"
206
- // message (because `typeof fn !== "boolean"` is true), before the clear
207
- // Function check here can run.
208
- validateActionWiring(feature);
209
- validateFieldWiring(feature);
210
- validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens);
211
- validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
212
- validateWorkspaces(feature, allNavQns);
213
- }
214
- validateNavCycles(allNavQns);
215
- warnOnNavAccessInversion(allNavQns);
216
- const effectiveNavAllowlist = resolveNavAllowlist(options?.navAllowlist, allNavQns, allWorkspaceQns);
217
- if (effectiveNavAllowlist !== undefined) {
218
- warnOnUnreachableNavScreens(allNavQns, effectiveNavAllowlist, options?.navAllowlistExempt, options?.navAllowlist === undefined ? "workspaces" : "explicit");
219
- }
220
- validateDefaultWorkspaceUniqueness(allWorkspaceQns);
221
- validateI18nSurfaceKeys(features);
222
- validateTranslationLocaleConsistency(features);
223
- validateRequiredLocales(features, options?.requiredLocales);
224
- validateEntityListScreens(features);
225
- validateSubmitPrefilledHiddenFields(features);
226
- validateCardMetaOverflow(features);
227
- // Must run before validateProjectionListScreens: an unresolvable query
228
- // there is silently treated as "capability absent" and surfaces as a
229
- // misleading "no search parameter in its Zod schema" error instead of
230
- // the clear typo message below.
231
- validateQueryRefs(features);
232
- validateExtensionSelectorPanels(features);
233
- // Must also run after validateQueryRefs — column/field checks below
234
- // assume every `query` string already resolves to a registered handler.
235
- validateQueryOutputColumns(features);
236
- validateProjectionListScreens(features);
237
- validateRelatedListSectionQueries(features);
238
- validateExtensionPreSaveWiring(features);
239
- validateGdprStoragePersistence(features);
240
- validateGlobalTenancyEntities(features);
241
- validateFeatureBootChecks(features);
242
- validateProjectionAllowlist(features);
243
- if (hasEncryptedFields) {
244
- // Availability check, not env-presence: eagerly building the keyring
245
- // catches malformed keys (wrong length, bad base64) at boot instead of
246
- // on the first encrypted read in prod. An injected cipher (test seam,
247
- // custom KMS provider) satisfies the requirement without env keys.
248
- validateEntityFieldEncryptionAvailable(options?.env, options?.entityFieldCipher);
249
- }
250
- if (hasFileFields && !(options?.env ?? process.env)[FILE_STORAGE_PROVIDER_ENV]) {
251
- throw new Error(`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`);
252
- }
253
- validateConfigReads(features, allConfigKeys);
254
- warnOnToggleableDependencies(features, featureMap);
255
- warnOnMissingSecurityBaseline(features);
256
- if (options?.warnOnUniqueAccessRoles === true) {
257
- warnOnUniqueAccessRoles(features);
258
- }
71
+ const ctx = buildBootContext(dedupeFeatures(rawFeatures), options);
72
+ const report = createBootReport();
73
+ const detailForScreens = runStructurePhase(report, ctx);
74
+ const flags = runFeaturePhase(report, ctx, detailForScreens);
75
+ runAppWidePhase(report, ctx);
76
+ runEnvironmentPhase(report, ctx, flags);
77
+ report.finish();
78
+ emitBootWarnings(ctx);
259
79
  }
@@ -1,4 +1,6 @@
1
+ import { holdsFileRefs } from "../../files/file-field-slots.js";
1
2
  import { createFallbackLogger } from "../../logging/utils.js";
3
+ import { isUserEntityReference } from "../user-entity-reference.js";
2
4
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
3
5
  const log = createFallbackLogger("kumiko:boot");
4
6
  // Framework-managed timestamp fields may be used as retention.reference
@@ -13,12 +15,6 @@ const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
13
15
  // The cleanup job performs the actual parsing; the boot validator only checks
14
16
  // the format so typos ("30days") become visible before the first cleanup run.
15
17
  const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
16
- const FILE_FIELD_TYPES = new Set([
17
- "file",
18
- "image",
19
- "files",
20
- "images",
21
- ]);
22
18
  // Excludes subjectRef: its `personal: "ref"` union member structurally
23
19
  // forbids `anonymize` (packages/types/src/fields.ts).
24
20
  function hasAnonymizableSubjectField(annot) {
@@ -139,6 +135,7 @@ export function validatePiiAndRetention(feature) {
139
135
  const FIELD_RULES = [
140
136
  rejectMultipleSubjectAnnotations,
141
137
  rejectTextWithoutPersonalStance,
138
+ rejectPlaintextUserReference,
142
139
  rejectInvalidLookupable,
143
140
  rejectSensitiveWithoutCiphertextAtRest,
144
141
  validateWriteOnlyField,
@@ -185,6 +182,16 @@ function rejectTextWithoutPersonalStance(input) {
185
182
  throw new Error(`${fieldLocation(input)} (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
186
183
  }
187
184
  }
185
+ function rejectPlaintextUserReference(input) {
186
+ const { field, flags } = input;
187
+ // skip: only a plaintext reference to the user entity is a violation
188
+ if (field.type !== "reference" || !flags.allowPlaintext)
189
+ return;
190
+ // skip: references to other entities may stay plaintext
191
+ if (!isUserEntityReference(field.entity))
192
+ return;
193
+ throw new Error(`${fieldLocation(input)} references the user entity "${field.entity}" with { personal: false } — a user id is a pseudonym that falls under Art.17 forget. Use createReferenceField({ entity: "${field.entity}" }) or { personal: "ref" }.`);
194
+ }
188
195
  // lookupable: only text fields with a subject annotation are supported. Without
189
196
  // a subject, the value remains plaintext, so a blind index would be a
190
197
  // second deterministic copy without any benefit.
@@ -268,8 +275,7 @@ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
268
275
  if (ownerField.type !== "reference")
269
276
  return;
270
277
  const refTarget = ownerField.entity;
271
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
272
- if (targetEntity !== "user") {
278
+ if (!isUserEntityReference(refTarget)) {
273
279
  log.warn(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
274
280
  }
275
281
  }
@@ -278,7 +284,7 @@ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
278
284
  // with { personal: false, reason: "is_business_data" }.
279
285
  const UNANNOTATED_NAME_WARNINGS = [
280
286
  {
281
- applies: (input) => FILE_FIELD_TYPES.has(input.field.type),
287
+ applies: (input) => holdsFileRefs(input.field),
282
288
  message: (input) => `[Feature ${input.featureName}] File field "${input.fieldName}" on entity "${input.entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`,
283
289
  },
284
290
  {
@@ -167,6 +167,22 @@ function validateRelatedListFacets(prefix, section, schema) {
167
167
  return;
168
168
  throw new Error(`${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
169
169
  }
170
+ function validateRelatedListCount(prefix, section, layoutMode, schema) {
171
+ // skip: no count badge declared — nothing to reject.
172
+ if (section.count !== true)
173
+ return;
174
+ if (layoutMode !== "tabs") {
175
+ throw new Error(`${prefix}: count: true only renders in a layout with mode "tabs".`);
176
+ }
177
+ if (section.countField !== undefined) {
178
+ throw new Error(`${prefix}: declares both count and countField — pick one badge source.`);
179
+ }
180
+ for (const key of ["totalCount", "limit"]) {
181
+ if (schemaAccepts(schema, key))
182
+ continue;
183
+ throw new Error(`${prefix}: count: true needs query "${section.query}" to accept "${key}" in its Zod schema (the badge asks for limit: 1, totalCount: true).`);
184
+ }
185
+ }
170
186
  // parentFilter lets a relatedList reuse the
171
187
  // generic `<entity>:list` query instead of a bespoke child-rows handler —
172
188
  // same 422 footgun as facets/filter above, plus its own mutual-exclusivity
@@ -219,7 +235,9 @@ export function validateRelatedListSectionQueries(features) {
219
235
  for (const section of screen.layout.sections) {
220
236
  if (section.kind !== "relatedList")
221
237
  continue;
222
- validateSectionQuery(`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`, section);
238
+ const sectionPrefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
239
+ validateSectionQuery(sectionPrefix, section);
240
+ validateRelatedListCount(sectionPrefix, section, screen.layout.mode, queryHandlers.get(section.query)?.schema);
223
241
  }
224
242
  }
225
243
  }
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "../types/index.ts";
2
+ export declare function validateReadGates(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;