@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.
- package/AGENTS.md +33 -19
- package/agents/api/dev-server-testing.md +23 -0
- package/agents/api/framework-db.md +277 -0
- package/agents/api/framework-engine.md +1810 -0
- package/agents/api/framework-errors.md +119 -0
- package/agents/api/framework-event-store.md +99 -0
- package/agents/api/framework-seeding.md +26 -0
- package/agents/api/framework-ui-types.md +542 -0
- package/agents/api/renderer-web.md +1450 -0
- package/agents/api/testing-helpers.md +189 -0
- package/agents/api/testing-stack.md +144 -0
- package/agents/api/types.md +5124 -0
- package/agents/api.md +20 -0
- package/agents/examples/derived-fields-sql.md +251 -0
- package/agents/examples/documents.md +161 -0
- package/agents/examples/inspection-checklist.md +807 -0
- package/agents/examples.md +3 -0
- package/agents/features-index.md +178 -0
- package/agents/features.md +9 -1
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +46 -2
- package/dist/api/server-boot-checks.js +2 -2
- package/dist/api/server-routes.js +3 -1
- package/dist/db/derived-field-sql.d.ts +23 -0
- package/dist/db/derived-field-sql.js +115 -0
- package/dist/db/derived-related-sql.d.ts +31 -0
- package/dist/db/derived-related-sql.js +137 -0
- package/dist/db/driver-row-coercion.d.ts +1 -0
- package/dist/db/driver-row-coercion.js +13 -0
- package/dist/db/eagerload.d.ts +3 -1
- package/dist/db/eagerload.js +4 -6
- package/dist/db/event-store-executor-context.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +1 -1
- package/dist/db/event-store-executor-read-search.d.ts +3 -0
- package/dist/db/event-store-executor-read-search.js +17 -9
- package/dist/db/event-store-executor-read-sort.d.ts +9 -2
- package/dist/db/event-store-executor-read-sort.js +27 -14
- package/dist/db/event-store-executor-read-where.d.ts +0 -5
- package/dist/db/event-store-executor-read-where.js +3 -9
- package/dist/db/event-store-executor-read.js +23 -4
- package/dist/db/event-store-executor-write-steps.d.ts +6 -1
- package/dist/db/event-store-executor-write-steps.js +20 -1
- package/dist/db/event-store-executor-write.js +8 -8
- package/dist/db/money.d.ts +4 -0
- package/dist/db/money.js +34 -29
- package/dist/db/parent-ref-clause.d.ts +1 -0
- package/dist/db/parent-ref-clause.js +18 -8
- package/dist/db/read-gate-clause.d.ts +7 -0
- package/dist/db/read-gate-clause.js +188 -0
- package/dist/db/read-ownership.d.ts +9 -0
- package/dist/db/read-ownership.js +12 -0
- package/dist/engine/boot-validator/boot-check.d.ts +3 -1
- package/dist/engine/boot-validator/boot-check.js +7 -11
- package/dist/engine/boot-validator/boot-context.d.ts +26 -0
- package/dist/engine/boot-validator/boot-context.js +57 -0
- package/dist/engine/boot-validator/boot-options.d.ts +33 -0
- package/dist/engine/boot-validator/boot-options.js +1 -0
- package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +103 -0
- package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
- package/dist/engine/boot-validator/boot-phases.js +140 -0
- package/dist/engine/boot-validator/boot-report.d.ts +6 -0
- package/dist/engine/boot-validator/boot-report.js +47 -0
- package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
- package/dist/engine/boot-validator/boot-rule.js +14 -0
- package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
- package/dist/engine/boot-validator/boot-warnings.js +20 -0
- package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
- package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
- package/dist/engine/boot-validator/entity-handler.js +6 -3
- package/dist/engine/boot-validator/entity-list-screens.js +3 -1
- package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
- package/dist/engine/boot-validator/field-conditions.js +178 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
- package/dist/engine/boot-validator/i18n-keys.js +1 -3
- package/dist/engine/boot-validator/index.d.ts +5 -34
- package/dist/engine/boot-validator/index.js +24 -204
- package/dist/engine/boot-validator/pii-retention.js +2 -7
- package/dist/engine/boot-validator/projection-list-screens.js +19 -1
- package/dist/engine/boot-validator/read-gate.d.ts +2 -0
- package/dist/engine/boot-validator/read-gate.js +54 -0
- package/dist/engine/boot-validator/screens-forms.js +19 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
- package/dist/engine/boot-validator/screens-lists.js +8 -0
- package/dist/engine/boot-validator/zod-shape.js +4 -2
- package/dist/engine/build-app-schema.js +11 -5
- package/dist/engine/embedded-derived.d.ts +8 -6
- package/dist/engine/embedded-derived.js +25 -7
- package/dist/engine/entity-handlers.d.ts +1 -1
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +5 -1
- package/dist/engine/entity-list-runtime-options.js +103 -4
- package/dist/engine/factories.d.ts +5 -5
- package/dist/engine/factories.js +3 -5
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +48 -1
- package/dist/engine/field-state.d.ts +12 -0
- package/dist/engine/field-state.js +67 -0
- package/dist/engine/handler-helpers.d.ts +3 -0
- package/dist/engine/handler-helpers.js +7 -1
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +2 -1
- package/dist/engine/ownership.d.ts +1 -1
- package/dist/engine/ownership.js +9 -5
- package/dist/engine/registry-state.d.ts +1 -0
- package/dist/engine/registry-state.js +1 -1
- package/dist/engine/schema-builder.js +3 -0
- package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
- package/dist/engine/strip-unreadable-related-lists.js +80 -0
- package/dist/entrypoint/index.js +1 -0
- package/dist/entrypoint/internals.d.ts +2 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
- package/dist/files/bind-pending-file-refs.d.ts +3 -2
- package/dist/files/bind-pending-file-refs.js +11 -12
- package/dist/files/bind-referenced-files.write.js +5 -5
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +2 -0
- package/dist/files/create-file-ref.d.ts +2 -0
- package/dist/files/create-file-ref.js +39 -22
- package/dist/files/dispose-removed-file-refs.js +5 -8
- package/dist/files/file-field-slots.d.ts +10 -0
- package/dist/files/file-field-slots.js +53 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-table.d.ts +1 -1
- package/dist/files/file-routes.js +30 -9
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/record-read-check.js +2 -30
- package/dist/files/record-write-check.d.ts +2 -0
- package/dist/files/record-write-check.js +18 -3
- package/dist/files/upload-field-resolution.d.ts +4 -1
- package/dist/files/upload-field-resolution.js +57 -12
- package/dist/pipeline/dispatch-batch.js +6 -5
- package/dist/pipeline/dispatch-write.js +2 -1
- package/dist/pipeline/write-result-masking.js +2 -1
- package/dist/schema-cli/validate-command.js +46 -12
- package/dist/schema-cli.js +1 -1
- package/dist/ui-types/index.d.ts +2 -1
- package/dist/ui-types/index.js +2 -1
- package/package.json +3 -3
- 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 (
|
|
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 =
|
|
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,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
|
-
*
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
}
|