@cosmicdrift/kumiko-framework 0.326.0 → 0.327.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.
Files changed (36) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
  3. package/src/api/__tests__/client-ip.test.ts +20 -0
  4. package/src/api/__tests__/origin-middleware.test.ts +65 -0
  5. package/src/api/__tests__/websocket-route.test.ts +552 -0
  6. package/src/api/auth-middleware.ts +29 -7
  7. package/src/api/client-ip.ts +15 -2
  8. package/src/api/index.ts +24 -1
  9. package/src/api/jwt.ts +5 -0
  10. package/src/api/origin-middleware.ts +27 -1
  11. package/src/api/server.ts +161 -2
  12. package/src/api/websocket-route.ts +327 -0
  13. package/src/bun-db/query.ts +7 -5
  14. package/src/changes.json +18 -0
  15. package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
  16. package/src/crypto/event-pii.ts +20 -4
  17. package/src/crypto/system-event-pii.ts +57 -0
  18. package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
  19. package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
  20. package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
  21. package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
  22. package/src/engine/define-feature.ts +1 -0
  23. package/src/engine/feature-builder-state.ts +3 -0
  24. package/src/engine/feature-ui-extensions.ts +52 -0
  25. package/src/engine/index.ts +5 -0
  26. package/src/engine/steps/describe-workflow-step-error.ts +6 -0
  27. package/src/engine/steps/mail-send.ts +5 -1
  28. package/src/engine/steps/retry.ts +13 -1
  29. package/src/engine/steps/webhook-send.ts +6 -16
  30. package/src/engine/types/index.ts +7 -0
  31. package/src/event-store/provenance-append.ts +1 -1
  32. package/src/event-store/transfer.ts +2 -2
  33. package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
  34. package/src/jobs/job-runner.ts +18 -0
  35. package/src/pipeline/append-event-core.ts +1 -8
  36. package/src/pipeline/irreversible-operation-gate.ts +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.326.0",
3
+ "version": "0.327.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>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.326.0",
206
- "@cosmicdrift/kumiko-types": "0.326.0",
205
+ "@cosmicdrift/kumiko-http": "0.327.0",
206
+ "@cosmicdrift/kumiko-types": "0.327.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.326.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.327.0",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
@@ -5,7 +5,13 @@
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import { Hono } from "hono";
7
7
  import { TestUsers } from "../../stack";
8
- import { AUTH_COOKIE_NAME, authMiddleware, getAuthTransport, getUser } from "../auth-middleware";
8
+ import {
9
+ AUTH_COOKIE_NAME,
10
+ authMiddleware,
11
+ getAuthTokenExpiry,
12
+ getAuthTransport,
13
+ getUser,
14
+ } from "../auth-middleware";
9
15
  import { createJwtHelper } from "../jwt";
10
16
 
11
17
  const JWT_SECRET = "auth-middleware-transport-test-secret-min-32-chars";
@@ -78,3 +84,19 @@ describe("auth-middleware transport selection", () => {
78
84
  expect(body.error.code).toBe("invalid_token");
79
85
  });
80
86
  });
87
+
88
+ describe("auth-middleware token expiry", () => {
89
+ test("exposes the JWT exp (epoch seconds) to downstream handlers", async () => {
90
+ const ttlSeconds = 600;
91
+ const jwt = createJwtHelper(JWT_SECRET, "kumiko", ttlSeconds);
92
+ const token = await jwt.sign(TestUsers.user);
93
+ const app = new Hono();
94
+ app.use("/api/*", authMiddleware(jwt));
95
+ app.get("/api/exp", (c) => c.json({ exp: getAuthTokenExpiry(c) }));
96
+ const beforeSec = Math.floor(Date.now() / 1000);
97
+ const res = await app.request("/api/exp", { headers: { Authorization: `Bearer ${token}` } });
98
+ const { exp } = (await res.json()) as { exp: number };
99
+ expect(exp).toBeGreaterThanOrEqual(beforeSec + ttlSeconds - 5);
100
+ expect(exp).toBeLessThanOrEqual(beforeSec + ttlSeconds + 5);
101
+ });
102
+ });
@@ -9,9 +9,29 @@ import {
9
9
  assertValidTrustedProxyHops,
10
10
  type ClientIpHeaderSource,
11
11
  createClientIpResolver,
12
+ extractSocketAddress,
12
13
  parseTrustedProxyHopsEnv,
13
14
  } from "../client-ip";
14
15
 
16
+ describe("extractSocketAddress", () => {
17
+ test("accepts the bare string shape", () => {
18
+ expect(extractSocketAddress("10.0.0.1")).toBe("10.0.0.1");
19
+ });
20
+
21
+ test("accepts the object shape used by WebSocket-upgrade requests", () => {
22
+ expect(extractSocketAddress({ socketAddress: "10.0.0.2", server: {} })).toBe("10.0.0.2");
23
+ });
24
+
25
+ test("returns undefined for empty, missing or wrongly typed values", () => {
26
+ expect(extractSocketAddress("")).toBeUndefined();
27
+ expect(extractSocketAddress({ socketAddress: "" })).toBeUndefined();
28
+ expect(extractSocketAddress({ socketAddress: 42 })).toBeUndefined();
29
+ expect(extractSocketAddress({})).toBeUndefined();
30
+ expect(extractSocketAddress(undefined)).toBeUndefined();
31
+ expect(extractSocketAddress(null)).toBeUndefined();
32
+ });
33
+ });
34
+
15
35
  function sourceOf(headers: Record<string, string>, socketAddress?: string): ClientIpHeaderSource {
16
36
  return {
17
37
  header: (name) => headers[name],
@@ -12,6 +12,7 @@ import { createJwtHelper } from "../jwt";
12
12
  import {
13
13
  assertOriginGuardConfig,
14
14
  isOriginAllowed,
15
+ isWebSocketOriginAllowed,
15
16
  normalizeOrigin,
16
17
  originMiddleware,
17
18
  } from "../origin-middleware";
@@ -212,3 +213,67 @@ describe("originMiddleware", () => {
212
213
  expect(await readErrorCode(res)).toBe("origin_not_allowed");
213
214
  });
214
215
  });
216
+
217
+ describe("isWebSocketOriginAllowed", () => {
218
+ const HOST = "app.example.eu";
219
+ const SAME_HOST_ORIGIN = `https://${HOST}`;
220
+ const allowlist: ReadonlySet<string> = new Set([normalizeOrigin(ALLOWED)]);
221
+
222
+ // Probe route: reports the predicate's verdict for the request it received.
223
+ async function verdict(
224
+ headers: Record<string, string>,
225
+ list: ReadonlySet<string> | undefined,
226
+ transport: "cookie" | "bearer" = "cookie",
227
+ ): Promise<boolean> {
228
+ const jwt = createJwtHelper(JWT_SECRET);
229
+ const token = await jwt.sign(TestUsers.user);
230
+ const app = new Hono();
231
+ app.use("/api/*", authMiddleware(jwt));
232
+ app.get("/api/ws/probe", (c) => c.json({ allowed: isWebSocketOriginAllowed(c, list) }));
233
+ const auth: Record<string, string> =
234
+ transport === "cookie"
235
+ ? { Cookie: `${AUTH_COOKIE_NAME}=${token}` }
236
+ : { Authorization: `Bearer ${token}` };
237
+ const res = await app.request("/api/ws/probe", {
238
+ headers: { Host: HOST, ...auth, ...headers },
239
+ });
240
+ const body: unknown = await res.json();
241
+ if (typeof body !== "object" || body === null || !("allowed" in body)) {
242
+ throw new Error(`unexpected probe body ${JSON.stringify(body)}`);
243
+ }
244
+ return body.allowed === true;
245
+ }
246
+
247
+ test("bearer transport is always allowed, even with a foreign Origin", async () => {
248
+ expect(await verdict({ Origin: DISALLOWED }, allowlist, "bearer")).toBe(true);
249
+ expect(await verdict({}, undefined, "bearer")).toBe(true);
250
+ });
251
+
252
+ test("cookie without Origin header is rejected", async () => {
253
+ expect(await verdict({}, undefined)).toBe(false);
254
+ expect(await verdict({}, allowlist)).toBe(false);
255
+ });
256
+
257
+ test("Origin 'null' and unparsable origins are rejected", async () => {
258
+ expect(await verdict({ Origin: "null" }, undefined)).toBe(false);
259
+ expect(await verdict({ Origin: "not a url" }, undefined)).toBe(false);
260
+ });
261
+
262
+ test("non-empty allowlist: listed origin passes, case/trailing slash normalized", async () => {
263
+ expect(await verdict({ Origin: ALLOWED }, allowlist)).toBe(true);
264
+ expect(await verdict({ Origin: `${ALLOWED.toUpperCase()}/` }, allowlist)).toBe(true);
265
+ expect(await verdict({ Origin: DISALLOWED }, allowlist)).toBe(false);
266
+ });
267
+
268
+ test("non-empty allowlist has no same-host fallback", async () => {
269
+ expect(await verdict({ Origin: SAME_HOST_ORIGIN }, allowlist)).toBe(false);
270
+ });
271
+
272
+ test("empty or absent allowlist: same host passes (case-insensitive), other host fails", async () => {
273
+ expect(await verdict({ Origin: SAME_HOST_ORIGIN }, undefined)).toBe(true);
274
+ expect(await verdict({ Origin: SAME_HOST_ORIGIN }, new Set())).toBe(true);
275
+ expect(await verdict({ Origin: `https://${HOST.toUpperCase()}` }, undefined)).toBe(true);
276
+ expect(await verdict({ Origin: DISALLOWED }, undefined)).toBe(false);
277
+ expect(await verdict({ Origin: `https://${HOST}:8443` }, undefined)).toBe(false);
278
+ });
279
+ });