@cosmicdrift/kumiko-framework 0.82.0 → 0.84.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 +2 -2
- package/src/api/__tests__/server-error-logging.test.ts +87 -0
- package/src/api/routes.ts +36 -10
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +69 -1
- package/src/engine/__tests__/build-config-feature-schema.test.ts +1 -2
- package/src/engine/boot-validator/gdpr-storage.ts +20 -0
- package/src/engine/boot-validator/index.ts +2 -1
- package/src/engine/extensions/user-data.ts +20 -0
- package/src/engine/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.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.84.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
4
|
+
import { TestUsers } from "../../stack";
|
|
5
|
+
import { ensureTemporalPolyfill } from "../../time";
|
|
6
|
+
import { buildServer } from "../server";
|
|
7
|
+
|
|
8
|
+
// Self-ensure Temporal rather than rely on the suite-level preload: the check
|
|
9
|
+
// runs `bun test` from packages/framework where the root preload path doesn't
|
|
10
|
+
// resolve, so buildHandlerContext would otherwise throw before our handler runs
|
|
11
|
+
// and the logged cause would be the polyfill error, not the thrown one.
|
|
12
|
+
await ensureTemporalPolyfill();
|
|
13
|
+
|
|
14
|
+
const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
|
|
15
|
+
|
|
16
|
+
const boomFeature = defineFeature("boom", (r) => {
|
|
17
|
+
r.queryHandler(
|
|
18
|
+
"explode",
|
|
19
|
+
z.object({}),
|
|
20
|
+
async () => {
|
|
21
|
+
throw new Error("disk on fire");
|
|
22
|
+
},
|
|
23
|
+
{ access: { openToAll: true } },
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const { app, jwt } = buildServer({
|
|
28
|
+
registry: createRegistry([boomFeature]),
|
|
29
|
+
context: {},
|
|
30
|
+
jwtSecret: JWT_SECRET,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function auth(): Promise<Record<string, string>> {
|
|
34
|
+
const token = await jwt.sign(TestUsers.admin);
|
|
35
|
+
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Find the `[api] handler failed` line our logServerFault emits (createFallback
|
|
39
|
+
// Logger console-falls-back to `console.error("[api] handler failed", data)`).
|
|
40
|
+
// Match on the namespaced message so unrelated console noise can't false-fire.
|
|
41
|
+
function apiFaultLog(calls: unknown[][]): string | undefined {
|
|
42
|
+
const hit = calls.find(
|
|
43
|
+
(args) => typeof args[0] === "string" && args[0].includes("[api] handler failed"),
|
|
44
|
+
);
|
|
45
|
+
return hit ? JSON.stringify(hit) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("HTTP layer logs unexpected 5xx faults", () => {
|
|
49
|
+
test("a throwing query 500s AND the cause stack reaches the log", async () => {
|
|
50
|
+
const calls: unknown[][] = [];
|
|
51
|
+
const spy = spyOn(console, "error").mockImplementation((...args) => {
|
|
52
|
+
calls.push(args);
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const res = await app.request("/api/query", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: await auth(),
|
|
58
|
+
body: JSON.stringify({ type: "boom:query:explode", payload: {} }),
|
|
59
|
+
});
|
|
60
|
+
expect(res.status).toBe(500);
|
|
61
|
+
const logged = apiFaultLog(calls);
|
|
62
|
+
expect(logged).toBeDefined();
|
|
63
|
+
expect(logged).toContain("boom:query:explode"); // which handler 500'd
|
|
64
|
+
expect(logged).toContain("disk on fire"); // the cause — the line that was missing in prod
|
|
65
|
+
} finally {
|
|
66
|
+
spy.mockRestore();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an expected 404 does NOT log a server fault (no 4xx noise)", async () => {
|
|
71
|
+
const calls: unknown[][] = [];
|
|
72
|
+
const spy = spyOn(console, "error").mockImplementation((...args) => {
|
|
73
|
+
calls.push(args);
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
const res = await app.request("/api/query", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: await auth(),
|
|
79
|
+
body: JSON.stringify({ type: "nope:query:nothing", payload: {} }),
|
|
80
|
+
});
|
|
81
|
+
expect(res.status).toBe(404);
|
|
82
|
+
expect(apiFaultLog(calls)).toBeUndefined();
|
|
83
|
+
} finally {
|
|
84
|
+
spy.mockRestore();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/api/routes.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
toKumikoError,
|
|
8
8
|
ValidationError,
|
|
9
9
|
} from "../errors";
|
|
10
|
+
import { createFallbackLogger } from "../logging";
|
|
10
11
|
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
11
12
|
import { stringifyJson } from "../utils/safe-json";
|
|
12
13
|
import { Routes } from "./api-constants";
|
|
@@ -23,11 +24,11 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
23
24
|
try {
|
|
24
25
|
const result = await dispatcher.write(body.type, body.payload, user, body.requestId);
|
|
25
26
|
if (!result.isSuccess) {
|
|
26
|
-
return writeErrorResponse(c, reraiseAsKumikoError(result.error));
|
|
27
|
+
return writeErrorResponse(c, reraiseAsKumikoError(result.error), body.type);
|
|
27
28
|
}
|
|
28
29
|
return jsonResponse(c, result);
|
|
29
30
|
} catch (e) {
|
|
30
|
-
return writeErrorResponse(c, toKumiko(e));
|
|
31
|
+
return writeErrorResponse(c, toKumiko(e), body.type);
|
|
31
32
|
}
|
|
32
33
|
});
|
|
33
34
|
|
|
@@ -62,6 +63,9 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
62
63
|
if (!result.isSuccess) {
|
|
63
64
|
const err = reraiseAsKumikoError(result.error);
|
|
64
65
|
const requestId = requestContext.get()?.requestId;
|
|
66
|
+
const failedType =
|
|
67
|
+
result.failedIndex != null ? body.commands[result.failedIndex]?.type : undefined;
|
|
68
|
+
logServerFault(err, requestId, failedType);
|
|
65
69
|
const { error } = serializeError(err, requestId);
|
|
66
70
|
// Keep failedIndex + results alongside the error envelope so callers
|
|
67
71
|
// can tell which command in the batch failed and inspect the partial
|
|
@@ -91,7 +95,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
91
95
|
const result = await dispatcher.query(body.type, body.payload, user);
|
|
92
96
|
return jsonResponse(c, { data: result });
|
|
93
97
|
} catch (e) {
|
|
94
|
-
return queryErrorResponse(c, toKumiko(e));
|
|
98
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
95
99
|
}
|
|
96
100
|
});
|
|
97
101
|
|
|
@@ -103,7 +107,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
103
107
|
await dispatcher.command(body.type, body.payload, user);
|
|
104
108
|
return c.json({ ok: true }, 202);
|
|
105
109
|
} catch (e) {
|
|
106
|
-
return queryErrorResponse(c, toKumiko(e));
|
|
110
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
107
111
|
}
|
|
108
112
|
});
|
|
109
113
|
|
|
@@ -116,21 +120,43 @@ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode =
|
|
|
116
120
|
|
|
117
121
|
const toKumiko = toKumikoError;
|
|
118
122
|
|
|
123
|
+
// Unexpected server faults (5xx) carry their diagnostic stack only on the
|
|
124
|
+
// in-process error — serializeError strips cause/details from the wire body.
|
|
125
|
+
// Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
|
|
126
|
+
// log lines, leaving ops nothing to debug (the bug this guards). 4xx are
|
|
127
|
+
// expected client outcomes and stay unlogged. `type` is the only handler
|
|
128
|
+
// discriminator — every request hits the same /api/{query,command} path.
|
|
129
|
+
function logServerFault(err: KumikoError, requestId: string | undefined, type?: string): void {
|
|
130
|
+
if (err.httpStatus < 500) {
|
|
131
|
+
// skip: 4xx are expected client outcomes (not-found, validation, denied) — logging them is noise
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const cause = err.cause;
|
|
135
|
+
createFallbackLogger("api").error("handler failed", {
|
|
136
|
+
requestId,
|
|
137
|
+
type,
|
|
138
|
+
code: err.code,
|
|
139
|
+
message: err.message,
|
|
140
|
+
cause: cause instanceof Error ? cause.message : cause,
|
|
141
|
+
stack: cause instanceof Error ? cause.stack : err.stack,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
// For /write + /batch: keep the isSuccess flag so clients can flip on a single
|
|
120
146
|
// boolean (mirrors the success shape). The actual error body is the
|
|
121
147
|
// error-contract payload nested under .error.
|
|
122
|
-
function writeErrorResponse(c: Context, err: KumikoError,
|
|
148
|
+
function writeErrorResponse(c: Context, err: KumikoError, type?: string) {
|
|
123
149
|
const requestId = requestContext.get()?.requestId;
|
|
150
|
+
logServerFault(err, requestId, type);
|
|
124
151
|
const { error } = serializeError(err, requestId);
|
|
125
|
-
|
|
126
|
-
return c.json({ isSuccess: false, error }, status);
|
|
152
|
+
return c.json({ isSuccess: false, error }, err.httpStatus as ContentfulStatusCode); // @cast-boundary engine-payload
|
|
127
153
|
}
|
|
128
154
|
|
|
129
155
|
// For /query + /command: no isSuccess on success (just { data } / {ok}), so we
|
|
130
156
|
// keep the same lean shape on failure — only the `error` key.
|
|
131
|
-
function queryErrorResponse(c: Context, err: KumikoError,
|
|
157
|
+
function queryErrorResponse(c: Context, err: KumikoError, type?: string) {
|
|
132
158
|
const requestId = requestContext.get()?.requestId;
|
|
159
|
+
logServerFault(err, requestId, type);
|
|
133
160
|
const body = serializeError(err, requestId);
|
|
134
|
-
|
|
135
|
-
return c.json(body, status);
|
|
161
|
+
return c.json(body, err.httpStatus as ContentfulStatusCode); // @cast-boundary engine-payload
|
|
136
162
|
}
|
|
@@ -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)");
|
|
@@ -34,6 +34,20 @@ type UserId = string;
|
|
|
34
34
|
*/
|
|
35
35
|
export type UserDataDeleteStrategy = "delete" | "anonymize";
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Effective tenant-occupancy model for THIS tenant during a forget run, set by
|
|
39
|
+
* the forget orchestrator. `"single-user"` means the tenant has exactly one
|
|
40
|
+
* member (the user being forgotten) — so a tenant-scoped contributor MAY erase
|
|
41
|
+
* the tenant's data as that user's personal data. `"multi-user"` (the safe
|
|
42
|
+
* default) means tenant-scoped rows are shared and must NOT be erased per-user.
|
|
43
|
+
*
|
|
44
|
+
* The orchestrator derives this from the app-level `tenantModel` config AND a
|
|
45
|
+
* runtime sole-member check, so a stray invite that makes the config's
|
|
46
|
+
* `"single-user"` claim false at runtime never causes a co-member's data to be
|
|
47
|
+
* deleted. Absent → treat as `"multi-user"`.
|
|
48
|
+
*/
|
|
49
|
+
export type TenantUserModel = "single-user" | "multi-user";
|
|
50
|
+
|
|
37
51
|
/**
|
|
38
52
|
* Context-Snapshot der dem Hook übergeben wird. Sprint 2 erweitert
|
|
39
53
|
* das ggf. um cancel-/timeout-Marker; aktuell minimaler Schnitt.
|
|
@@ -70,6 +84,12 @@ export interface UserDataHookCtx {
|
|
|
70
84
|
readonly buildStorageProvider?: (
|
|
71
85
|
tenantId: TenantId,
|
|
72
86
|
) => Promise<UserDataStorageProvider | undefined>;
|
|
87
|
+
/**
|
|
88
|
+
* Effective tenant-occupancy model for this tenant — see {@link TenantUserModel}.
|
|
89
|
+
* A tenant-scoped contributor reads this to decide whether per-user erasure may
|
|
90
|
+
* touch tenant-scoped rows. Absent → treat as `"multi-user"` (no erasure).
|
|
91
|
+
*/
|
|
92
|
+
readonly tenantModel?: TenantUserModel;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
/**
|