@cosmicdrift/kumiko-framework 0.125.1 → 0.125.2

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.125.1",
3
+ "version": "0.125.2",
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>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.125.1",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.125.2",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -24,6 +24,17 @@ const fileProvider = (name: string) =>
24
24
 
25
25
  const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
26
26
 
27
+ // V2/V3 are hard boot gates now — capture the thrown message so a single case
28
+ // can assert several substrings (feature, entity, article).
29
+ function catchMessage(fn: () => void): string {
30
+ try {
31
+ fn();
32
+ } catch (e) {
33
+ return e instanceof Error ? e.message : String(e);
34
+ }
35
+ throw new Error("expected function to throw, but it did not");
36
+ }
37
+
27
38
  describe("validateGdprStoragePersistence (V1)", () => {
28
39
  let warnSpy: ReturnType<typeof spyOn>;
29
40
  let savedEnv: Array<readonly [string, string | undefined]>;
@@ -84,79 +95,53 @@ describe("validateGdprStoragePersistence (V1)", () => {
84
95
  });
85
96
 
86
97
  describe("validateGdprHookCompleteness (V2)", () => {
87
- let warnSpy: ReturnType<typeof spyOn>;
88
-
89
- beforeEach(() => {
90
- warnSpy = spyOn(console, "warn").mockImplementation(() => {});
91
- });
92
-
93
- afterEach(() => {
94
- warnSpy.mockRestore();
95
- });
96
-
97
98
  const exportFn = async () => null;
98
99
  const deleteFn = async () => {};
99
100
 
100
- test("export + delete hooks → no warn", () => {
101
+ test("export + delete hooks → no throw", () => {
101
102
  const f = defineFeature("my-feature", (r) => {
102
103
  r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
103
104
  });
104
- validateGdprHookCompleteness([f]);
105
- expect(warnSpy).not.toHaveBeenCalled();
105
+ expect(() => validateGdprHookCompleteness([f])).not.toThrow();
106
106
  });
107
107
 
108
- test("export hook without delete hook → Art.17 warn", () => {
108
+ test("export hook without delete hook → Art.17 throw", () => {
109
109
  const f = defineFeature("my-feature", (r) => {
110
110
  r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
111
111
  });
112
- validateGdprHookCompleteness([f]);
113
- expect(warnSpy).toHaveBeenCalledTimes(1);
114
- const msg = String(warnSpy.mock.calls[0]?.[0]);
112
+ const msg = catchMessage(() => validateGdprHookCompleteness([f]));
115
113
  expect(msg).toContain("my-feature");
116
114
  expect(msg).toContain("myEntity");
117
115
  expect(msg).toContain("Art.17");
118
116
  });
119
117
 
120
- test("delete hook only (no export) → no warn", () => {
118
+ test("delete hook only (no export) → no throw", () => {
121
119
  const f = defineFeature("my-feature", (r) => {
122
120
  r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
123
121
  });
124
- validateGdprHookCompleteness([f]);
125
- expect(warnSpy).not.toHaveBeenCalled();
122
+ expect(() => validateGdprHookCompleteness([f])).not.toThrow();
126
123
  });
127
124
 
128
- test("no EXT_USER_DATA hooks at all → no warn", () => {
125
+ test("no EXT_USER_DATA hooks at all → no throw", () => {
129
126
  const f = defineFeature("my-feature", (r) => {
130
127
  r.useExtension("fileProvider", "s3");
131
128
  });
132
- validateGdprHookCompleteness([f]);
133
- expect(warnSpy).not.toHaveBeenCalled();
129
+ expect(() => validateGdprHookCompleteness([f])).not.toThrow();
134
130
  });
135
131
 
136
- test("multiple features, one missing delete → one warn per missing hook", () => {
132
+ test("multiple features, one missing delete → throws naming the offender", () => {
137
133
  const good = defineFeature("good", (r) => {
138
134
  r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
139
135
  });
140
136
  const bad = defineFeature("bad", (r) => {
141
137
  r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
142
138
  });
143
- validateGdprHookCompleteness([good, bad]);
144
- expect(warnSpy).toHaveBeenCalledTimes(1);
145
- expect(String(warnSpy.mock.calls[0]?.[0])).toContain("entityB");
139
+ const msg = catchMessage(() => validateGdprHookCompleteness([good, bad]));
140
+ expect(msg).toContain("entityB");
146
141
  });
147
142
  });
148
143
 
149
144
  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
145
  const exportFn = async () => null;
161
146
  const deleteFn = async () => {};
162
147
 
@@ -174,30 +159,38 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
174
159
  );
175
160
  });
176
161
 
177
- test("user-data-rights not mounted → no warn", () => {
178
- validateGdprPiiHookCoverage([piiFeature()]);
179
- expect(warnSpy).not.toHaveBeenCalled();
162
+ test("user-data-rights not mounted → no throw", () => {
163
+ expect(() => validateGdprPiiHookCoverage([piiFeature()])).not.toThrow();
180
164
  });
181
165
 
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]);
166
+ test("pii entity without any EXT_USER_DATA hook → throws naming entity and fields", () => {
167
+ const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), piiFeature()]));
186
168
  expect(msg).toContain('"contact"');
187
169
  expect(msg).toContain("email");
188
170
  expect(msg).toContain("note");
189
171
  expect(msg).toContain("Art.17");
190
172
  });
191
173
 
192
- test("pii entity with hook registered by another feature → no warn", () => {
174
+ test("pii entity with hook registered by another feature → no throw", () => {
193
175
  const hooks = defineFeature("crm-user-data", (r) => {
194
176
  r.useExtension(EXT_USER_DATA, "contact", { export: exportFn, delete: deleteFn });
195
177
  });
196
- validateGdprPiiHookCoverage([udr(), piiFeature(), hooks]);
197
- expect(warnSpy).not.toHaveBeenCalled();
178
+ expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
198
179
  });
199
180
 
200
- test("entity without subject annotations → no warn", () => {
181
+ test("no-op hook is the intentional escape hatch → no throw", () => {
182
+ const hooks = defineFeature("crm-user-data", (r) => {
183
+ // Escape hatch: erasure handled elsewhere (crypto-shredding key-erase),
184
+ // so the pipeline hook is a deliberate no-op.
185
+ r.useExtension(EXT_USER_DATA, "contact", {
186
+ export: async () => null,
187
+ delete: async () => {},
188
+ });
189
+ });
190
+ expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
191
+ });
192
+
193
+ test("entity without subject annotations → no throw", () => {
201
194
  const plain = defineFeature("catalog", (r) => {
202
195
  r.entity(
203
196
  "product",
@@ -206,11 +199,10 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
206
199
  }),
207
200
  );
208
201
  });
209
- validateGdprPiiHookCoverage([udr(), plain]);
210
- expect(warnSpy).not.toHaveBeenCalled();
202
+ expect(() => validateGdprPiiHookCoverage([udr(), plain])).not.toThrow();
211
203
  });
212
204
 
213
- test("userOwned field alone counts as user-subject data → warn", () => {
205
+ test("userOwned field alone counts as user-subject data → throws", () => {
214
206
  const f = defineFeature("notes", (r) => {
215
207
  r.entity(
216
208
  "note",
@@ -222,8 +214,7 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
222
214
  }),
223
215
  );
224
216
  });
225
- validateGdprPiiHookCoverage([udr(), f]);
226
- expect(warnSpy).toHaveBeenCalledTimes(1);
227
- expect(String(warnSpy.mock.calls[0]?.[0])).toContain('"note"');
217
+ const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), f]));
218
+ expect(msg).toContain('"note"');
228
219
  });
229
220
  });
@@ -60,10 +60,12 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
60
60
  }
61
61
  }
62
62
 
63
- // V2: export-without-erase guard. A feature that registers an EXT_USER_DATA
63
+ // V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
64
64
  // export hook without a matching delete hook exports data under Art.20 but
65
- // never erases it on forget — an Art.17 violation. Registry-level signal only;
66
- // runtime no-ops (a delete hook that silently skips) are not detectable here.
65
+ // never erases it on forget — an Art.17 violation. Hard boot failure: no app
66
+ // should ship a GDPR export path with no erase path. Registry-level signal
67
+ // only; runtime no-ops (a delete hook that silently skips) are not detectable
68
+ // here — those are covered by the export/forget integration tests.
67
69
  export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
68
70
  for (const feature of features) {
69
71
  for (const usage of feature.extensionUsages) {
@@ -71,21 +73,23 @@ export function validateGdprHookCompleteness(features: readonly FeatureDefinitio
71
73
  const hasExport = typeof usage.options?.["export"] === "function";
72
74
  const hasDelete = typeof usage.options?.["delete"] === "function";
73
75
  if (hasExport && !hasDelete) {
74
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
75
- console.warn(
76
- `[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.`,
76
+ throw new Error(
77
+ `[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 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
77
78
  );
78
79
  }
79
80
  }
80
81
  }
81
82
  }
82
83
 
83
- // V3: PII-entity-without-hook guard. V2 checks registered hooks for
84
+ // V3: PII-entity-without-hook gate. V2 checks registered hooks for
84
85
  // completeness; V3 catches the entity nobody registered at all — fields
85
86
  // 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.
87
+ // Art.15/20 export and Art.17 forget pipeline. Hard boot failure once
88
+ // user-data-rights is mounted: a subject-data entity that skips the pipeline
89
+ // is exactly the "feature built past GDPR" leak this gate exists to stop.
90
+ // Matching is by entity name across all features (usage.entityName is
91
+ // unqualified); a same-named entity in another feature can mask a gap —
92
+ // accepted, as the common case is a distinct entity name.
89
93
  export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
90
94
  const featureNames = new Set(features.map((f) => f.name));
91
95
  if (!featureNames.has("user-data-rights")) {
@@ -110,9 +114,8 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
110
114
  })
111
115
  .map(([name]) => name);
112
116
  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.`,
117
+ throw new Error(
118
+ `[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. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
116
119
  );
117
120
  }
118
121
  }