@cosmicdrift/kumiko-framework 0.401.0 → 0.402.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 +24 -0
- package/agents/api/types.md +1 -0
- package/agents/api.md +1 -1
- package/agents/boot-rules.md +98 -0
- 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-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 +210 -0
- package/dist/engine/boot-validator/boot-rules.js +17 -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/entity-list-screens-rules.d.ts +32 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +32 -0
- package/dist/engine/boot-validator/entity-list-screens.js +74 -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/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/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/index.d.ts +2 -0
- package/dist/engine/index.js +2 -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 +12 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export declare const PII_RULES: {
|
|
2
|
+
readonly multipleSubjectAnnotations: {
|
|
3
|
+
readonly id: "pii/multiple-subject-annotations";
|
|
4
|
+
readonly summary: "A field carries at most one subject-key annotation.";
|
|
5
|
+
readonly fix: "Keep a single personal annotation on the field.";
|
|
6
|
+
};
|
|
7
|
+
readonly textWithoutPersonalStance: {
|
|
8
|
+
readonly id: "pii/text-without-personal-stance";
|
|
9
|
+
readonly summary: "Every text and longText field declares whether it holds personal data.";
|
|
10
|
+
readonly fix: "Declare a personal stance, or personal: false with a reason stating why the value is not personal data.";
|
|
11
|
+
};
|
|
12
|
+
readonly plaintextUserReference: {
|
|
13
|
+
readonly id: "pii/plaintext-user-reference";
|
|
14
|
+
readonly summary: "A reference to the user entity is never plaintext.";
|
|
15
|
+
readonly fix: 'Use the default reference field or personal: "ref" instead of personal: false.';
|
|
16
|
+
};
|
|
17
|
+
readonly lookupableNonText: {
|
|
18
|
+
readonly id: "pii/lookupable-non-text";
|
|
19
|
+
readonly summary: "Blind-index lookups apply to text fields only.";
|
|
20
|
+
readonly fix: 'Use a text field or set find: "none".';
|
|
21
|
+
};
|
|
22
|
+
readonly lookupableWithoutSubject: {
|
|
23
|
+
readonly id: "pii/lookupable-without-subject";
|
|
24
|
+
readonly summary: "A blind-index lookup requires a subject annotation on the field.";
|
|
25
|
+
readonly fix: 'Add a personal annotation or set find: "none".';
|
|
26
|
+
};
|
|
27
|
+
readonly sensitiveWithoutCiphertext: {
|
|
28
|
+
readonly id: "pii/sensitive-without-ciphertext";
|
|
29
|
+
readonly summary: "A sensitive field is stored as ciphertext at rest.";
|
|
30
|
+
readonly fix: "Add a subject annotation or encrypted: true.";
|
|
31
|
+
};
|
|
32
|
+
readonly writeOnlyEmbeddedSubField: {
|
|
33
|
+
readonly id: "pii/write-only-embedded-sub-field";
|
|
34
|
+
readonly summary: "writeOnly is only supported on top-level text fields, not on embedded sub-fields.";
|
|
35
|
+
readonly fix: "Remove writeOnly from the sub-field or declare it on a top-level text field.";
|
|
36
|
+
};
|
|
37
|
+
readonly writeOnlyNonText: {
|
|
38
|
+
readonly id: "pii/write-only-non-text";
|
|
39
|
+
readonly summary: "writeOnly applies to text fields only.";
|
|
40
|
+
readonly fix: "Use a text field or remove writeOnly.";
|
|
41
|
+
};
|
|
42
|
+
readonly writeOnlyWithoutSensitive: {
|
|
43
|
+
readonly id: "pii/write-only-without-sensitive";
|
|
44
|
+
readonly summary: "A writeOnly field is also sensitive.";
|
|
45
|
+
readonly fix: 'Declare the field with find: "secret".';
|
|
46
|
+
};
|
|
47
|
+
readonly writeOnlyConflictingFlag: {
|
|
48
|
+
readonly id: "pii/write-only-conflicting-flag";
|
|
49
|
+
readonly summary: "A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up.";
|
|
50
|
+
readonly fix: "Remove either writeOnly or the conflicting flag.";
|
|
51
|
+
};
|
|
52
|
+
readonly subjectKeySortable: {
|
|
53
|
+
readonly id: "pii/subject-key-sortable";
|
|
54
|
+
readonly summary: "A field with a subject-key annotation is not sortable, its column is ciphertext.";
|
|
55
|
+
readonly fix: 'Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext.';
|
|
56
|
+
};
|
|
57
|
+
readonly sensitiveSearchable: {
|
|
58
|
+
readonly id: "pii/sensitive-searchable";
|
|
59
|
+
readonly summary: "A sensitive field is never searchable.";
|
|
60
|
+
readonly fix: "Drop searchable, or add writeOnly to never return the value.";
|
|
61
|
+
};
|
|
62
|
+
readonly personalJsonbFlag: {
|
|
63
|
+
readonly id: "pii/personal-jsonb-flag";
|
|
64
|
+
readonly summary: "A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string.";
|
|
65
|
+
readonly fix: "Remove the flag or the personal annotation.";
|
|
66
|
+
};
|
|
67
|
+
readonly personalJsonbIndex: {
|
|
68
|
+
readonly id: "pii/personal-jsonb-index";
|
|
69
|
+
readonly summary: "A personal jsonb field is not part of an entity index.";
|
|
70
|
+
readonly fix: "Drop the index or the personal annotation.";
|
|
71
|
+
};
|
|
72
|
+
readonly personalJsonbCustomFields: {
|
|
73
|
+
readonly id: "pii/personal-jsonb-custom-fields";
|
|
74
|
+
readonly summary: "The custom-fields jsonb column cannot carry a personal annotation.";
|
|
75
|
+
readonly fix: "Drop the personal annotation.";
|
|
76
|
+
};
|
|
77
|
+
readonly personalOfMissingOwnerName: {
|
|
78
|
+
readonly id: "pii/personal-of-missing-owner-name";
|
|
79
|
+
readonly summary: "personal: { of } names the owner field.";
|
|
80
|
+
readonly fix: 'Pass the name of the owner field: personal: { of: "<ownerField>" }.';
|
|
81
|
+
};
|
|
82
|
+
readonly personalOfUnknownOwner: {
|
|
83
|
+
readonly id: "pii/personal-of-unknown-owner";
|
|
84
|
+
readonly summary: "The owner field named by personal: { of } exists on the entity.";
|
|
85
|
+
readonly fix: "Point personal.of at an existing field of the entity.";
|
|
86
|
+
};
|
|
87
|
+
readonly personalOfOwnerType: {
|
|
88
|
+
readonly id: "pii/personal-of-owner-type";
|
|
89
|
+
readonly summary: "The owner field named by personal: { of } is a reference or text field.";
|
|
90
|
+
readonly fix: "Use a reference or text field as owner.";
|
|
91
|
+
};
|
|
92
|
+
readonly personalOfInvalidWhenAbsent: {
|
|
93
|
+
readonly id: "pii/personal-of-invalid-when-absent";
|
|
94
|
+
readonly summary: 'personal.whenAbsent is either "tenant" or "self".';
|
|
95
|
+
readonly fix: 'Remove whenAbsent or set it to "tenant" or "self".';
|
|
96
|
+
};
|
|
97
|
+
readonly personalOfOptionalOwner: {
|
|
98
|
+
readonly id: "pii/personal-of-optional-owner";
|
|
99
|
+
readonly summary: "An optional owner field of personal: { of } declares a whenAbsent fallback.";
|
|
100
|
+
readonly fix: 'Set whenAbsent to "tenant" or "self", or make the owner field required.';
|
|
101
|
+
};
|
|
102
|
+
readonly retentionUnknownReference: {
|
|
103
|
+
readonly id: "retention/unknown-reference";
|
|
104
|
+
readonly summary: "retention.reference names an existing field or a framework timestamp.";
|
|
105
|
+
readonly fix: "Point retention.reference at an existing field or a framework-managed timestamp.";
|
|
106
|
+
};
|
|
107
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const PII_RULES = {
|
|
2
|
+
multipleSubjectAnnotations: {
|
|
3
|
+
id: "pii/multiple-subject-annotations",
|
|
4
|
+
summary: "A field carries at most one subject-key annotation.",
|
|
5
|
+
fix: "Keep a single personal annotation on the field.",
|
|
6
|
+
},
|
|
7
|
+
textWithoutPersonalStance: {
|
|
8
|
+
id: "pii/text-without-personal-stance",
|
|
9
|
+
summary: "Every text and longText field declares whether it holds personal data.",
|
|
10
|
+
fix: "Declare a personal stance, or personal: false with a reason stating why the value is not personal data.",
|
|
11
|
+
},
|
|
12
|
+
plaintextUserReference: {
|
|
13
|
+
id: "pii/plaintext-user-reference",
|
|
14
|
+
summary: "A reference to the user entity is never plaintext.",
|
|
15
|
+
fix: 'Use the default reference field or personal: "ref" instead of personal: false.',
|
|
16
|
+
},
|
|
17
|
+
lookupableNonText: {
|
|
18
|
+
id: "pii/lookupable-non-text",
|
|
19
|
+
summary: "Blind-index lookups apply to text fields only.",
|
|
20
|
+
fix: 'Use a text field or set find: "none".',
|
|
21
|
+
},
|
|
22
|
+
lookupableWithoutSubject: {
|
|
23
|
+
id: "pii/lookupable-without-subject",
|
|
24
|
+
summary: "A blind-index lookup requires a subject annotation on the field.",
|
|
25
|
+
fix: 'Add a personal annotation or set find: "none".',
|
|
26
|
+
},
|
|
27
|
+
sensitiveWithoutCiphertext: {
|
|
28
|
+
id: "pii/sensitive-without-ciphertext",
|
|
29
|
+
summary: "A sensitive field is stored as ciphertext at rest.",
|
|
30
|
+
fix: "Add a subject annotation or encrypted: true.",
|
|
31
|
+
},
|
|
32
|
+
writeOnlyEmbeddedSubField: {
|
|
33
|
+
id: "pii/write-only-embedded-sub-field",
|
|
34
|
+
summary: "writeOnly is only supported on top-level text fields, not on embedded sub-fields.",
|
|
35
|
+
fix: "Remove writeOnly from the sub-field or declare it on a top-level text field.",
|
|
36
|
+
},
|
|
37
|
+
writeOnlyNonText: {
|
|
38
|
+
id: "pii/write-only-non-text",
|
|
39
|
+
summary: "writeOnly applies to text fields only.",
|
|
40
|
+
fix: "Use a text field or remove writeOnly.",
|
|
41
|
+
},
|
|
42
|
+
writeOnlyWithoutSensitive: {
|
|
43
|
+
id: "pii/write-only-without-sensitive",
|
|
44
|
+
summary: "A writeOnly field is also sensitive.",
|
|
45
|
+
fix: 'Declare the field with find: "secret".',
|
|
46
|
+
},
|
|
47
|
+
writeOnlyConflictingFlag: {
|
|
48
|
+
id: "pii/write-only-conflicting-flag",
|
|
49
|
+
summary: "A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up.",
|
|
50
|
+
fix: "Remove either writeOnly or the conflicting flag.",
|
|
51
|
+
},
|
|
52
|
+
subjectKeySortable: {
|
|
53
|
+
id: "pii/subject-key-sortable",
|
|
54
|
+
summary: "A field with a subject-key annotation is not sortable, its column is ciphertext.",
|
|
55
|
+
fix: 'Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext.',
|
|
56
|
+
},
|
|
57
|
+
sensitiveSearchable: {
|
|
58
|
+
id: "pii/sensitive-searchable",
|
|
59
|
+
summary: "A sensitive field is never searchable.",
|
|
60
|
+
fix: "Drop searchable, or add writeOnly to never return the value.",
|
|
61
|
+
},
|
|
62
|
+
personalJsonbFlag: {
|
|
63
|
+
id: "pii/personal-jsonb-flag",
|
|
64
|
+
summary: "A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string.",
|
|
65
|
+
fix: "Remove the flag or the personal annotation.",
|
|
66
|
+
},
|
|
67
|
+
personalJsonbIndex: {
|
|
68
|
+
id: "pii/personal-jsonb-index",
|
|
69
|
+
summary: "A personal jsonb field is not part of an entity index.",
|
|
70
|
+
fix: "Drop the index or the personal annotation.",
|
|
71
|
+
},
|
|
72
|
+
personalJsonbCustomFields: {
|
|
73
|
+
id: "pii/personal-jsonb-custom-fields",
|
|
74
|
+
summary: "The custom-fields jsonb column cannot carry a personal annotation.",
|
|
75
|
+
fix: "Drop the personal annotation.",
|
|
76
|
+
},
|
|
77
|
+
personalOfMissingOwnerName: {
|
|
78
|
+
id: "pii/personal-of-missing-owner-name",
|
|
79
|
+
summary: "personal: { of } names the owner field.",
|
|
80
|
+
fix: 'Pass the name of the owner field: personal: { of: "<ownerField>" }.',
|
|
81
|
+
},
|
|
82
|
+
personalOfUnknownOwner: {
|
|
83
|
+
id: "pii/personal-of-unknown-owner",
|
|
84
|
+
summary: "The owner field named by personal: { of } exists on the entity.",
|
|
85
|
+
fix: "Point personal.of at an existing field of the entity.",
|
|
86
|
+
},
|
|
87
|
+
personalOfOwnerType: {
|
|
88
|
+
id: "pii/personal-of-owner-type",
|
|
89
|
+
summary: "The owner field named by personal: { of } is a reference or text field.",
|
|
90
|
+
fix: "Use a reference or text field as owner.",
|
|
91
|
+
},
|
|
92
|
+
personalOfInvalidWhenAbsent: {
|
|
93
|
+
id: "pii/personal-of-invalid-when-absent",
|
|
94
|
+
summary: 'personal.whenAbsent is either "tenant" or "self".',
|
|
95
|
+
fix: 'Remove whenAbsent or set it to "tenant" or "self".',
|
|
96
|
+
},
|
|
97
|
+
personalOfOptionalOwner: {
|
|
98
|
+
id: "pii/personal-of-optional-owner",
|
|
99
|
+
summary: "An optional owner field of personal: { of } declares a whenAbsent fallback.",
|
|
100
|
+
fix: 'Set whenAbsent to "tenant" or "self", or make the owner field required.',
|
|
101
|
+
},
|
|
102
|
+
retentionUnknownReference: {
|
|
103
|
+
id: "retention/unknown-reference",
|
|
104
|
+
summary: "retention.reference names an existing field or a framework timestamp.",
|
|
105
|
+
fix: "Point retention.reference at an existing field or a framework-managed timestamp.",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -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;
|
|
@@ -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
|
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";
|
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";
|
|
@@ -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.402.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.402.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.402.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,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Boot violations carry rule ids with a fix and a doc link",
|
|
6
|
+
"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."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.402.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Migration replay keeps tables whose name ends in \"as\"",
|
|
12
|
+
"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."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.401.0",
|
|
4
16
|
"type": "improvement",
|