@cosmicdrift/kumiko-framework 0.115.0 → 0.116.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.116.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.116.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
// and s3-env selected as the GDPR store without its env vars set.
|
|
4
4
|
// V2 — export-without-erase guard. Catches features that register an export
|
|
5
5
|
// hook but no delete hook (Art.17 violation).
|
|
6
|
+
// V3 — PII-entity-without-hook guard. Catches entities with pii/userOwned
|
|
7
|
+
// fields that no feature registers an EXT_USER_DATA hook for.
|
|
6
8
|
|
|
7
9
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
8
10
|
import {
|
|
9
11
|
validateGdprHookCompleteness,
|
|
12
|
+
validateGdprPiiHookCoverage,
|
|
10
13
|
validateGdprStoragePersistence,
|
|
11
14
|
} from "../boot-validator/gdpr-storage";
|
|
12
15
|
import { defineFeature } from "../define-feature";
|
|
13
16
|
import { EXT_USER_DATA } from "../extension-names";
|
|
17
|
+
import { createEntity, createLongTextField, createTextField } from "../factories";
|
|
14
18
|
|
|
15
19
|
const udr = () => defineFeature("user-data-rights", () => {});
|
|
16
20
|
const fileProvider = (name: string) =>
|
|
@@ -141,3 +145,85 @@ describe("validateGdprHookCompleteness (V2)", () => {
|
|
|
141
145
|
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("entityB");
|
|
142
146
|
});
|
|
143
147
|
});
|
|
148
|
+
|
|
149
|
+
describe("validateGdprPiiHookCoverage (V3)", () => {
|
|
150
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
151
|
+
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
warnSpy.mockRestore();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const exportFn = async () => null;
|
|
161
|
+
const deleteFn = async () => {};
|
|
162
|
+
|
|
163
|
+
const piiFeature = () =>
|
|
164
|
+
defineFeature("crm", (r) => {
|
|
165
|
+
r.entity(
|
|
166
|
+
"contact",
|
|
167
|
+
createEntity({
|
|
168
|
+
fields: {
|
|
169
|
+
email: createTextField({ pii: true }),
|
|
170
|
+
note: createLongTextField({ userOwned: { ownerField: "authorId" } }),
|
|
171
|
+
authorId: { type: "reference", entity: "user" },
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("user-data-rights not mounted → no warn", () => {
|
|
178
|
+
validateGdprPiiHookCoverage([piiFeature()]);
|
|
179
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("pii entity without any EXT_USER_DATA hook → warn naming entity and fields", () => {
|
|
183
|
+
validateGdprPiiHookCoverage([udr(), piiFeature()]);
|
|
184
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
185
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
186
|
+
expect(msg).toContain('"contact"');
|
|
187
|
+
expect(msg).toContain("email");
|
|
188
|
+
expect(msg).toContain("note");
|
|
189
|
+
expect(msg).toContain("Art.17");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("pii entity with hook registered by another feature → no warn", () => {
|
|
193
|
+
const hooks = defineFeature("crm-user-data", (r) => {
|
|
194
|
+
r.useExtension(EXT_USER_DATA, "contact", { export: exportFn, delete: deleteFn });
|
|
195
|
+
});
|
|
196
|
+
validateGdprPiiHookCoverage([udr(), piiFeature(), hooks]);
|
|
197
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("entity without subject annotations → no warn", () => {
|
|
201
|
+
const plain = defineFeature("catalog", (r) => {
|
|
202
|
+
r.entity(
|
|
203
|
+
"product",
|
|
204
|
+
createEntity({
|
|
205
|
+
fields: { sku: createTextField({ allowPlaintext: "is-business-data" }) },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
validateGdprPiiHookCoverage([udr(), plain]);
|
|
210
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("userOwned field alone counts as user-subject data → warn", () => {
|
|
214
|
+
const f = defineFeature("notes", (r) => {
|
|
215
|
+
r.entity(
|
|
216
|
+
"note",
|
|
217
|
+
createEntity({
|
|
218
|
+
fields: {
|
|
219
|
+
body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
|
|
220
|
+
authorId: { type: "reference", entity: "user" },
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
validateGdprPiiHookCoverage([udr(), f]);
|
|
226
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
227
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain('"note"');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EXT_USER_DATA } from "../extension-names";
|
|
2
2
|
import type { FeatureDefinition } from "../types";
|
|
3
|
+
import type { PiiAnnotations } from "../types/fields";
|
|
3
4
|
|
|
4
5
|
// Providers whose bytes do not survive a process restart. Only "inmemory"
|
|
5
6
|
// today; extend if another ephemeral bundled provider lands.
|
|
@@ -78,3 +79,41 @@ export function validateGdprHookCompleteness(features: readonly FeatureDefinitio
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
// V3: PII-entity-without-hook guard. V2 checks registered hooks for
|
|
84
|
+
// completeness; V3 catches the entity nobody registered at all — fields
|
|
85
|
+
// annotated as user-subject data (pii / userOwned) yet invisible to the
|
|
86
|
+
// Art.15/20 export and Art.17 forget pipeline. Matching is by entity name
|
|
87
|
+
// across all features (usage.entityName is unqualified); a same-named entity
|
|
88
|
+
// in another feature can mask a gap — accepted for a WARN-level heuristic.
|
|
89
|
+
export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
|
|
90
|
+
const featureNames = new Set(features.map((f) => f.name));
|
|
91
|
+
if (!featureNames.has("user-data-rights")) {
|
|
92
|
+
// skip: this guard only applies to apps that mount user-data-rights
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hookedEntities = new Set<string>();
|
|
97
|
+
for (const f of features) {
|
|
98
|
+
for (const usage of f.extensionUsages) {
|
|
99
|
+
if (usage.extensionName === EXT_USER_DATA) hookedEntities.add(usage.entityName);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const feature of features) {
|
|
104
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
105
|
+
if (hookedEntities.has(entityName)) continue;
|
|
106
|
+
const subjectFields = Object.entries(entity.fields)
|
|
107
|
+
.filter(([, field]) => {
|
|
108
|
+
const annot = field as PiiAnnotations; // @cast-boundary schema-walk
|
|
109
|
+
return Boolean(annot.pii) || Boolean(annot.userOwned);
|
|
110
|
+
})
|
|
111
|
+
.map(([name]) => name);
|
|
112
|
+
if (subjectFields.length === 0) continue;
|
|
113
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
114
|
+
console.warn(
|
|
115
|
+
`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
validateReferenceFields,
|
|
28
28
|
validateTransitions,
|
|
29
29
|
} from "./entity-handler";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
validateGdprHookCompleteness,
|
|
32
|
+
validateGdprPiiHookCoverage,
|
|
33
|
+
validateGdprStoragePersistence,
|
|
34
|
+
} from "./gdpr-storage";
|
|
31
35
|
import { validateI18nSurfaceKeys } from "./i18n-keys";
|
|
32
36
|
import { validateOwnershipRules } from "./ownership";
|
|
33
37
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
@@ -179,6 +183,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
179
183
|
validateExtensionPreSaveWiring(features);
|
|
180
184
|
validateGdprStoragePersistence(features);
|
|
181
185
|
validateGdprHookCompleteness(features);
|
|
186
|
+
validateGdprPiiHookCoverage(features);
|
|
182
187
|
|
|
183
188
|
if (hasEncryptedFields) {
|
|
184
189
|
// Availability check, not env-presence: eagerly building the keyring
|