@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.82.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.82.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, statusOverride?: number) {
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
- const status = (statusOverride ?? err.httpStatus) as ContentfulStatusCode; // @cast-boundary engine-payload
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, statusOverride?: number) {
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
- const status = (statusOverride ?? err.httpStatus) as ContentfulStatusCode; // @cast-boundary engine-payload
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 { 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)");
@@ -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
  /**
@@ -80,6 +80,7 @@ export {
80
80
  EXT_USER_DATA_ORDER,
81
81
  } from "./extension-names";
82
82
  export type {
83
+ TenantUserModel,
83
84
  UserDataDeleteHook,
84
85
  UserDataDeleteStrategy,
85
86
  UserDataExportHook,