@cosmicdrift/kumiko-framework 0.215.6 → 0.216.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.215.6",
3
+ "version": "0.216.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>",
@@ -194,7 +194,7 @@
194
194
  "./package.json": "./package.json"
195
195
  },
196
196
  "dependencies": {
197
- "@cosmicdrift/kumiko-types": "0.215.6",
197
+ "@cosmicdrift/kumiko-types": "0.216.0",
198
198
  "bullmq": "^5.76.7",
199
199
  "bun-types": "^1.3.13",
200
200
  "hono": "^4.13.1",
@@ -210,7 +210,7 @@
210
210
  "zod": "^4.4.3"
211
211
  },
212
212
  "devDependencies": {
213
- "@cosmicdrift/kumiko-dispatcher-live": "0.215.6",
213
+ "@cosmicdrift/kumiko-dispatcher-live": "0.216.0",
214
214
  "bun-types": "^1.3.13",
215
215
  "pino-pretty": "^13.1.3"
216
216
  },
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { findForbiddenRoleAssignment } from "../role-assignment";
3
+
4
+ describe("role assignment guard", () => {
5
+ test("rejects roles above the actor's highest role", () => {
6
+ expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"])).toBe("TenantAdmin");
7
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"])).toBe("SystemAdmin");
8
+ });
9
+
10
+ test("allows equal or lower roles, including self-updates", () => {
11
+ expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"])).toBeUndefined();
12
+ expect(findForbiddenRoleAssignment(["Admin"], ["Editor"])).toBeUndefined();
13
+ expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"])).toBeUndefined();
14
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"])).toBeUndefined();
15
+ expect(findForbiddenRoleAssignment(["Admin"], ["Admin"])).toBeUndefined();
16
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"])).toBeUndefined();
17
+ expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"])).toBeUndefined();
18
+ });
19
+
20
+ test("rejects Editor above User, Admin above Editor", () => {
21
+ expect(findForbiddenRoleAssignment(["User"], ["Editor"])).toBe("Editor");
22
+ expect(findForbiddenRoleAssignment(["Editor"], ["Admin"])).toBe("Admin");
23
+ });
24
+
25
+ test("rejects unknown roles (fail-closed)", () => {
26
+ expect(findForbiddenRoleAssignment(["User"], ["Custom"])).toBe("Custom");
27
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"])).toBe("Custom");
28
+ });
29
+
30
+ test("rejects assignment if actor has no roles or only unknown roles", () => {
31
+ expect(findForbiddenRoleAssignment([], ["User"])).toBe("User");
32
+ expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"])).toBe("User");
33
+ });
34
+
35
+ test("rejects modifying target user with higher existing role", () => {
36
+ expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["SystemAdmin"])).toBe("SystemAdmin");
37
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["TenantAdmin"])).toBeUndefined();
38
+ });
39
+
40
+ test("allows modifying target that currently holds an unranked app role", () => {
41
+ // Editor is ranked (invite options); use a true app-defined role here.
42
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["Billing"])).toBeUndefined();
43
+ expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["Billing", "User"])).toBeUndefined();
44
+ // Still cannot assign the unranked role on the write path (fail-closed).
45
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], ["Billing"])).toBe("Billing");
46
+ });
47
+ });
@@ -229,6 +229,7 @@ export { readClaim } from "./read-claim";
229
229
  export { createRegistry } from "./registry";
230
230
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param";
231
231
  export { resolveConfigOrParam } from "./resolve-config-or-param";
232
+ export { findForbiddenRoleAssignment } from "./role-assignment";
232
233
  export { runsInLane } from "./run-in";
233
234
  export type { StepListOutcome } from "./run-pipeline";
234
235
  export { runPipeline, runStepList } from "./run-pipeline";
@@ -0,0 +1,45 @@
1
+ const ROLE_RANKS: Readonly<Record<string, number>> = {
2
+ User: 0,
3
+ // Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
4
+ Editor: 1,
5
+ Admin: 2,
6
+ TenantAdmin: 3,
7
+ SystemAdmin: 4,
8
+ system: 5,
9
+ };
10
+
11
+ function getRoleRank(role: string): number {
12
+ return ROLE_RANKS[role] ?? Number.POSITIVE_INFINITY;
13
+ }
14
+
15
+ function maxRoleRank(roles: readonly string[]): number {
16
+ if (roles.length === 0) return -1;
17
+ return Math.max(...roles.map((role) => ROLE_RANKS[role] ?? -1));
18
+ }
19
+
20
+ /** Known built-in rank only — unranked app roles (Billing, …) are not privilege tiers. */
21
+ function knownRoleRank(role: string): number | undefined {
22
+ return ROLE_RANKS[role];
23
+ }
24
+
25
+ export function findForbiddenRoleAssignment(
26
+ actorRoles: readonly string[],
27
+ assignedRoles: readonly string[],
28
+ targetCurrentRoles: readonly string[] = [],
29
+ ): string | undefined {
30
+ const actorRank = maxRoleRank(actorRoles);
31
+ // Assign path: fail-closed on unknown / above-actor roles.
32
+ const forbiddenAssigned = assignedRoles.find((role) => getRoleRank(role) > actorRank);
33
+ if (forbiddenAssigned) return forbiddenAssigned;
34
+
35
+ // Target path: only ranked roles above the actor block (can't touch a
36
+ // SystemAdmin). Unranked app roles must not block demotion/updates — invite
37
+ // can assign them; treating them as rank ∞ on the target freezes those members.
38
+ const forbiddenTarget = targetCurrentRoles.find((role) => {
39
+ const rank = knownRoleRank(role);
40
+ return rank !== undefined && rank > actorRank;
41
+ });
42
+ if (forbiddenTarget) return forbiddenTarget;
43
+
44
+ return undefined;
45
+ }
@@ -0,0 +1,126 @@
1
+ // Pins createRequestHelper against a real setupTestStack (buildServer + JWT),
2
+ // not the mock-Hono unit suite. Covers success, structured errors, batch, and
3
+ // extra-header forwarding — the paths every integration test relies on via
4
+ // stack.http.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { defineFeature } from "../../engine";
9
+ import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
10
+ import { setupTestStack, type TestStack } from "../test-stack";
11
+ import { TestUsers } from "../test-users";
12
+
13
+ let stack: TestStack;
14
+
15
+ const pingFeature = defineFeature("reqhelp", (r) => {
16
+ r.writeHandler(
17
+ "echo",
18
+ z.object({ note: z.string().min(1) }),
19
+ async (event) => ({
20
+ isSuccess: true as const,
21
+ data: { note: event.payload.note, userId: event.user.id },
22
+ }),
23
+ { access: { openToAll: true } },
24
+ );
25
+
26
+ r.writeHandler(
27
+ "boom",
28
+ z.object({}),
29
+ async () =>
30
+ writeFailure(new UnprocessableError("reqhelp-boom", { i18nKey: "errors.unprocessable" })),
31
+ { access: { openToAll: true } },
32
+ );
33
+
34
+ r.queryHandler(
35
+ "lookup",
36
+ z.object({ id: z.string().min(1) }),
37
+ async (query) => {
38
+ if (query.payload.id === "missing") {
39
+ throw new NotFoundError("thing", query.payload.id, { i18nKey: "errors.notFound" });
40
+ }
41
+ return { id: query.payload.id, ok: true };
42
+ },
43
+ { access: { openToAll: true } },
44
+ );
45
+ });
46
+
47
+ beforeAll(async () => {
48
+ stack = await setupTestStack({ features: [pingFeature] });
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await stack.cleanup();
53
+ });
54
+
55
+ describe("createRequestHelper via setupTestStack.http", () => {
56
+ test("writeOk posts to /api/write and returns handler data", async () => {
57
+ const data = await stack.http.writeOk<{ note: string; userId: string }>(
58
+ "reqhelp:write:echo",
59
+ { note: "hello" },
60
+ TestUsers.admin,
61
+ );
62
+ expect(data).toEqual({ note: "hello", userId: TestUsers.admin.id });
63
+ });
64
+
65
+ test("queryOk posts to /api/query and returns handler data", async () => {
66
+ const data = await stack.http.queryOk<{ id: string; ok: boolean }>(
67
+ "reqhelp:query:lookup",
68
+ { id: "abc" },
69
+ TestUsers.admin,
70
+ );
71
+ expect(data).toEqual({ id: "abc", ok: true });
72
+ });
73
+
74
+ test("writeErr returns structured WriteErrorInfo with httpStatus", async () => {
75
+ const err = await stack.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
76
+ expect(err.code).toBe("unprocessable");
77
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
78
+ expect(err.i18nKey).toBe("errors.unprocessable");
79
+ });
80
+
81
+ test("queryErr returns structured WriteErrorInfo for not-found", async () => {
82
+ const err = await stack.http.queryErr(
83
+ "reqhelp:query:lookup",
84
+ { id: "missing" },
85
+ TestUsers.admin,
86
+ );
87
+ expect(err.code).toBe("not_found");
88
+ expect(err.httpStatus).toBe(404);
89
+ });
90
+
91
+ test("writeOk throws when the write fails (so suites cannot ignore failures)", async () => {
92
+ await expect(stack.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
93
+ /reqhelp:write:boom/,
94
+ );
95
+ });
96
+
97
+ test("batch posts commands and returns per-command results", async () => {
98
+ const res = await stack.http.batch(
99
+ [
100
+ { type: "reqhelp:write:echo", payload: { note: "a" } },
101
+ { type: "reqhelp:write:echo", payload: { note: "b" } },
102
+ ],
103
+ TestUsers.admin,
104
+ );
105
+ expect(res.ok).toBe(true);
106
+ const body = (await res.json()) as {
107
+ isSuccess?: boolean;
108
+ results?: readonly { isSuccess?: boolean; data?: { note?: string } }[];
109
+ };
110
+ expect(body.isSuccess).toBe(true);
111
+ expect(body.results?.map((r) => r.data?.note)).toEqual(["a", "b"]);
112
+ });
113
+
114
+ test("writeWithHeaders forwards extra headers alongside auth", async () => {
115
+ const res = await stack.http.writeWithHeaders(
116
+ "reqhelp:write:echo",
117
+ { note: "hdr" },
118
+ TestUsers.admin,
119
+ { "X-Correlation-ID": "corr-42" },
120
+ );
121
+ expect(res.ok).toBe(true);
122
+ const body = (await res.json()) as { isSuccess?: boolean; data?: { note?: string } };
123
+ expect(body.isSuccess).toBe(true);
124
+ expect(body.data?.note).toBe("hdr");
125
+ });
126
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import type { JwtHelper } from "../../api/jwt";
4
+ import type { SessionUser } from "../../engine/types";
5
+ import { createRequestHelper } from "../request-helper";
6
+
7
+ const user: SessionUser = {
8
+ id: "u1",
9
+ tenantId: "t1",
10
+ roles: ["member"],
11
+ };
12
+
13
+ function jwtStub() {
14
+ const signed: unknown[] = [];
15
+ const jwt = {
16
+ sign: async (payload: unknown) => {
17
+ signed.push(payload);
18
+ return `j.${Buffer.from(JSON.stringify(payload)).toString("base64url")}`;
19
+ },
20
+ } as unknown as JwtHelper;
21
+ return { jwt, signed };
22
+ }
23
+
24
+ function appRecording(
25
+ handler: (c: {
26
+ req: { json: () => Promise<unknown>; header: (n: string) => string | undefined };
27
+ }) => Response | Promise<Response>,
28
+ ) {
29
+ const calls: { path: string; auth?: string; extraHeaders: Record<string, string | undefined> }[] =
30
+ [];
31
+ const app = new Hono();
32
+ for (const path of ["/api/write", "/api/query", "/api/command", "/api/batch"]) {
33
+ app.post(path, async (c) => {
34
+ calls.push({
35
+ path,
36
+ auth: c.req.header("authorization"),
37
+ extraHeaders: { "x-trace": c.req.header("x-trace") },
38
+ });
39
+ return handler({ req: { json: () => c.req.json(), header: (n) => c.req.header(n) } });
40
+ });
41
+ }
42
+ return { app, calls };
43
+ }
44
+
45
+ describe("createRequestHelper", () => {
46
+ test("writeOk posts JSON to /api/write and resolves the success data", async () => {
47
+ const { app, calls } = appRecording(() =>
48
+ Response.json({ isSuccess: true, data: { written: true } }),
49
+ );
50
+ const { jwt } = jwtStub();
51
+ const http = createRequestHelper(app, jwt);
52
+
53
+ const data = await http.writeOk("todo.create", { title: "x" }, user);
54
+ expect(data).toEqual({ written: true });
55
+ expect(calls).toHaveLength(1);
56
+ expect(calls[0]!.path).toBe("/api/write");
57
+ expect(calls[0]!.auth).toMatch(/^Bearer j\./);
58
+ });
59
+
60
+ test("sessionCreator is consulted once per user and its sid reaches the jwt", async () => {
61
+ let creations = 0;
62
+ const { app } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
63
+ const { jwt, signed } = jwtStub();
64
+ const http = createRequestHelper(app, jwt, {
65
+ sessionCreator: async () => {
66
+ creations += 1;
67
+ return "sid-42";
68
+ },
69
+ });
70
+
71
+ await http.writeOk("a.b", {}, user);
72
+ await http.queryOk("c.d", {}, user);
73
+ expect(creations).toBe(1);
74
+ expect(signed).toHaveLength(2);
75
+ expect(signed[0]).toMatchObject({ id: "u1", tenantId: "t1", sid: "sid-42" });
76
+ });
77
+
78
+ test("user-provided sid skips the sessionCreator entirely", async () => {
79
+ const { app } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
80
+ const { jwt, signed } = jwtStub();
81
+ let creations = 0;
82
+ const http = createRequestHelper(app, jwt, {
83
+ sessionCreator: async () => {
84
+ creations += 1;
85
+ throw new Error("must not be called");
86
+ },
87
+ });
88
+ await http.writeOk("a.b", {}, { ...user, sid: "pre-set" });
89
+ expect(creations).toBe(0);
90
+ expect(signed[0]).toMatchObject({ sid: "pre-set" });
91
+ });
92
+
93
+ test("writeOk surfaces the formatted failure including cause details", async () => {
94
+ const { app } = appRecording(() =>
95
+ Response.json(
96
+ {
97
+ error: {
98
+ code: "internal_error",
99
+ details: { causeName: "DbError", causeMessage: "connection lost" },
100
+ },
101
+ },
102
+ { status: 500 },
103
+ ),
104
+ );
105
+ const http = createRequestHelper(app, jwtStub().jwt);
106
+ expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
107
+ 'Expected write "todo.create" to succeed but got error: internal_error (DbError: connection lost)',
108
+ );
109
+ });
110
+
111
+ test("queryErr returns the wire error plus http status, but throws on success", async () => {
112
+ const failing = appRecording(() =>
113
+ Response.json({ error: { code: "validation_failed", message: "nope" } }, { status: 400 }),
114
+ );
115
+ const http = createRequestHelper(failing.app, jwtStub().jwt);
116
+ const err = await http.queryErr("q.type", {}, user);
117
+ expect(err.code).toBe("validation_failed");
118
+ expect(err.httpStatus).toBe(400);
119
+
120
+ const succeeding = appRecording(() => Response.json({ isSuccess: true, data: {} }));
121
+ const http2 = createRequestHelper(succeeding.app, jwtStub().jwt);
122
+ expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
123
+ 'Expected query "q.type" to fail but it succeeded',
124
+ );
125
+ });
126
+
127
+ test("batch posts the commands array unchanged to /api/batch", async () => {
128
+ const bodies: unknown[] = [];
129
+ const app = new Hono();
130
+ app.post("/api/batch", async (c) => {
131
+ bodies.push(await c.req.json());
132
+ return Response.json({ isSuccess: true, data: [] });
133
+ });
134
+ const http = createRequestHelper(app, jwtStub().jwt);
135
+ await http.batch(
136
+ [
137
+ { type: "a", payload: { x: 1 } },
138
+ { type: "b", payload: {} },
139
+ ],
140
+ user,
141
+ "req-9",
142
+ );
143
+ expect(bodies[0]).toEqual({
144
+ commands: [
145
+ { type: "a", payload: { x: 1 } },
146
+ { type: "b", payload: {} },
147
+ ],
148
+ requestId: "req-9",
149
+ });
150
+ });
151
+
152
+ test("extra headers are forwarded alongside the auth header", async () => {
153
+ const { app, calls } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
154
+ const http = createRequestHelper(app, jwtStub().jwt);
155
+ await http.writeWithHeaders("a.b", {}, user, { "x-trace": "tr-1" });
156
+ expect(calls[0]!.extraHeaders["x-trace"]).toBe("tr-1");
157
+ expect(calls[0]!.auth).toMatch(/^Bearer /);
158
+ });
159
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildMultipartBody } from "../multipart-helper";
3
+
4
+ describe("buildMultipartBody", () => {
5
+ test("serializes text fields so a Response can parse them back", async () => {
6
+ const fd = new FormData();
7
+ fd.set("title", "hello world");
8
+ fd.set("count", "42");
9
+
10
+ const { body, contentType } = await buildMultipartBody(fd);
11
+ expect(contentType).toMatch(/^multipart\/form-data; boundary=kumikoBnd/i);
12
+
13
+ const parsed = await new Response(body, {
14
+ headers: { "content-type": contentType },
15
+ }).formData();
16
+ expect(parsed.get("title")).toBe("hello world");
17
+ expect(parsed.get("count")).toBe("42");
18
+ });
19
+
20
+ test("carries file name and bytes through", async () => {
21
+ const fd = new FormData();
22
+ fd.set("doc", new File(["PDF-bytes"], "report.pdf", { type: "application/pdf" }));
23
+
24
+ const { body, contentType } = await buildMultipartBody(fd);
25
+ const text = typeof body === "string" ? body : await new Response(body).text();
26
+
27
+ expect(contentType).toMatch(/^multipart\/form-data; boundary=kumikoBnd/i);
28
+ expect(text).toContain('filename="report.pdf"');
29
+ expect(text).toContain("PDF-bytes");
30
+ expect(text.endsWith(`--${contentType.split("boundary=")[1]}--\r\n`)).toBe(true);
31
+ });
32
+
33
+ test("mixed fields and files round-trip together", async () => {
34
+ const fd = new FormData();
35
+ fd.set("note", "attached");
36
+ fd.set("file", new File(["abc"], "a.txt"));
37
+
38
+ const { body, contentType } = await buildMultipartBody(fd);
39
+ const parsed = await new Response(body, {
40
+ headers: { "content-type": contentType },
41
+ }).formData();
42
+ expect(parsed.get("note")).toBe("attached");
43
+ // Structural check: happy-dom and bun can expose different File realms,
44
+ // so instanceof would fail depending on file order in the same process.
45
+ const file = parsed.get("file") as { name?: string; size?: number } | null;
46
+ expect(file?.name).toBe("a.txt");
47
+ expect(file?.size).toBe(3);
48
+ });
49
+ });