@cosmicdrift/kumiko-framework 0.398.0 → 0.400.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 (143) hide show
  1. package/AGENTS.md +33 -19
  2. package/agents/api/dev-server-testing.md +23 -0
  3. package/agents/api/framework-db.md +277 -0
  4. package/agents/api/framework-engine.md +1810 -0
  5. package/agents/api/framework-errors.md +119 -0
  6. package/agents/api/framework-event-store.md +99 -0
  7. package/agents/api/framework-seeding.md +26 -0
  8. package/agents/api/framework-ui-types.md +542 -0
  9. package/agents/api/renderer-web.md +1450 -0
  10. package/agents/api/testing-helpers.md +189 -0
  11. package/agents/api/testing-stack.md +144 -0
  12. package/agents/api/types.md +5124 -0
  13. package/agents/api.md +20 -0
  14. package/agents/examples/derived-fields-sql.md +251 -0
  15. package/agents/examples/documents.md +161 -0
  16. package/agents/examples/inspection-checklist.md +807 -0
  17. package/agents/examples.md +3 -0
  18. package/agents/features-index.md +178 -0
  19. package/agents/features.md +9 -1
  20. package/dist/api/entity-read-access.d.ts +2 -0
  21. package/dist/api/entity-read-access.js +46 -2
  22. package/dist/api/server-boot-checks.js +2 -2
  23. package/dist/api/server-routes.js +3 -1
  24. package/dist/db/derived-field-sql.d.ts +23 -0
  25. package/dist/db/derived-field-sql.js +115 -0
  26. package/dist/db/derived-related-sql.d.ts +31 -0
  27. package/dist/db/derived-related-sql.js +137 -0
  28. package/dist/db/driver-row-coercion.d.ts +1 -0
  29. package/dist/db/driver-row-coercion.js +13 -0
  30. package/dist/db/eagerload.d.ts +3 -1
  31. package/dist/db/eagerload.js +4 -6
  32. package/dist/db/event-store-executor-context.js +1 -1
  33. package/dist/db/event-store-executor-read-paging.js +1 -1
  34. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  35. package/dist/db/event-store-executor-read-search.js +17 -9
  36. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  37. package/dist/db/event-store-executor-read-sort.js +27 -14
  38. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  39. package/dist/db/event-store-executor-read-where.js +3 -9
  40. package/dist/db/event-store-executor-read.js +23 -4
  41. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  42. package/dist/db/event-store-executor-write-steps.js +20 -1
  43. package/dist/db/event-store-executor-write.js +8 -8
  44. package/dist/db/money.d.ts +4 -0
  45. package/dist/db/money.js +34 -29
  46. package/dist/db/parent-ref-clause.d.ts +1 -0
  47. package/dist/db/parent-ref-clause.js +18 -8
  48. package/dist/db/read-gate-clause.d.ts +7 -0
  49. package/dist/db/read-gate-clause.js +188 -0
  50. package/dist/db/read-ownership.d.ts +9 -0
  51. package/dist/db/read-ownership.js +12 -0
  52. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  53. package/dist/engine/boot-validator/boot-check.js +7 -11
  54. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  55. package/dist/engine/boot-validator/boot-context.js +57 -0
  56. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  57. package/dist/engine/boot-validator/boot-options.js +1 -0
  58. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  59. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  60. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  61. package/dist/engine/boot-validator/boot-phases.js +140 -0
  62. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  63. package/dist/engine/boot-validator/boot-report.js +47 -0
  64. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  65. package/dist/engine/boot-validator/boot-rule.js +14 -0
  66. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  67. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  68. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  69. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  70. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  71. package/dist/engine/boot-validator/entity-handler.js +6 -3
  72. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  73. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  74. package/dist/engine/boot-validator/field-conditions.js +178 -0
  75. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  76. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  77. package/dist/engine/boot-validator/index.d.ts +5 -34
  78. package/dist/engine/boot-validator/index.js +24 -204
  79. package/dist/engine/boot-validator/pii-retention.js +2 -7
  80. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  81. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  82. package/dist/engine/boot-validator/read-gate.js +54 -0
  83. package/dist/engine/boot-validator/screens-forms.js +19 -6
  84. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  85. package/dist/engine/boot-validator/screens-lists.js +8 -0
  86. package/dist/engine/boot-validator/zod-shape.js +4 -2
  87. package/dist/engine/build-app-schema.js +11 -5
  88. package/dist/engine/embedded-derived.d.ts +8 -6
  89. package/dist/engine/embedded-derived.js +25 -7
  90. package/dist/engine/entity-handlers.d.ts +1 -1
  91. package/dist/engine/entity-handlers.js +17 -18
  92. package/dist/engine/entity-list-runtime-options.d.ts +5 -1
  93. package/dist/engine/entity-list-runtime-options.js +103 -4
  94. package/dist/engine/factories.d.ts +5 -5
  95. package/dist/engine/factories.js +3 -5
  96. package/dist/engine/field-access.d.ts +2 -0
  97. package/dist/engine/field-access.js +48 -1
  98. package/dist/engine/field-state.d.ts +12 -0
  99. package/dist/engine/field-state.js +67 -0
  100. package/dist/engine/handler-helpers.d.ts +3 -0
  101. package/dist/engine/handler-helpers.js +7 -1
  102. package/dist/engine/index.d.ts +2 -1
  103. package/dist/engine/index.js +2 -1
  104. package/dist/engine/ownership.d.ts +1 -1
  105. package/dist/engine/ownership.js +9 -5
  106. package/dist/engine/registry-state.d.ts +1 -0
  107. package/dist/engine/registry-state.js +1 -1
  108. package/dist/engine/schema-builder.js +3 -0
  109. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  110. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  111. package/dist/entrypoint/index.js +1 -0
  112. package/dist/entrypoint/internals.d.ts +2 -0
  113. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  114. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  115. package/dist/files/bind-pending-file-refs.js +11 -12
  116. package/dist/files/bind-referenced-files.write.js +5 -5
  117. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  118. package/dist/files/create-file-ref-from-bytes.js +2 -0
  119. package/dist/files/create-file-ref.d.ts +2 -0
  120. package/dist/files/create-file-ref.js +39 -22
  121. package/dist/files/dispose-removed-file-refs.js +5 -8
  122. package/dist/files/file-field-slots.d.ts +10 -0
  123. package/dist/files/file-field-slots.js +53 -0
  124. package/dist/files/file-ref-entity.d.ts +1 -1
  125. package/dist/files/file-ref-table.d.ts +1 -1
  126. package/dist/files/file-routes.js +30 -9
  127. package/dist/files/index.d.ts +1 -0
  128. package/dist/files/index.js +1 -0
  129. package/dist/files/load-save-file-refs.js +3 -4
  130. package/dist/files/record-read-check.js +2 -30
  131. package/dist/files/record-write-check.d.ts +2 -0
  132. package/dist/files/record-write-check.js +18 -3
  133. package/dist/files/upload-field-resolution.d.ts +4 -1
  134. package/dist/files/upload-field-resolution.js +57 -12
  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-command.js +46 -12
  139. package/dist/schema-cli.js +1 -1
  140. package/dist/ui-types/index.d.ts +2 -1
  141. package/dist/ui-types/index.js +2 -1
  142. package/package.json +3 -3
  143. package/src/changes.json +67 -0
@@ -2,6 +2,7 @@ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
2
  import { declaredRateLimitOptions, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
3
3
  import { ZodObject } from "zod";
4
4
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
5
+ import { holdsFileRefs } from "../../files/file-field-slots.js";
5
6
  import { access as accessPresets } from "../config-helpers.js";
6
7
  import { parseRefTarget } from "../parse-ref-target.js";
7
8
  import { isSelectOptionTone } from "./select-option-tone.js";
@@ -469,7 +470,7 @@ export function validateEncryptedFields(feature) {
469
470
  export function validateFileFields(feature) {
470
471
  for (const entity of Object.values(feature.entities ?? {})) {
471
472
  for (const field of Object.values(entity.fields)) {
472
- if (FILE_FIELD_TYPES.has(field.type))
473
+ if (holdsFileRefs(field))
473
474
  return true;
474
475
  }
475
476
  }
@@ -486,6 +487,8 @@ const VALID_EMBEDDED_SUB_TYPES = new Set([
486
487
  "select",
487
488
  "reference",
488
489
  "timestamp",
490
+ "images",
491
+ "files",
489
492
  ]);
490
493
  const NUMERIC_EMBEDDED_SUB_TYPES = new Set(["number", "money", "decimal"]);
491
494
  // 15 is where 10^scale exhausts a double's integer range — beyond it the
@@ -786,7 +789,7 @@ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
786
789
  }
787
790
  }
788
791
  }
789
- function conditionComparisonValues(when) {
792
+ export function conditionComparisonValues(when) {
790
793
  if ("eq" in when)
791
794
  return [when.eq];
792
795
  if ("ne" in when)
@@ -949,7 +952,7 @@ function isCiphertextAtRest(field) {
949
952
  // Types whose stored value is a plain scalar that the condition's `===` can match.
950
953
  // Compound, temporal, array and bigint columns come back in another shape than the
951
954
  // literal an author writes, so such a lock would silently never hold.
952
- const LOCKABLE_FIELD_TYPES = new Set([
955
+ export const LOCKABLE_FIELD_TYPES = new Set([
953
956
  "text",
954
957
  "longText",
955
958
  "boolean",
@@ -1,5 +1,6 @@
1
1
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
2
2
  import { normalizeListColumn } from "../screen-helpers.js";
3
+ import { isSortableSqlDerivedField } from "./derived-sql-fields.js";
3
4
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
4
5
  export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list", "cap-list"]);
5
6
  // Same set computeListViewModel (headless) can actually render — NOT
@@ -38,7 +39,8 @@ function validateOneEntityListScreen(feature, screen) {
38
39
  }
39
40
  const entityDef = feature.entities?.[screen.entity];
40
41
  const fieldDef = entityDef?.fields[sortField];
41
- const isSortable = fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
42
+ const isSortable = (entityDef !== undefined && isSortableSqlDerivedField(entityDef, sortField)) ||
43
+ (fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true);
42
44
  if (!isSortable) {
43
45
  throw new Error(`${prefix}: defaultSort column "${sortField}" must be sortable`);
44
46
  }
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "../types/index.ts";
2
+ export declare function validateFieldConditions(feature: FeatureDefinition): void;
@@ -0,0 +1,178 @@
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
+ if (sub.accept?.some((ext) => ext.length === 0)) {
81
+ throw new Error(`${where} has an empty entry in accept. List file extensions like "pdf".`);
82
+ }
83
+ }
84
+ function assertSelectSubFieldTones(where, sub) {
85
+ // skip: only select sub-fields with declared tones have tones to check
86
+ if (sub.type !== "select" || sub.optionTones === undefined)
87
+ return;
88
+ for (const option of Object.keys(sub.optionTones)) {
89
+ if (!sub.options.includes(option)) {
90
+ throw new Error(`${where} has optionTones["${option}"] which is not an option (${sub.options.join(", ")}).`);
91
+ }
92
+ }
93
+ }
94
+ function assertSubFields(where, embedded) {
95
+ for (const [subName, sub] of Object.entries(embedded.schema)) {
96
+ const subWhere = `${where}.${subName}`;
97
+ assertNoRequiredConflict(subWhere, sub, sub.required === true);
98
+ for (const key of STATE_KEYS) {
99
+ const condition = sub[key];
100
+ if (condition !== undefined)
101
+ assertSubFieldCondition(subWhere, key, subName, condition, embedded);
102
+ }
103
+ assertSelectSubFieldTones(subWhere, sub);
104
+ if (isFileSubField(sub))
105
+ assertFileSubField(subWhere, sub);
106
+ }
107
+ }
108
+ function assertNumericSources(where, derivedName, embedded, from) {
109
+ for (const source of from) {
110
+ const sourceType = embedded.schema[source]?.type;
111
+ if (sourceType === undefined || !NUMERIC_SUB_TYPES.has(sourceType)) {
112
+ throw new Error(`${where} has a derived cell "${derivedName}" reading "${source}", which is not a number, decimal or money sub-field.`);
113
+ }
114
+ }
115
+ }
116
+ function assertDerivedCells(where, embedded) {
117
+ for (const [name, cell] of Object.entries(embedded.derived ?? {})) {
118
+ const target = embedded.schema[name];
119
+ assertNumericSources(where, name, embedded, cell.from);
120
+ if (cell.op === "inRange") {
121
+ if (cell.from.length !== 3) {
122
+ throw new Error(`${where} has a derived cell "${name}" with op "inRange", which needs exactly three sources (value, min, max).`);
123
+ }
124
+ if (target?.type !== "select" ||
125
+ !target.options.includes(cell.inside) ||
126
+ !target.options.includes(cell.outside)) {
127
+ 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}".`);
128
+ }
129
+ }
130
+ else if (target === undefined || !NUMERIC_SUB_TYPES.has(target.type)) {
131
+ throw new Error(`${where} has a derived cell "${name}" with op "${cell.op}", whose target must be a number, decimal or money sub-field.`);
132
+ }
133
+ if (cell.when !== undefined) {
134
+ assertSubFieldCondition(`${where}.${name}`, "when", undefined, cell.when, embedded);
135
+ if (cell.when.field in (embedded.derived ?? {})) {
136
+ throw new Error(`${where} has a derived cell "${name}" whose when reads the derived cell "${cell.when.field}". Read a stored sub-field.`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ function assertFileSubFieldsAreNotWired(where, embedded) {
142
+ const fileSubs = new Set(Object.entries(embedded.schema).flatMap(([name, sub]) => (isFileSubField(sub) ? [name] : [])));
143
+ // skip: without file sub-fields there is nothing that could be wired into derived or conditions
144
+ if (fileSubs.size === 0)
145
+ return;
146
+ const wired = [
147
+ ...Object.entries(embedded.derived ?? {}).flatMap(([name, cell]) => [name, ...cell.from]),
148
+ ...Object.keys(embedded.totalsMatch ?? {}),
149
+ ...(embedded.totals ?? []).flatMap((total) => typeof total === "string" ? [total] : total.from),
150
+ ];
151
+ const offender = wired.find((name) => fileSubs.has(name));
152
+ if (offender !== undefined) {
153
+ throw new Error(`${where} uses file sub-field "${offender}" in derived or totals, which only take number, decimal or money sub-fields.`);
154
+ }
155
+ }
156
+ function assertEmbeddedField(where, embedded) {
157
+ assertSubFields(where, embedded);
158
+ assertFileSubFieldsAreNotWired(where, embedded);
159
+ assertDerivedCells(where, embedded);
160
+ }
161
+ function assertField(feature, entityName, entity, fieldName, field) {
162
+ const where = `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}"`;
163
+ assertNoRequiredConflict(where, field, "required" in field && field.required === true);
164
+ for (const key of STATE_KEYS) {
165
+ const condition = field[key];
166
+ if (condition !== undefined)
167
+ assertTopLevelCondition(where, key, fieldName, condition, entity);
168
+ }
169
+ if (field.type === "embedded")
170
+ assertEmbeddedField(where, field);
171
+ }
172
+ export function validateFieldConditions(feature) {
173
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
174
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
175
+ assertField(feature, entityName, entity, fieldName, field);
176
+ }
177
+ }
178
+ }
@@ -1,4 +1,4 @@
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;
@@ -1,7 +1,6 @@
1
1
  import { buildEffectiveTranslationKeys, featureHasI18nSurface, 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
@@ -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,3 +1,4 @@
1
+ import { holdsFileRefs } from "../../files/file-field-slots.js";
1
2
  import { createFallbackLogger } from "../../logging/utils.js";
2
3
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
3
4
  const log = createFallbackLogger("kumiko:boot");
@@ -13,12 +14,6 @@ const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
13
14
  // The cleanup job performs the actual parsing; the boot validator only checks
14
15
  // the format so typos ("30days") become visible before the first cleanup run.
15
16
  const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
16
- const FILE_FIELD_TYPES = new Set([
17
- "file",
18
- "image",
19
- "files",
20
- "images",
21
- ]);
22
17
  // Excludes subjectRef: its `personal: "ref"` union member structurally
23
18
  // forbids `anonymize` (packages/types/src/fields.ts).
24
19
  function hasAnonymizableSubjectField(annot) {
@@ -278,7 +273,7 @@ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
278
273
  // with { personal: false, reason: "is_business_data" }.
279
274
  const UNANNOTATED_NAME_WARNINGS = [
280
275
  {
281
- applies: (input) => FILE_FIELD_TYPES.has(input.field.type),
276
+ applies: (input) => holdsFileRefs(input.field),
282
277
  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
278
  },
284
279
  {
@@ -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;