@cosmicdrift/kumiko-framework 0.114.0 → 0.115.1

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.114.0",
3
+ "version": "0.115.1",
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.114.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.115.1",
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 { validateGdprHookCompleteness, validateGdprStoragePersistence } from "./gdpr-storage";
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