@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.83.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.83.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 { validateGdprStoragePersistence } from "../boot-validator/gdpr-storage";
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 (!screen || screen.type !== "configEdit")
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)");