@cosmicdrift/kumiko-framework 0.83.0 → 0.85.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.85.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.85.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// V1 — GDPR storage-persistence boot guard. Catches the prod failure class:
|
|
2
2
|
// user-data-rights mounted but exports land in an ephemeral / missing store,
|
|
3
3
|
// and s3-env selected as the GDPR store without its env vars set.
|
|
4
|
+
// V2 — export-without-erase guard. Catches features that register an export
|
|
5
|
+
// hook but no delete hook (Art.17 violation).
|
|
4
6
|
|
|
5
7
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
6
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
validateGdprHookCompleteness,
|
|
10
|
+
validateGdprStoragePersistence,
|
|
11
|
+
} from "../boot-validator/gdpr-storage";
|
|
7
12
|
import { defineFeature } from "../define-feature";
|
|
8
13
|
|
|
9
14
|
const udr = () => defineFeature("user-data-rights", () => {});
|
|
@@ -72,3 +77,66 @@ describe("validateGdprStoragePersistence (V1)", () => {
|
|
|
72
77
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
73
78
|
});
|
|
74
79
|
});
|
|
80
|
+
|
|
81
|
+
describe("validateGdprHookCompleteness (V2)", () => {
|
|
82
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
warnSpy.mockRestore();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const exportFn = async () => null;
|
|
93
|
+
const deleteFn = async () => {};
|
|
94
|
+
|
|
95
|
+
test("export + delete hooks → no warn", () => {
|
|
96
|
+
const f = defineFeature("my-feature", (r) => {
|
|
97
|
+
r.useExtension("userData", "myEntity", { export: exportFn, delete: deleteFn });
|
|
98
|
+
});
|
|
99
|
+
validateGdprHookCompleteness([f]);
|
|
100
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("export hook without delete hook → Art.17 warn", () => {
|
|
104
|
+
const f = defineFeature("my-feature", (r) => {
|
|
105
|
+
r.useExtension("userData", "myEntity", { export: exportFn });
|
|
106
|
+
});
|
|
107
|
+
validateGdprHookCompleteness([f]);
|
|
108
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
109
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
110
|
+
expect(msg).toContain("my-feature");
|
|
111
|
+
expect(msg).toContain("myEntity");
|
|
112
|
+
expect(msg).toContain("Art.17");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("delete hook only (no export) → no warn", () => {
|
|
116
|
+
const f = defineFeature("my-feature", (r) => {
|
|
117
|
+
r.useExtension("userData", "myEntity", { delete: deleteFn });
|
|
118
|
+
});
|
|
119
|
+
validateGdprHookCompleteness([f]);
|
|
120
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("no EXT_USER_DATA hooks at all → no warn", () => {
|
|
124
|
+
const f = defineFeature("my-feature", (r) => {
|
|
125
|
+
r.useExtension("fileProvider", "s3");
|
|
126
|
+
});
|
|
127
|
+
validateGdprHookCompleteness([f]);
|
|
128
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("multiple features, one missing delete → one warn per missing hook", () => {
|
|
132
|
+
const good = defineFeature("good", (r) => {
|
|
133
|
+
r.useExtension("userData", "entityA", { export: exportFn, delete: deleteFn });
|
|
134
|
+
});
|
|
135
|
+
const bad = defineFeature("bad", (r) => {
|
|
136
|
+
r.useExtension("userData", "entityB", { export: exportFn });
|
|
137
|
+
});
|
|
138
|
+
validateGdprHookCompleteness([good, bad]);
|
|
139
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("entityB");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -220,8 +220,7 @@ describe("buildConfigFeatureSchema — machine-role does not leak into screen ac
|
|
|
220
220
|
test("a key with write ['system','SystemAdmin'] yields a SystemAdmin-only screen (no 'system')", () => {
|
|
221
221
|
const s = buildConfigFeatureSchema(createRegistry([mixedWrite]));
|
|
222
222
|
const screen = s.screens.find((x) => x.id === "mixed-system");
|
|
223
|
-
if (
|
|
224
|
-
throw new Error("no mixed-system configEdit screen");
|
|
223
|
+
if (screen?.type !== "configEdit") throw new Error("no mixed-system configEdit screen");
|
|
225
224
|
expect(screen.access).toEqual({ roles: ["SystemAdmin"] });
|
|
226
225
|
const roles = screen.access && "roles" in screen.access ? screen.access.roles : [];
|
|
227
226
|
expect(roles).not.toContain("system");
|
|
@@ -57,3 +57,23 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
// V2: export-without-erase guard. A feature that registers an EXT_USER_DATA
|
|
62
|
+
// export hook without a matching delete hook exports data under Art.20 but
|
|
63
|
+
// never erases it on forget — an Art.17 violation. Registry-level signal only;
|
|
64
|
+
// runtime no-ops (a delete hook that silently skips) are not detectable here.
|
|
65
|
+
export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
|
|
66
|
+
for (const feature of features) {
|
|
67
|
+
for (const usage of feature.extensionUsages) {
|
|
68
|
+
if (usage.extensionName !== "userData") continue;
|
|
69
|
+
const hasExport = typeof usage.options?.["export"] === "function";
|
|
70
|
+
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
71
|
+
if (hasExport && !hasDelete) {
|
|
72
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
73
|
+
console.warn(
|
|
74
|
+
`[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 risk). Add a delete hook, or a no-op with a comment explaining why erasure is intentionally skipped.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
validateReferenceFields,
|
|
26
26
|
validateTransitions,
|
|
27
27
|
} from "./entity-handler";
|
|
28
|
-
import { validateGdprStoragePersistence } from "./gdpr-storage";
|
|
28
|
+
import { validateGdprHookCompleteness, validateGdprStoragePersistence } from "./gdpr-storage";
|
|
29
29
|
import { validateOwnershipRules } from "./ownership";
|
|
30
30
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
31
31
|
import {
|
|
@@ -162,6 +162,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
162
162
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
163
163
|
validateExtensionPreSaveWiring(features);
|
|
164
164
|
validateGdprStoragePersistence(features);
|
|
165
|
+
validateGdprHookCompleteness(features);
|
|
165
166
|
|
|
166
167
|
if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
|
|
167
168
|
throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
|