@cosmicdrift/kumiko-framework 0.82.0 → 0.83.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.
|
|
3
|
+
"version": "0.83.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.83.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
|
}
|
|
@@ -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
|
/**
|