@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.
- package/AGENTS.md +4 -3
- package/agents/api/framework-engine.md +16 -8
- package/agents/api/framework-ui-types.md +10 -5
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/testing-stack.md +1 -0
- package/agents/api/types.md +256 -80
- package/agents/api.md +3 -3
- 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/relations.md +13 -3
- package/agents/examples.md +3 -0
- package/agents/features-index.md +2 -0
- package/agents/features.md +8 -0
- 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/config/index.d.ts +1 -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 +201 -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 +38 -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 +107 -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 +196 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +3 -2
- package/dist/engine/boot-validator/i18n-keys.js +18 -4
- 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 +15 -9
- 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 +59 -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 +13 -6
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +2 -1
- package/dist/engine/entity-list-runtime-options.js +86 -5
- package/dist/engine/factories.d.ts +18 -6
- package/dist/engine/factories.js +21 -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 +3 -3
- package/dist/engine/index.js +3 -3
- 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/engine/user-entity-reference.d.ts +2 -0
- package/dist/engine/user-entity-reference.js +7 -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 +12 -3
- package/dist/files/index.d.ts +3 -0
- package/dist/files/index.js +2 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/rebind-file-ref.d.ts +41 -0
- package/dist/files/rebind-file-ref.js +170 -0
- 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 +2 -1
- package/dist/files/upload-field-resolution.js +33 -13
- package/dist/i18n/required-surface-keys.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +61 -0
- 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-boot-input.js +2 -0
- 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 +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
|
-
*
|
|
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,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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
}
|