@cosmicdrift/kumiko-framework 0.401.0 → 0.403.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 -2
- package/agents/api/framework-engine.md +29 -1
- package/agents/api/framework-ui-types.md +4 -0
- package/agents/api/renderer-web.md +1 -1
- package/agents/api/types.md +34 -0
- package/agents/api.md +1 -1
- package/agents/boot-rules.md +110 -0
- package/agents/examples/dashboard-stats.md +58 -3
- package/dist/db/replay-migration-sql.js +1 -1
- package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
- package/dist/engine/boot-validator/api-ext-rules.js +27 -0
- package/dist/engine/boot-validator/api-ext.js +25 -12
- package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
- package/dist/engine/boot-validator/boot-check-rules.js +7 -0
- package/dist/engine/boot-validator/boot-check.js +13 -1
- package/dist/engine/boot-validator/boot-phases-app.js +5 -0
- package/dist/engine/boot-validator/boot-report.d.ts +1 -0
- package/dist/engine/boot-validator/boot-report.js +58 -7
- package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
- package/dist/engine/boot-validator/boot-rule.js +33 -2
- package/dist/engine/boot-validator/boot-rules.d.ts +246 -0
- package/dist/engine/boot-validator/boot-rules.js +19 -0
- package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
- package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
- package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +37 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +37 -0
- package/dist/engine/boot-validator/entity-list-screens.js +86 -41
- package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
- package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
- package/dist/engine/boot-validator/i18n-keys.js +27 -16
- package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
- package/dist/engine/boot-validator/named-filter-rules.js +32 -0
- package/dist/engine/boot-validator/pii-retention-rules.d.ts +107 -0
- package/dist/engine/boot-validator/pii-retention-rules.js +107 -0
- package/dist/engine/boot-validator/pii-retention.js +76 -33
- package/dist/engine/boot-validator/projection-list-screens.js +13 -0
- package/dist/engine/boot-validator/screens-actions-rules.d.ts +7 -0
- package/dist/engine/boot-validator/screens-actions-rules.js +7 -0
- package/dist/engine/boot-validator/screens-actions.js +7 -3
- package/dist/engine/entity-handlers.d.ts +1 -0
- package/dist/engine/entity-handlers.js +13 -1
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/index.d.ts +3 -0
- package/dist/engine/index.js +3 -0
- package/dist/engine/resolve-named-list-filters.d.ts +3 -0
- package/dist/engine/resolve-named-list-filters.js +30 -0
- package/dist/i18n/required-surface-keys.js +3 -0
- package/dist/schema-cli/validate-command.js +4 -1
- package/package.json +3 -3
- package/src/changes.json +18 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { holdsFileRefs } from "../../files/file-field-slots.js";
|
|
2
2
|
import { createFallbackLogger } from "../../logging/utils.js";
|
|
3
3
|
import { isUserEntityReference } from "../user-entity-reference.js";
|
|
4
|
+
import { collectBootRuleViolations } from "./boot-report.js";
|
|
5
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
4
6
|
import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
|
|
7
|
+
import { PII_RULES } from "./pii-retention-rules.js";
|
|
5
8
|
const log = createFallbackLogger("kumiko:boot");
|
|
6
9
|
// Framework-managed timestamp fields may be used as retention.reference
|
|
7
10
|
// even when they are not declared in entity.fields.
|
|
@@ -46,7 +49,9 @@ function validateWriteOnlyField(input) {
|
|
|
46
49
|
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
47
50
|
if ("writeOnly" in subField && subField.writeOnly === true) {
|
|
48
51
|
// @cast-boundary schema-walk
|
|
49
|
-
throw
|
|
52
|
+
throw bootRuleError(PII_RULES.writeOnlyEmbeddedSubField, {
|
|
53
|
+
message: `${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields. Remove writeOnly from the sub-field or declare it on a top-level text field.`,
|
|
54
|
+
});
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
// skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
|
|
@@ -57,15 +62,21 @@ function validateWriteOnlyField(input) {
|
|
|
57
62
|
if (flags.writeOnly !== true)
|
|
58
63
|
return;
|
|
59
64
|
if (field.type !== "text") {
|
|
60
|
-
throw
|
|
65
|
+
throw bootRuleError(PII_RULES.writeOnlyNonText, {
|
|
66
|
+
message: `${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields. Use a text field or remove writeOnly.`,
|
|
67
|
+
});
|
|
61
68
|
}
|
|
62
69
|
if (field.sensitive !== true) {
|
|
63
|
-
throw
|
|
70
|
+
throw bootRuleError(PII_RULES.writeOnlyWithoutSensitive, {
|
|
71
|
+
message: `${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`,
|
|
72
|
+
});
|
|
64
73
|
}
|
|
65
74
|
for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
|
|
66
75
|
const value = flags[flag];
|
|
67
76
|
if (value !== undefined && value !== false) {
|
|
68
|
-
throw
|
|
77
|
+
throw bootRuleError(PII_RULES.writeOnlyConflictingFlag, {
|
|
78
|
+
message: `${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up. Remove either writeOnly or the conflicting flag.`,
|
|
79
|
+
});
|
|
69
80
|
}
|
|
70
81
|
}
|
|
71
82
|
}
|
|
@@ -89,15 +100,21 @@ function validatePersonalJsonbField(input) {
|
|
|
89
100
|
const flags = field; // @cast-boundary schema-walk
|
|
90
101
|
for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
|
|
91
102
|
if (flags[flag] !== undefined && flags[flag] !== false) {
|
|
92
|
-
throw
|
|
103
|
+
throw bootRuleError(PII_RULES.personalJsonbFlag, {
|
|
104
|
+
message: `${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}. Remove the ${flag} flag or the personal annotation.`,
|
|
105
|
+
});
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
108
|
const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
|
|
96
109
|
if (index) {
|
|
97
|
-
throw
|
|
110
|
+
throw bootRuleError(PII_RULES.personalJsonbIndex, {
|
|
111
|
+
message: `${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`,
|
|
112
|
+
});
|
|
98
113
|
}
|
|
99
114
|
if (fieldName === CUSTOM_FIELDS_COLUMN) {
|
|
100
|
-
throw
|
|
115
|
+
throw bootRuleError(PII_RULES.personalJsonbCustomFields, {
|
|
116
|
+
message: `${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`,
|
|
117
|
+
});
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
120
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
@@ -125,13 +142,13 @@ function validatePersonalJsonbField(input) {
|
|
|
125
142
|
// Encryption and decryption are handled elsewhere; this validation runs early
|
|
126
143
|
// so schema drift is detected during boot.
|
|
127
144
|
export function validatePiiAndRetention(feature) {
|
|
128
|
-
|
|
129
|
-
validateFieldAnnotations
|
|
130
|
-
|
|
131
|
-
}
|
|
145
|
+
collectBootRuleViolations(Object.entries(feature.entities ?? {}), ([entityName, entity]) => {
|
|
146
|
+
const entityChecks = [validateFieldAnnotations, validateEntityRetention];
|
|
147
|
+
collectBootRuleViolations(entityChecks, (check) => check(feature.name, entityName, entity));
|
|
148
|
+
});
|
|
132
149
|
}
|
|
133
|
-
//
|
|
134
|
-
//
|
|
150
|
+
// Every rule runs on every field; order only decides the order of the reported
|
|
151
|
+
// violations and warnings.
|
|
135
152
|
const FIELD_RULES = [
|
|
136
153
|
rejectMultipleSubjectAnnotations,
|
|
137
154
|
rejectTextWithoutPersonalStance,
|
|
@@ -146,7 +163,7 @@ const FIELD_RULES = [
|
|
|
146
163
|
];
|
|
147
164
|
function validateFieldAnnotations(featureName, entityName, entity) {
|
|
148
165
|
const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(entity.fields);
|
|
149
|
-
|
|
166
|
+
collectBootRuleViolations(Object.entries(entity.fields), ([fieldName, field]) => {
|
|
150
167
|
const flags = field; // @cast-boundary schema-walk
|
|
151
168
|
const input = {
|
|
152
169
|
featureName,
|
|
@@ -158,9 +175,8 @@ function validateFieldAnnotations(featureName, entityName, entity) {
|
|
|
158
175
|
subjectAnnotationCount: countSubjectAnnotations(flags),
|
|
159
176
|
personalOfOwnerFieldNames,
|
|
160
177
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
178
|
+
collectBootRuleViolations(FIELD_RULES, (rule) => rule(input));
|
|
179
|
+
});
|
|
164
180
|
}
|
|
165
181
|
function countSubjectAnnotations(flags) {
|
|
166
182
|
return [flags.pii, flags.userOwned, flags.tenantOwned, flags.recordOwned].filter(Boolean).length;
|
|
@@ -170,7 +186,9 @@ function fieldLocation(input) {
|
|
|
170
186
|
}
|
|
171
187
|
function rejectMultipleSubjectAnnotations(input) {
|
|
172
188
|
if (input.subjectAnnotationCount > 1) {
|
|
173
|
-
throw
|
|
189
|
+
throw bootRuleError(PII_RULES.multipleSubjectAnnotations, {
|
|
190
|
+
message: `${fieldLocation(input)} has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`,
|
|
191
|
+
});
|
|
174
192
|
}
|
|
175
193
|
}
|
|
176
194
|
function rejectTextWithoutPersonalStance(input) {
|
|
@@ -179,7 +197,9 @@ function rejectTextWithoutPersonalStance(input) {
|
|
|
179
197
|
input.subjectAnnotationCount === 0 &&
|
|
180
198
|
!flags.allowPlaintext &&
|
|
181
199
|
!flags.subjectRef) {
|
|
182
|
-
throw
|
|
200
|
+
throw bootRuleError(PII_RULES.textWithoutPersonalStance, {
|
|
201
|
+
message: `${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.`,
|
|
202
|
+
});
|
|
183
203
|
}
|
|
184
204
|
}
|
|
185
205
|
function rejectPlaintextUserReference(input) {
|
|
@@ -190,7 +210,9 @@ function rejectPlaintextUserReference(input) {
|
|
|
190
210
|
// skip: references to other entities may stay plaintext
|
|
191
211
|
if (!isUserEntityReference(field.entity))
|
|
192
212
|
return;
|
|
193
|
-
throw
|
|
213
|
+
throw bootRuleError(PII_RULES.plaintextUserReference, {
|
|
214
|
+
message: `${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" }.`,
|
|
215
|
+
});
|
|
194
216
|
}
|
|
195
217
|
// lookupable: only text fields with a subject annotation are supported. Without
|
|
196
218
|
// a subject, the value remains plaintext, so a blind index would be a
|
|
@@ -200,10 +222,14 @@ function rejectInvalidLookupable(input) {
|
|
|
200
222
|
if (input.flags.lookupable !== true)
|
|
201
223
|
return;
|
|
202
224
|
if (input.field.type !== "text") {
|
|
203
|
-
throw
|
|
225
|
+
throw bootRuleError(PII_RULES.lookupableNonText, {
|
|
226
|
+
message: `${fieldLocation(input)} declares find: "exact" (or "fuzzy") but has type "${input.field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`,
|
|
227
|
+
});
|
|
204
228
|
}
|
|
205
229
|
if (input.subjectAnnotationCount === 0) {
|
|
206
|
-
throw
|
|
230
|
+
throw bootRuleError(PII_RULES.lookupableWithoutSubject, {
|
|
231
|
+
message: `${fieldLocation(input)} declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`,
|
|
232
|
+
});
|
|
207
233
|
}
|
|
208
234
|
}
|
|
209
235
|
// Sensitive fields are stored as table ciphertext in the event log; without
|
|
@@ -212,7 +238,9 @@ function rejectInvalidLookupable(input) {
|
|
|
212
238
|
function rejectSensitiveWithoutCiphertextAtRest(input) {
|
|
213
239
|
const { flags } = input;
|
|
214
240
|
if (flags.sensitive === true && input.subjectAnnotationCount === 0 && flags.encrypted !== true) {
|
|
215
|
-
throw
|
|
241
|
+
throw bootRuleError(PII_RULES.sensitiveWithoutCiphertext, {
|
|
242
|
+
message: `${fieldLocation(input)} declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`,
|
|
243
|
+
});
|
|
216
244
|
}
|
|
217
245
|
}
|
|
218
246
|
// Sorting reads the projection column — that stays ciphertext, so
|
|
@@ -223,10 +251,14 @@ function rejectSensitiveWithoutCiphertextAtRest(input) {
|
|
|
223
251
|
function rejectCiphertextSortOrSensitiveSearch(input) {
|
|
224
252
|
const { flags } = input;
|
|
225
253
|
if (input.subjectAnnotationCount > 0 && flags.sortable === true) {
|
|
226
|
-
throw
|
|
254
|
+
throw bootRuleError(PII_RULES.subjectKeySortable, {
|
|
255
|
+
message: `${fieldLocation(input)} combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`,
|
|
256
|
+
});
|
|
227
257
|
}
|
|
228
258
|
if (flags.sensitive === true && flags.searchable === true) {
|
|
229
|
-
throw
|
|
259
|
+
throw bootRuleError(PII_RULES.sensitiveSearchable, {
|
|
260
|
+
message: `${fieldLocation(input)} combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`,
|
|
261
|
+
});
|
|
230
262
|
}
|
|
231
263
|
}
|
|
232
264
|
function validatePersonalOfOwner(input) {
|
|
@@ -236,7 +268,9 @@ function validatePersonalOfOwner(input) {
|
|
|
236
268
|
return;
|
|
237
269
|
const ownerName = personalOf.ownerField;
|
|
238
270
|
if (!ownerName || typeof ownerName !== "string") {
|
|
239
|
-
throw
|
|
271
|
+
throw bootRuleError(PII_RULES.personalOfMissingOwnerName, {
|
|
272
|
+
message: `${fieldLocation(input)} has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`,
|
|
273
|
+
});
|
|
240
274
|
}
|
|
241
275
|
const ownerField = requireOwnerField(input, ownerName);
|
|
242
276
|
rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf);
|
|
@@ -247,7 +281,9 @@ function requireOwnerField(input, ownerName) {
|
|
|
247
281
|
const ownerField = fieldsByName[ownerName];
|
|
248
282
|
if (!ownerField) {
|
|
249
283
|
const known = Object.keys(fieldsByName).sort().join(", ");
|
|
250
|
-
throw
|
|
284
|
+
throw bootRuleError(PII_RULES.personalOfUnknownOwner, {
|
|
285
|
+
message: `${fieldLocation(input)} references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`,
|
|
286
|
+
});
|
|
251
287
|
}
|
|
252
288
|
// Text is accepted alongside reference: the ES-framework carries
|
|
253
289
|
// user ids as plain text columns throughout the bundled features
|
|
@@ -255,17 +291,23 @@ function requireOwnerField(input, ownerName) {
|
|
|
255
291
|
// relational FK to point a reference at. Self-referencing
|
|
256
292
|
// ownerField (the field's own value IS the owner id) rides on this.
|
|
257
293
|
if (ownerField.type !== "reference" && ownerField.type !== "text") {
|
|
258
|
-
throw
|
|
294
|
+
throw bootRuleError(PII_RULES.personalOfOwnerType, {
|
|
295
|
+
message: `[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
|
|
296
|
+
});
|
|
259
297
|
}
|
|
260
298
|
return ownerField;
|
|
261
299
|
}
|
|
262
300
|
function rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf) {
|
|
263
301
|
const { whenAbsent } = personalOf;
|
|
264
302
|
if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
|
|
265
|
-
throw
|
|
303
|
+
throw bootRuleError(PII_RULES.personalOfInvalidWhenAbsent, {
|
|
304
|
+
message: `${fieldLocation(input)} declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`,
|
|
305
|
+
});
|
|
266
306
|
}
|
|
267
307
|
if (ownerField.required !== true && whenAbsent === undefined) {
|
|
268
|
-
throw
|
|
308
|
+
throw bootRuleError(PII_RULES.personalOfOptionalOwner, {
|
|
309
|
+
message: `${fieldLocation(input)} is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`,
|
|
310
|
+
});
|
|
269
311
|
}
|
|
270
312
|
}
|
|
271
313
|
// Custom subject entities (such as HR employees or patients) are allowed but
|
|
@@ -327,8 +369,7 @@ function validateEntityRetention(featureName, entityName, entity) {
|
|
|
327
369
|
fieldsByName: entity.fields,
|
|
328
370
|
retention,
|
|
329
371
|
};
|
|
330
|
-
|
|
331
|
-
rule(input);
|
|
372
|
+
collectBootRuleViolations(RETENTION_RULES, (rule) => rule(input));
|
|
332
373
|
}
|
|
333
374
|
function warnOnInvalidKeepFor({ featureName, entityName, retention }) {
|
|
334
375
|
if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
|
|
@@ -344,7 +385,9 @@ function rejectUnknownRetentionReference(input) {
|
|
|
344
385
|
if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
|
|
345
386
|
const known = Object.keys(fieldsByName).sort().join(", ");
|
|
346
387
|
const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
|
|
347
|
-
throw
|
|
388
|
+
throw bootRuleError(PII_RULES.retentionUnknownReference, {
|
|
389
|
+
message: `[Feature ${featureName}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`,
|
|
390
|
+
});
|
|
348
391
|
}
|
|
349
392
|
}
|
|
350
393
|
// blockDelete with no field that can carry `anonymize` is fine;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
3
3
|
import { isEditLayoutScreen } from "../screen-helpers.js";
|
|
4
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
4
5
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
|
|
5
6
|
import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
|
|
7
|
+
import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
|
|
6
8
|
import { findEntity } from "./parent-ref.js";
|
|
7
9
|
import { getZodObjectShape } from "./zod-shape.js";
|
|
8
10
|
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
@@ -66,6 +68,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
|
|
|
66
68
|
}
|
|
67
69
|
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
68
70
|
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
71
|
+
validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema);
|
|
69
72
|
validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
|
|
70
73
|
}
|
|
71
74
|
// filter sends `payload.filter` — same 422 footgun as facets below
|
|
@@ -78,6 +81,16 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
|
|
|
78
81
|
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
79
82
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
|
|
80
83
|
}
|
|
84
|
+
// namedFilters send `payload.namedFilter` — same 422 footgun as filter/facets.
|
|
85
|
+
function validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema) {
|
|
86
|
+
// skip: no named filters declared, or the schema already accepts the key — nothing to reject.
|
|
87
|
+
if (screen.namedFilters === undefined || schemaAccepts(schema, "namedFilter"))
|
|
88
|
+
return;
|
|
89
|
+
throw bootRuleError(NAMED_FILTER_RULES.projectionQueryAcceptsNamedFilter, {
|
|
90
|
+
message: `${prefix}: declares namedFilters but query "${screen.query}" has no "namedFilter" parameter in its ` +
|
|
91
|
+
`Zod schema — add namedFilter: z.string().optional() to the handler's schema and resolve it with resolveNamedListFilters.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
81
94
|
// facets send `payload.filters` — same failure mode as search/sort:
|
|
82
95
|
// definePagedQueryHandler doesn't auto-merge params into the
|
|
83
96
|
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const SCREEN_ACTION_RULES: {
|
|
2
|
+
readonly actionIcon: {
|
|
3
|
+
readonly id: "screen/action-icon";
|
|
4
|
+
readonly summary: "Every screen action resolves to a registered icon.";
|
|
5
|
+
readonly fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.";
|
|
6
|
+
};
|
|
7
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const SCREEN_ACTION_RULES = {
|
|
2
|
+
actionIcon: {
|
|
3
|
+
id: "screen/action-icon",
|
|
4
|
+
summary: "Every screen action resolves to a registered icon.",
|
|
5
|
+
fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.",
|
|
6
|
+
},
|
|
7
|
+
};
|
|
@@ -4,6 +4,8 @@ import { fallbackTargets, SCREEN_FALLBACK_REASONS, } from "@cosmicdrift/kumiko-t
|
|
|
4
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
5
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
6
6
|
import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
|
|
7
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
8
|
+
import { SCREEN_ACTION_RULES } from "./screens-actions-rules.js";
|
|
7
9
|
export function isRowClickAction(action) {
|
|
8
10
|
return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
|
|
9
11
|
}
|
|
@@ -117,9 +119,11 @@ function isRegisteredIcon(icon) {
|
|
|
117
119
|
export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
|
|
118
120
|
const resolved = resolveActionIcon(action.id, action.icon);
|
|
119
121
|
if (resolved === undefined || !isRegisteredIcon(resolved)) {
|
|
120
|
-
throw
|
|
121
|
-
`
|
|
122
|
-
|
|
122
|
+
throw bootRuleError(SCREEN_ACTION_RULES.actionIcon, {
|
|
123
|
+
message: `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
|
|
124
|
+
`resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
|
|
125
|
+
`its kebab segments via the default icon map (resolveActionIcon).`,
|
|
126
|
+
});
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
// Pin an action's declarative field references against the entity
|
|
@@ -45,6 +45,7 @@ export declare const entityListSchema: z.ZodObject<{
|
|
|
45
45
|
}>;
|
|
46
46
|
value: z.ZodUnknown;
|
|
47
47
|
}, z.core.$strip>>>;
|
|
48
|
+
namedFilter: z.ZodOptional<z.ZodString>;
|
|
48
49
|
aggregate: z.ZodOptional<z.ZodObject<{
|
|
49
50
|
groupBy: z.ZodOptional<z.ZodString>;
|
|
50
51
|
measures: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -15,6 +15,7 @@ import { entityListRuntime, parentVisibilityFor } from "./entity-list-runtime-op
|
|
|
15
15
|
import { assertReason } from "./escape-hatch-reason.js";
|
|
16
16
|
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
17
17
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
18
|
+
import { resolveNamedListFilters } from "./resolve-named-list-filters.js";
|
|
18
19
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
19
20
|
import { parseTenantId } from "./types/identifiers.js";
|
|
20
21
|
// Convention-based handler factories for event-sourced aggregates.
|
|
@@ -109,6 +110,8 @@ export const entityListSchema = z.object({
|
|
|
109
110
|
value: z.unknown(),
|
|
110
111
|
}))
|
|
111
112
|
.optional(),
|
|
113
|
+
// Entity-defined work-queue rule (EntityDefinition.namedFilters); its conditions are appended to `filters`.
|
|
114
|
+
namedFilter: z.string().min(1).max(100).optional(),
|
|
112
115
|
aggregate: listAggregatePayloadSchema.optional(),
|
|
113
116
|
});
|
|
114
117
|
function parseHandlerName(name, validVerbs) {
|
|
@@ -398,7 +401,16 @@ export function defineEntityQueryHandler(name, entity, options) {
|
|
|
398
401
|
// against Meilisearch/InMem at runtime (remote combobox search). The
|
|
399
402
|
// executor is built at definition time and cannot know the adapter, so the
|
|
400
403
|
// runtime override supplies it.
|
|
401
|
-
const
|
|
404
|
+
const requestPayload = query.payload; // @cast-boundary engine-payload
|
|
405
|
+
const listPayload = requestPayload.namedFilter === undefined
|
|
406
|
+
? requestPayload
|
|
407
|
+
: {
|
|
408
|
+
...requestPayload,
|
|
409
|
+
filters: [
|
|
410
|
+
...(requestPayload.filters ?? []),
|
|
411
|
+
...(await resolveNamedListFilters(ctx, query.user, entityName, requestPayload.namedFilter)),
|
|
412
|
+
],
|
|
413
|
+
};
|
|
402
414
|
const db = dbFor(ctx);
|
|
403
415
|
const { runtimeOptions, eagerLoadViewer, resolveEntity } = entityListRuntime(ctx, entityName, query.user);
|
|
404
416
|
if (listPayload.aggregate !== undefined) {
|
|
@@ -208,6 +208,8 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
|
|
|
208
208
|
readonly readGate?: EntityDefinition["readGate"];
|
|
209
209
|
readonly retention?: RetentionDef;
|
|
210
210
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
211
|
+
/** See EntityDefinition.namedFilters. */
|
|
212
|
+
readonly namedFilters?: EntityDefinition["namedFilters"];
|
|
211
213
|
readonly tenancy?: T;
|
|
212
214
|
/** See EntityDefinition.transferable. */
|
|
213
215
|
readonly transferable?: boolean;
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-
|
|
|
22
22
|
export { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.ts";
|
|
23
23
|
export { hasAccess } from "./access.ts";
|
|
24
24
|
export type { PrincipalProfile, PrincipalStatus, PrincipalStatusPlugin, TenantLifecycleStatusPlugin, } from "./active-membership.ts";
|
|
25
|
+
export { collectBootRuleViolations } from "./boot-validator/boot-report.ts";
|
|
26
|
+
export { type BootRule, type BootRuleTable, BootRuleViolation, BootValidationError, type BootViolation, bootRuleError, } from "./boot-validator/boot-rule.ts";
|
|
25
27
|
export { validateBundledListsOptions } from "./boot-validator/bundled-lists-table.ts";
|
|
26
28
|
export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.ts";
|
|
27
29
|
export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.ts";
|
|
@@ -78,6 +80,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.ts"
|
|
|
78
80
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.ts";
|
|
79
81
|
export { readClaim } from "./read-claim.ts";
|
|
80
82
|
export { createRegistry } from "./registry.ts";
|
|
83
|
+
export { resolveNamedListFilters } from "./resolve-named-list-filters.ts";
|
|
81
84
|
export type { AssignableAppRoles } from "./role-assignment.ts";
|
|
82
85
|
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.ts";
|
|
83
86
|
export { runPipeline, runStepList } from "./run-pipeline.ts";
|
package/dist/engine/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN,
|
|
|
4
4
|
export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-keys.js";
|
|
5
5
|
export { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.js";
|
|
6
6
|
export { hasAccess } from "./access.js";
|
|
7
|
+
export { collectBootRuleViolations } from "./boot-validator/boot-report.js";
|
|
8
|
+
export { BootRuleViolation, BootValidationError, bootRuleError, } from "./boot-validator/boot-rule.js";
|
|
7
9
|
export { validateBundledListsOptions } from "./boot-validator/bundled-lists-table.js";
|
|
8
10
|
export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
|
|
9
11
|
export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
|
|
@@ -62,6 +64,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.js"
|
|
|
62
64
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
63
65
|
export { readClaim } from "./read-claim.js";
|
|
64
66
|
export { createRegistry } from "./registry.js";
|
|
67
|
+
export { resolveNamedListFilters } from "./resolve-named-list-filters.js";
|
|
65
68
|
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
66
69
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
|
67
70
|
export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { NamedListFilterCondition } from "@cosmicdrift/kumiko-types/named-list-filters";
|
|
2
|
+
import type { HandlerContext, SessionUser } from "./types/index.ts";
|
|
3
|
+
export declare function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<readonly NamedListFilterCondition[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AccessDeniedError, ValidationError } from "../errors/index.js";
|
|
2
|
+
import { hasAccess } from "./access.js";
|
|
3
|
+
export async function resolveNamedListFilters(ctx, user, entityName, name) {
|
|
4
|
+
const registered = ctx.registry.getEntity(entityName)?.namedFilters ?? {};
|
|
5
|
+
const def = Object.hasOwn(registered, name) ? registered[name] : undefined;
|
|
6
|
+
if (def === undefined) {
|
|
7
|
+
throw new ValidationError({
|
|
8
|
+
fields: [
|
|
9
|
+
{
|
|
10
|
+
path: "namedFilter",
|
|
11
|
+
code: "unknown_named_filter",
|
|
12
|
+
i18nKey: "errors.validation.invalid_value",
|
|
13
|
+
params: { entity: entityName, namedFilter: name },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (def.access !== undefined && !hasAccess(user, def.access)) {
|
|
19
|
+
throw new AccessDeniedError();
|
|
20
|
+
}
|
|
21
|
+
const conditions = await def.filters({ ctx, user, now: ctx.tz.now() });
|
|
22
|
+
const entityFields = ctx.registry.getEntity(entityName)?.fields ?? {};
|
|
23
|
+
for (const condition of conditions) {
|
|
24
|
+
// Fail closed: a condition on a non-column is dropped by the list filter and would widen the queue.
|
|
25
|
+
if (condition.field !== "id" && !Object.hasOwn(entityFields, condition.field)) {
|
|
26
|
+
throw new Error(`Named filter "${name}" of entity "${entityName}" filters on unknown field "${condition.field}".`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return conditions;
|
|
30
|
+
}
|
|
@@ -480,6 +480,9 @@ export function requiredKeysFromFeature(feature) {
|
|
|
480
480
|
for (const key of requiredKeysFromNav(nav))
|
|
481
481
|
out.add(key);
|
|
482
482
|
}
|
|
483
|
+
for (const collection of Object.values(feature.contentCollections ?? {})) {
|
|
484
|
+
pushKey(out, collection.nav.entryLabel);
|
|
485
|
+
}
|
|
483
486
|
for (const ws of Object.values(feature.workspaces)) {
|
|
484
487
|
for (const key of requiredKeysFromWorkspace(ws))
|
|
485
488
|
out.add(key);
|
|
@@ -4,7 +4,7 @@ import { assertRowLevelSecurityTableState, createDbConnection, generateMigration
|
|
|
4
4
|
import { isLiveFrameworkTable } from "../db/known-table-names.js";
|
|
5
5
|
import { readMigrationSqlTexts } from "../db/migrate-runner.js";
|
|
6
6
|
import { diffReplayAgainstSnapshot, replayMigrationsDir } from "../db/replay-migration-sql.js";
|
|
7
|
-
import { BootValidationError } from "../engine/boot-validator/boot-rule.js";
|
|
7
|
+
import { BootValidationError, bootRuleDocLink, } from "../engine/boot-validator/boot-rule.js";
|
|
8
8
|
import { validateBoot } from "../engine/boot-validator.js";
|
|
9
9
|
import { describeThrown, loadPreviousSnapshot, loadSchemaModOrReport, nextSequenceNumber, SNAPSHOT_FILENAME, } from "./shared.js";
|
|
10
10
|
import { featureDefinitionsOf, parseValidateBootOptions, withFileStoragePresence, } from "./validate-boot-input.js";
|
|
@@ -44,6 +44,9 @@ function reportBootViolations(out, violations) {
|
|
|
44
44
|
out.err(` ✗ boot [${v.ruleId}] ${v.message}`);
|
|
45
45
|
if (v.fix !== undefined)
|
|
46
46
|
out.err(` Fix: ${v.fix}`);
|
|
47
|
+
const docLink = bootRuleDocLink(v.ruleId);
|
|
48
|
+
if (docLink !== undefined)
|
|
49
|
+
out.err(` Rule: ${docLink}`);
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
// 2. Boot validity — needs the composed feature set.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.403.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.403.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"hono": "^4.13.14",
|
|
209
209
|
"i18next": "^26.1.0",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.403.0",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
230
|
"fflate": "^0.8.3",
|
|
231
231
|
"meilisearch": "^0.58.0",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.403.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Entities define named list filters",
|
|
6
|
+
"detail": "createEntity takes namedFilters, a record of { access?, filters(scope) } where filters returns ListFilter conditions (sync or async). The default list query accepts namedFilter and appends the resolved conditions to filters, on the row path and the aggregate path. An unknown name is a validation error on namedFilter, a failed access rule is access_denied. resolveNamedListFilters is exported from the engine entry. The definition is function-valued and never reaches the client schema."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.402.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Boot violations carry rule ids with a fix and a doc link",
|
|
12
|
+
"detail": "43 boot rules have an id such as `pii/subject-key-sortable` or `entity-list/default-sort-required`. A violation in `BootValidationError.violations` and in `kumiko-schema validate` (text and `--json`) carries that id instead of `unclassified/<check>`, plus a `Fix:` line and `Rule: agents/boot-rules.md#<id>`. PII field rules, entityList screen rules, missing i18n keys, API and extension usages and user-data-rights hooks now report all violations in one run. Message texts are unchanged. `@cosmicdrift/kumiko-framework/engine` exports `bootRuleError`, `BootRuleViolation`, `BootValidationError`, `collectBootRuleViolations` and the types `BootRule`, `BootRuleTable`, `BootViolation`, so a feature's `r.bootCheck` can throw a classified rule."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.402.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Migration replay keeps tables whose name ends in \"as\"",
|
|
18
|
+
"detail": "`CREATE TABLE IF NOT EXISTS \"read_themas\" (...)` was parsed as `CREATE TABLE read_them AS ...`, so `kumiko-schema validate` reported content drift for `read_them` and `read_themas`. `AS` now needs a column list or whitespace before it."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.401.0",
|
|
4
22
|
"type": "improvement",
|