@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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 →
|
|
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(
|
|
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
|
|
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 →
|
|
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
|
|
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("
|
|
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 →
|
|
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(
|
|
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
|
|
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.
|
|
66
|
-
//
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
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
|
}
|