@cosmicdrift/kumiko-framework 0.312.0 → 0.314.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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/upgrade-cli.test.ts +28 -0
  3. package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
  4. package/src/api/auth-middleware.ts +30 -1
  5. package/src/api/auth-routes.ts +27 -2
  6. package/src/api/origin-middleware.ts +23 -20
  7. package/src/api/server.ts +10 -1
  8. package/src/changes.json +48 -0
  9. package/src/crypto/pg-kms-adapter.ts +2 -1
  10. package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
  11. package/src/db/api.ts +6 -0
  12. package/src/db/bun-provider.ts +8 -3
  13. package/src/db/connection.ts +3 -3
  14. package/src/db/eagerload.ts +2 -9
  15. package/src/db/index.ts +1 -1
  16. package/src/db/postgres-provider.ts +7 -4
  17. package/src/db/queries/event-consumer.ts +4 -3
  18. package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
  19. package/src/engine/boot-validator/transfer-graph.ts +2 -1
  20. package/src/engine/index.ts +3 -0
  21. package/src/engine/parse-ref-target.ts +6 -0
  22. package/src/engine/registry-validate.ts +3 -12
  23. package/src/engine/types/index.ts +1 -0
  24. package/src/files/__tests__/files.integration.test.ts +128 -1
  25. package/src/files/__tests__/storage-tracking.test.ts +46 -0
  26. package/src/files/file-routes.ts +10 -3
  27. package/src/files/index.ts +9 -1
  28. package/src/files/storage-tracking.ts +160 -15
  29. package/src/files/types.ts +57 -2
  30. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
  31. package/src/jobs/job-runner.ts +27 -0
  32. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
  33. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +34 -6
  34. package/src/pipeline/event-dispatcher-delivery.ts +22 -1
  35. package/src/pipeline/event-dispatcher.ts +25 -2
  36. package/src/pipeline/index.ts +4 -1
  37. package/src/pipeline/system-hooks.ts +6 -2
  38. package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
  39. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
  40. package/src/stack/drain-event-consumers.ts +74 -0
  41. package/src/stack/index.ts +1 -0
  42. package/src/stack/redis.ts +3 -0
  43. package/src/stack/test-stack.ts +13 -4
  44. package/src/upgrade-cli.ts +17 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.312.0",
3
+ "version": "0.314.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>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.312.0",
202
- "@cosmicdrift/kumiko-types": "0.312.0",
201
+ "@cosmicdrift/kumiko-http": "0.314.0",
202
+ "@cosmicdrift/kumiko-types": "0.314.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.312.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.314.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -564,6 +564,34 @@ describe("upgrade command — filter baseline is the marker, not the installed v
564
564
  // rather than filtered out entirely by a too-high baseline.
565
565
  expect(updatedMarker.version).toBe("0.188.0");
566
566
  expect(updatedMarker.version).not.toBe("0.195.0");
567
+ expect(spy.logs.join("\n")).toContain(
568
+ "Marker moved past the manual change(s) above to 0.188.0",
569
+ );
570
+ });
571
+
572
+ test("a manual breaking change is acknowledged by a second --apply after the first one held the marker back", async () => {
573
+ const fixEntry = { version: "0.310.0", type: "fix", title: "earlier fix" };
574
+ const manualEntry = { version: "0.311.0", type: "breaking", title: "manual breaking change" };
575
+ const cwd = tmp({
576
+ "packages/framework/src/changes.json": JSON.stringify([fixEntry, manualEntry]),
577
+ "packages/bundled-features/package.json": JSON.stringify({ version: "0.311.0" }),
578
+ ".kumiko/upgrade-state.json": marker("0.309.0"),
579
+ });
580
+ const markerPath = join(cwd, ".kumiko/upgrade-state.json");
581
+
582
+ const firstRun = makeSpyOutput();
583
+ expect(await runUpgradeCli(["--apply"], cwd, firstRun.out, { repoRoot: REAL_REPO_ROOT })).toBe(
584
+ 0,
585
+ );
586
+ expect(JSON.parse(readFileSync(markerPath, "utf-8")).version).toBe("0.310.0");
587
+ expect(firstRun.logs.join("\n")).toContain("Marker stays at 0.310.0");
588
+
589
+ const secondRun = makeSpyOutput();
590
+ expect(await runUpgradeCli(["--apply"], cwd, secondRun.out, { repoRoot: REAL_REPO_ROOT })).toBe(
591
+ 0,
592
+ );
593
+ expect(secondRun.logs.join("\n")).toContain("no codemod, manual migration required");
594
+ expect(JSON.parse(readFileSync(markerPath, "utf-8")).version).toBe("0.311.0");
567
595
  });
568
596
 
569
597
  test("bare --apply with no marker (bootstrap): behaves as before, marker is written at the installed version", async () => {
@@ -9,8 +9,10 @@
9
9
  // rejected them (guards return 403).
10
10
 
11
11
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import * as z from "zod";
13
+ import { defineFeature } from "../../engine";
12
14
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
13
- import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "../auth-middleware";
15
+ import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getUser } from "../auth-middleware";
14
16
 
15
17
  const ALLOWED = "https://admin.example.eu";
16
18
  const DISALLOWED = "https://tenant.example.eu";
@@ -130,3 +132,142 @@ describe("origin-middleware opt-out (unsafeSkipOriginCheck)", () => {
130
132
  expect(await errorCode(res)).toBe("csrf_token_mismatch");
131
133
  });
132
134
  });
135
+
136
+ // The "without anonymousAccess" case (disallowed origin → 403) is already
137
+ // covered above by the first describe block's server config, minus
138
+ // anonymousAccess — not repeated here.
139
+ const anonEchoQuery = { type: "origin-anon-probe:query:anon-echo", payload: {} };
140
+ const userOnlyRoute = { type: "origin-anon-probe:write:user-only", payload: {} };
141
+ const probeFeature = defineFeature("origin-anon-probe", (r) => {
142
+ r.queryHandler({
143
+ name: "anon-echo",
144
+ schema: z.object({}),
145
+ access: { roles: ["anonymous"] },
146
+ handler: async (event) => ({ userId: event.user.id, roles: event.user.roles }),
147
+ });
148
+ r.writeHandler({
149
+ name: "user-only",
150
+ schema: z.object({}),
151
+ access: { roles: ["User"] },
152
+ handler: async (event) => ({
153
+ isSuccess: true as const,
154
+ data: { userId: event.user.id, roles: event.user.roles },
155
+ }),
156
+ });
157
+ // anonymous:false httpRoutes can't live under /api/* — the boot validator
158
+ // (feature-ui-extensions.ts) hard-blocks that path prefix for every
159
+ // feature route, so this is mounted outside /api/* and guarded by
160
+ // sessionOnlyHttpRouteGuards (sessionOnlyGuard, no foreignCookieOrigins
161
+ // downgrade) instead of the /api/* jwtGuard used above.
162
+ r.httpRoute({
163
+ method: "POST",
164
+ path: "/origin-anon-probe-http",
165
+ anonymous: false,
166
+ handler: async (c) => c.json({ id: getUser(c).id }),
167
+ });
168
+ });
169
+
170
+ describe("origin-middleware + anonymousAccess: foreign-origin cookie becomes anonymous", () => {
171
+ let stack: TestStack;
172
+ let authCookie: string;
173
+
174
+ beforeAll(async () => {
175
+ stack = await setupTestStack({
176
+ features: [probeFeature],
177
+ authConfig: {
178
+ membershipQuery: "tenant:query:memberships",
179
+ allowedOrigins: [ALLOWED],
180
+ cookieDomain: "example.eu",
181
+ },
182
+ anonymousAccess: { defaultTenantId: TestUsers.user.tenantId },
183
+ });
184
+ const token = await stack.jwt.sign(TestUsers.user);
185
+ authCookie = `${AUTH_COOKIE_NAME}=${token}`;
186
+ });
187
+
188
+ afterAll(async () => {
189
+ await stack.cleanup();
190
+ });
191
+
192
+ test("foreign origin + cookie, no CSRF token → handler runs as the anonymous user", async () => {
193
+ const res = await stack.http.raw("POST", "/api/query", anonEchoQuery, {
194
+ Cookie: authCookie,
195
+ Origin: DISALLOWED,
196
+ });
197
+ // 200 (not csrf_token_mismatch) proves CSRF wasn't required — authTransport
198
+ // stayed unset — and the echoed identity is the synthesised anonymous
199
+ // user, not the cookie's real one.
200
+ expect(res.status).toBe(200);
201
+ const body = (await res.json()) as { data: { userId: string; roles: readonly string[] } };
202
+ expect(body.data.userId).toBe("anonymous");
203
+ expect(body.data.roles).toEqual(["anonymous"]);
204
+ });
205
+
206
+ test("foreign origin + garbage cookie value → still anonymous, jwt.verify never runs", async () => {
207
+ const res = await stack.http.raw("POST", "/api/query", anonEchoQuery, {
208
+ Cookie: `${AUTH_COOKIE_NAME}=not-a-valid-jwt`,
209
+ Origin: DISALLOWED,
210
+ });
211
+ // A malformed token would 401 invalid_token if jwt.verify ran on it —
212
+ // 200 here is only possible if the cookie was dropped unread.
213
+ expect(res.status).toBe(200);
214
+ const body = (await res.json()) as { data: { userId: string } };
215
+ expect(body.data.userId).toBe("anonymous");
216
+ });
217
+
218
+ test("/api/auth/logout from a foreign origin → still 403 origin_not_allowed, not silently anonymous", async () => {
219
+ const res = await stack.http.raw(
220
+ "POST",
221
+ "/api/auth/logout",
222
+ {},
223
+ {
224
+ Cookie: authCookie,
225
+ Origin: DISALLOWED,
226
+ },
227
+ );
228
+ expect(res.status).toBe(403);
229
+ expect(await errorCode(res)).toBe("origin_not_allowed");
230
+ });
231
+
232
+ test("allowed origin + cookie, no CSRF token → real user path still requires CSRF", async () => {
233
+ const res = await stack.http.raw("POST", "/api/write", userOnlyRoute, {
234
+ Cookie: authCookie,
235
+ Origin: ALLOWED,
236
+ });
237
+ expect(res.status).toBe(403);
238
+ expect(await errorCode(res)).toBe("csrf_token_mismatch");
239
+ });
240
+
241
+ test("allowed origin + cookie + valid CSRF → authenticated as the real user, not anonymous", async () => {
242
+ const csrf = "csrf-fixed-anon-fallthrough-token";
243
+ const res = await stack.http.raw("POST", "/api/write", userOnlyRoute, {
244
+ Cookie: `${authCookie}; ${CSRF_COOKIE_NAME}=${csrf}`,
245
+ Origin: ALLOWED,
246
+ [CSRF_HEADER_NAME]: csrf,
247
+ });
248
+ expect(res.status).toBe(200);
249
+ const body = (await res.json()) as { data: { userId: string; roles: readonly string[] } };
250
+ expect(body.data.userId).toBe(TestUsers.user.id);
251
+ expect(body.data.roles).toEqual(TestUsers.user.roles);
252
+ });
253
+
254
+ test("anonymous:false r.httpRoute, foreign origin + cookie, no CSRF → 403 origin_not_allowed, sessionOnlyGuard does not downgrade to anonymous", async () => {
255
+ // /api/* would downgrade a foreign-origin cookie to anonymous (see the
256
+ // anon-echo tests above) because jwtGuard carries foreignCookieOrigins.
257
+ // r.httpRoute anonymous:false routes run behind sessionOnlyGuard instead,
258
+ // which was deliberately built WITHOUT anonymousAccess/foreignCookieOrigins
259
+ // — a real cookie must still resolve to the real user, so the Origin guard
260
+ // rejects it outright instead of silently falling through anonymous.
261
+ const res = await stack.http.raw(
262
+ "POST",
263
+ "/origin-anon-probe-http",
264
+ {},
265
+ {
266
+ Cookie: authCookie,
267
+ Origin: DISALLOWED,
268
+ },
269
+ );
270
+ expect(res.status).toBe(403);
271
+ expect(await errorCode(res)).toBe("origin_not_allowed");
272
+ });
273
+ });
@@ -6,6 +6,7 @@ import type { SessionUser, TenantId } from "../engine/types";
6
6
  import { parseTenantId } from "../engine/types/identifiers";
7
7
  import { TENANT_COOKIE_NAME, TENANT_HEADER_NAME } from "./api-constants";
8
8
  import type { JwtHelper } from "./jwt";
9
+ import { isForeignCookieOrigin } from "./origin-middleware";
9
10
 
10
11
  const USER_KEY = "pipelineUser";
11
12
  const AUTH_TRANSPORT_KEY = "authTransport";
@@ -92,6 +93,14 @@ export type AuthMiddlewareOptions = {
92
93
  // when the tenant is in teardown (destroyRequested/destroying/destroyed).
93
94
  // cancel-destruction is exempt while status=destroyRequested.
94
95
  readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
96
+ // Normalized auth.allowedOrigins. A cookie from an origin outside this set
97
+ // is dropped BEFORE jwt.verify — treated as if no cookie was sent — and
98
+ // falls through to the anonymousAccess flow instead of the 403 the
99
+ // sibling originMiddleware would give (fw#340: a public status page's
100
+ // session cookie, set on the base domain, reaches every tenant subdomain
101
+ // even though those pages only ever make anonymous queries). Only
102
+ // meaningful together with anonymousAccess.
103
+ readonly foreignCookieOrigins?: ReadonlySet<string>;
95
104
  };
96
105
 
97
106
  // Resolves the tenant for an unauthenticated request. Returns null when no
@@ -205,7 +214,13 @@ function extractToken(
205
214
  }
206
215
 
207
216
  export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
208
- const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus } = options;
217
+ const {
218
+ sessionChecker,
219
+ anonymousAccess,
220
+ tokenVerifier,
221
+ resolveTenantLifecycleStatus,
222
+ foreignCookieOrigins,
223
+ } = options;
209
224
 
210
225
  // Fail loud at boot, not silently at request time: a tenantResolver
211
226
  // without a declared resolverTrust is an ambiguous trust decision no
@@ -259,6 +274,20 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
259
274
  }
260
275
  const { token, transport } = extracted;
261
276
 
277
+ // A foreign-origin cookie is noise, not an authentication attempt worth
278
+ // verifying — drop it before jwt.verify, the token is never read. Auth
279
+ // routes are excluded (same reasoning as the missing-token branch above):
280
+ // logout must still see the real auth state, not be silently downgraded.
281
+ if (
282
+ transport === "cookie" &&
283
+ anonymousAccess &&
284
+ !c.req.path.startsWith("/api/auth/") &&
285
+ foreignCookieOrigins &&
286
+ isForeignCookieOrigin(c, foreignCookieOrigins)
287
+ ) {
288
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
289
+ }
290
+
262
291
  // Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
263
292
  // JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
264
293
  // entirely; a miss (null — no provider's shape matched, or the matching
@@ -148,6 +148,20 @@ const SignupConfirmBody = z.object({
148
148
  password: z.string().min(8).max(200),
149
149
  });
150
150
 
151
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up,
152
+ // offlot-app#454): an optional tenant-handover grant the signup-request
153
+ // route forwards to the handler for server-side verification and binding —
154
+ // the grant itself never appears anywhere else (URL, mail, log, response).
155
+ const SignupRequestBody = z.object({
156
+ email: z.email(),
157
+ handover: z
158
+ .object({
159
+ entityType: z.string().min(1).max(200),
160
+ token: z.string().min(1).max(4096),
161
+ })
162
+ .optional(),
163
+ });
164
+
151
165
  const InviteAcceptBody = z.object({
152
166
  token: z.string().min(1),
153
167
  });
@@ -1122,6 +1136,7 @@ export function createAuthRoutes(
1122
1136
  dispatcher,
1123
1137
  path: Routes.authSignupRequest,
1124
1138
  requestHandler: sg.requestHandler,
1139
+ schema: SignupRequestBody,
1125
1140
  });
1126
1141
 
1127
1142
  api.post(Routes.authSignupConfirm, async (c) => {
@@ -1149,6 +1164,7 @@ export function createAuthRoutes(
1149
1164
  kind: "auth-session";
1150
1165
  session: SessionUser;
1151
1166
  tenantKey: string;
1167
+ handover?: { entityType: string; id: string };
1152
1168
  };
1153
1169
 
1154
1170
  // Session creation + JWT sign + cookies — see mintSessionAndRespond.
@@ -1168,6 +1184,11 @@ export function createAuthRoutes(
1168
1184
  // membership — die Frontend-UI nimmt das direkt als Redirect-
1169
1185
  // Target.
1170
1186
  tenantKey: data.tenantKey,
1187
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up):
1188
+ // present only when signup-confirm actually claimed a bound grant —
1189
+ // absent, not null, so a client that doesn't know the field sees
1190
+ // nothing unusual.
1191
+ ...(data.handover !== undefined && { handover: data.handover }),
1171
1192
  });
1172
1193
  });
1173
1194
  }
@@ -1462,8 +1483,12 @@ function registerTokenRequestRoute(opts: {
1462
1483
  dispatcher: Dispatcher;
1463
1484
  path: string;
1464
1485
  requestHandler: string;
1486
+ // reset/verify/unlock keep the plain RequestTokenBody; signup passes its
1487
+ // own schema (adds an optional `handover`, see SignupRequestBody above)
1488
+ // so the extra field reaches the handler instead of being parsed away.
1489
+ schema?: typeof RequestTokenBody | typeof SignupRequestBody;
1465
1490
  }): void {
1466
- const body = RequestTokenBody;
1491
+ const body = opts.schema ?? RequestTokenBody;
1467
1492
  opts.api.post(opts.path, async (c) => {
1468
1493
  const raw = await c.req.json().catch(() => null);
1469
1494
  const parsed = body.safeParse(raw);
@@ -1478,7 +1503,7 @@ function registerTokenRequestRoute(opts: {
1478
1503
  // the log line (PII).
1479
1504
  const result = await opts.dispatcher.write(
1480
1505
  opts.requestHandler,
1481
- { email: parsed.data.email },
1506
+ parsed.data,
1482
1507
  createAnonymousUser(SYSTEM_TENANT_ID),
1483
1508
  );
1484
1509
  if (!result.isSuccess) {
@@ -16,6 +16,21 @@ export function isOriginAllowed(origin: string, normalizedAllowlist: ReadonlySet
16
16
  return normalizedAllowlist.has(normalizeOrigin(origin));
17
17
  }
18
18
 
19
+ // True when a state-changing request carries an Origin (or Sec-Fetch-Site)
20
+ // signal that marks it as coming from outside the allowlist.
21
+ export function isForeignCookieOrigin(c: Context, allowlist: ReadonlySet<string>): boolean {
22
+ if (!STATE_CHANGING_METHODS.has(c.req.method)) return false;
23
+
24
+ const origin = c.req.header("origin");
25
+ if (origin !== undefined) return !isOriginAllowed(origin, allowlist);
26
+
27
+ // No Origin header — older browsers, and some same-origin POSTs in Safari.
28
+ // Fall back to the Fetch-Metadata Sec-Fetch-Site signal: only an explicit
29
+ // cross-site marker counts as foreign here (it's a relation, not an origin,
30
+ // so it can't be matched against the allowlist).
31
+ return c.req.header("sec-fetch-site") === "cross-site";
32
+ }
33
+
19
34
  function rejectOrigin(c: Context): Response {
20
35
  return c.json(
21
36
  {
@@ -49,26 +64,14 @@ export function originMiddleware(allowedOrigins: readonly string[]) {
49
64
  return async (c: Context, next: Next) => {
50
65
  const transport = getAuthTransport(c);
51
66
  if (transport !== "cookie") return next();
52
- if (!STATE_CHANGING_METHODS.has(c.req.method)) return next();
53
-
54
- const origin = c.req.header("origin");
55
- if (origin !== undefined) {
56
- if (isOriginAllowed(origin, allowlist)) return next();
57
- return rejectOrigin(c);
58
- }
59
-
60
- // No Origin header — older browsers, and some same-origin POSTs in Safari.
61
- // Fall back to the Fetch-Metadata Sec-Fetch-Site signal: only an explicit
62
- // cross-site marker is blocked here (it's a relation, not an origin, so it
63
- // can't be matched against the allowlist). Everything else (same-site,
64
- // same-origin, none, absent) falls through to the CSRF token. Note: the
65
- // CSRF token alone does NOT stop a same-site subdomain XSS — it can read
66
- // the wide cookie — but that attack uses fetch/XHR, which always sends an
67
- // Origin header and is already rejected by the allowlist branch above. The
68
- // residual is only the no-Origin-yet-Sec-Fetch-Site combo, which no current
69
- // browser emits for state-changing requests.
70
- if (c.req.header("sec-fetch-site") === "cross-site") return rejectOrigin(c);
71
-
67
+ // Everything else (same-site, same-origin, none, absent) falls through to
68
+ // the CSRF token. Note: the CSRF token alone does NOT stop a same-site
69
+ // subdomain XSS — it can read the wide cookie — but that attack uses
70
+ // fetch/XHR, which always sends an Origin header and is already rejected
71
+ // by the allowlist branch inside isForeignCookieOrigin. The residual is
72
+ // only the no-Origin-yet-Sec-Fetch-Site combo, which no current browser
73
+ // emits for state-changing requests.
74
+ if (isForeignCookieOrigin(c, allowlist)) return rejectOrigin(c);
72
75
  return next();
73
76
  };
74
77
  }
package/src/api/server.ts CHANGED
@@ -71,7 +71,7 @@ import {
71
71
  } from "./extra-route";
72
72
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
73
73
  import { observabilityMiddleware } from "./observability-middleware";
74
- import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
74
+ import { assertOriginGuardConfig, normalizeOrigin, originMiddleware } from "./origin-middleware";
75
75
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
76
76
  import { createDefaultSseBroker, type RedisSseBroker } from "./redis-sse-broker";
77
77
  import { requestContext } from "./request-context";
@@ -726,11 +726,20 @@ export function buildServer(options: ServerOptions): KumikoServer {
726
726
  const tenantLifecycleResolver =
727
727
  options.auth?.resolveTenantLifecycleStatus ??
728
728
  deriveTenantLifecycleResolver(options.registry, baseDb);
729
+ // sessionOnlyGuard deliberately does NOT get this — its callers have no
730
+ // anonymousAccess fallback, so a foreign-origin cookie there must keep
731
+ // failing through the sibling originMiddleware 403.
732
+ const normalizedForeignCookieOrigins = options.auth?.allowedOrigins?.length
733
+ ? new Set(options.auth.allowedOrigins.map(normalizeOrigin))
734
+ : undefined;
729
735
  const jwtGuard = authMiddleware(jwt, {
730
736
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
731
737
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
732
738
  ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
733
739
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
740
+ ...(normalizedForeignCookieOrigins
741
+ ? { foreignCookieOrigins: normalizedForeignCookieOrigins }
742
+ : {}),
734
743
  });
735
744
  app.use("/api/*", async (c, next) => {
736
745
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
package/src/changes.json CHANGED
@@ -1,4 +1,52 @@
1
1
  [
2
+ {
3
+ "version": "0.314.0",
4
+ "type": "improvement",
5
+ "title": "drainEventConsumers helper for test stacks with a named consumer backlog",
6
+ "detail": "The `while ((await eventDispatcher.runOnce())?.processed ?? 0) > 0) {}`\npattern under-drains a backlog bigger than one dispatcher batch and gives no\ndiagnostics when a consumer is stuck. drainEventConsumers(stack,\nconsumerNames, { maxPasses }) from @cosmicdrift/kumiko-framework/stack\nsnapshots the events high-water mark at call time and runs bounded passes\nuntil every named consumer's cursor reaches it, throwing with per-consumer\nstatus, attempts and lastError when the budget (default 25) is exhausted.\nEvents written by consumers after the snapshot are not waited on. No\nmigration needed."
7
+ },
8
+ {
9
+ "version": "0.314.0",
10
+ "type": "improvement",
11
+ "title": "validateFile/resolveUploadMimeType accept .csv declared as application/vnd.ms-excel",
12
+ "detail": "Windows derives a file's upload MIME type from its Explorer file-type\nassociation rather than its content — .csv is registered to Excel there,\nso a Windows browser can declare \"application/vnd.ms-excel\" for a\nplain-text CSV. validateFile now accepts that declared/extension\ncombination for fields whose `accept` includes \"csv\" instead of rejecting\nit as a `mime_mismatch`. The built-in multipart upload route also calls the\nnew `resolveUploadMimeType`, which rewrites an aliased declared mimeType to\nits canonical form (\"text/csv\") after checking the uploaded bytes against\nknown binary signatures — a match (e.g. a real .xls/.xlsx renamed to .csv)\nis rejected with `content_mismatch` instead of being silently rewritten.\nNote: on Bun 1.4.0 (this repo's pinned engines/Dockerfile version, verified\nlocally), `Request#formData()` derives a multipart file's `.type` from its\nfilename extension rather than the part's declared Content-Type, so a\nbrowser's declared \"application/vnd.ms-excel\" for a `.csv` file does not\ncurrently reach the built-in route as such — callers that read a declared\nmimeType from elsewhere (e.g. a separate form field) are the ones this\nalias helps today. Aliases are declarative per extension; only\ncsv → vnd.ms-excel is defined so far.\n`@cosmicdrift/kumiko-framework/files` now also exports `sniffMimeType(bytes:\nUint8Array): string | null` (canonical MIME for a known binary signature,\nor null for text formats — every OLE Compound File returns\n\"application/msword\" and every ZIP returns the docx MIME, callers can't\ntell those apart by bytes alone) and `resolveUploadMimeType`."
13
+ },
14
+ {
15
+ "version": "0.314.0",
16
+ "type": "fix",
17
+ "title": "Foreign-origin cookie on an anonymousAccess server is treated as anonymous, not 403",
18
+ "detail": "originMiddleware rejected any state-changing request whose cookie-carrying\nOrigin (or Sec-Fetch-Site) fell outside auth.allowedOrigins with 403\norigin_not_allowed, including a logged-in tenant admin's own session cookie\nsent along by their public status page, which only makes anonymous queries.\nOn servers with anonymousAccess wired, authMiddleware now drops such a\nforeign-origin cookie before jwt.verify and falls through to the anonymous\nflow; the token is never read. Servers without anonymousAccess, /api/auth/*\npaths and session-only httpRoutes keep the 403. No migration needed."
19
+ },
20
+ {
21
+ "version": "0.314.0",
22
+ "type": "fix",
23
+ "title": "setupTestStack isolates job queues per stack by default",
24
+ "detail": "BullMQ queues live on the raw Redis URL, outside the per-test keyPrefix, so\ntwo parallel test stacks with `jobs` and no explicit `queueNamePrefix` shared\nthe prod default queue names and one stack's consumer could run the other\nstack's jobs. setupTestStack/setupAppTestStack now derive a unique\n`queueNamePrefix` from the stack's test Redis keyPrefix. An explicit\n`jobs.queueNamePrefix` still wins; pass the same value to every runner that\nhas to share a stack's queues. createJobRunner and the prod path are unchanged."
25
+ },
26
+ {
27
+ "version": "0.314.0",
28
+ "type": "improvement",
29
+ "title": "kumiko-upgrade --apply says where the marker landed relative to manual breaking changes",
30
+ "detail": "A breaking change without a codemod was listed as \"manual migration\nrequired\", but the run never said whether the upgrade marker stopped below\nit (the guard keeps listing it until a second --apply acknowledges it) or\nalready moved past it (the guard no longer lists it). --apply now prints\nwhich of the two happened. The marker logic itself is unchanged; the\nacknowledgement path is documented in docs/reference/stability-policy.md."
31
+ },
32
+ {
33
+ "version": "0.313.0",
34
+ "type": "fix",
35
+ "title": "DB pool close() no longer hangs forever during an outage",
36
+ "detail": "createDbConnection/createConnection's close() and PgKmsAdapter.close()\ncalled client.end() with no timeout, which postgres.js waits on\nindefinitely if a query is still referenced on a dead connection — a DB\noutage during process shutdown could hang forever. close() now bounds\nthe wait via a new closeTimeoutSeconds option on DbConnectionOptions\n(default 5s, DEFAULT_DB_CLOSE_TIMEOUT_SECONDS). No migration needed."
37
+ },
38
+ {
39
+ "version": "0.313.0",
40
+ "type": "fix",
41
+ "title": "event-dispatcher stop() waits for a pass still in its idle pre-check",
42
+ "detail": "Since 0.308.0 a pass started by the timer or a NOTIFY wake-up shortly\nbefore stop() could still be inside its idle pre-check, which stop()\nnever waited for. That pass kept running after stop() returned and\nqueried the DB pool the caller was closing, which could make\npostgres.js' end() hang. stop() now also drains in-flight passes.\nNo migration needed."
43
+ },
44
+ {
45
+ "version": "0.313.0",
46
+ "type": "improvement",
47
+ "title": "Event-triggered jobs get the triggering event's id and headers via ctx.triggerEvent",
48
+ "detail": "r.job's `trigger.on` reached via an r.defineEvent QN (async, at-least-once\ndelivery through the job-trigger event consumer) had no way to key an\nidempotency check on the stored event that fired it. JobContext now\ncarries `triggerEvent?: { id, headers }` — the stored event's id and\nmetadata.headers — when the job was reached this way. Undefined for\ncron/manual jobs and for jobs triggered synchronously off a write/query\nhandler (no stored event to hand over). No migration needed."
49
+ },
2
50
  {
3
51
  "version": "0.312.0",
4
52
  "type": "improvement",
@@ -1,5 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
2
  import postgres from "postgres";
3
+ import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/api";
3
4
  import {
4
5
  KeyAlreadyExistsError,
5
6
  KeyErasedError,
@@ -172,7 +173,7 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
172
173
  }
173
174
 
174
175
  async close(): Promise<void> {
175
- await this.sql.end();
176
+ await this.sql.end({ timeout: DEFAULT_DB_CLOSE_TIMEOUT_SECONDS });
176
177
  }
177
178
 
178
179
  private ensureSchema(): Promise<void> {
@@ -0,0 +1,110 @@
1
+ // A DB outage must not turn process shutdown into a hang: postgres.js keeps
2
+ // an in-flight query referenced on a dead socket, so a plain end() (no
3
+ // timeout = wait for queries to finish) never resolves once the DB is gone.
4
+ // close() has to bound that wait (db/api.ts's closeTimeoutSeconds). Routes
5
+ // createDbConnection through a TCP proxy that stops forwarding bytes after
6
+ // connect ("blackhole") — same simulated-outage approach as
7
+ // pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts, but
8
+ // without touching the shared test DB's actual availability.
9
+
10
+ import { afterAll, describe, expect, test } from "bun:test";
11
+ import net from "node:net";
12
+ import { waitFor } from "../../testing";
13
+ import { testDatabaseUrl } from "../../testing/closed-connection-error";
14
+ import { createDbConnection } from "../connection";
15
+
16
+ function startBlackholeProxy(dbUrl: URL): {
17
+ readonly server: net.Server;
18
+ blackhole: () => void;
19
+ swallowedBytes: () => number;
20
+ destroy: () => void;
21
+ } {
22
+ let blackholed = false;
23
+ let swallowed = 0;
24
+ const sockets = new Set<net.Socket>();
25
+ const server = net.createServer((downstream) => {
26
+ sockets.add(downstream);
27
+ const upstream = net.connect(Number(dbUrl.port), dbUrl.hostname);
28
+ sockets.add(upstream);
29
+ downstream.on("data", (chunk: Buffer) => {
30
+ if (blackholed) {
31
+ swallowed += chunk.length;
32
+ return;
33
+ }
34
+ upstream.write(chunk);
35
+ });
36
+ upstream.on("data", (chunk: Buffer) => {
37
+ if (!blackholed) downstream.write(chunk);
38
+ });
39
+ downstream.on("error", () => {});
40
+ upstream.on("error", () => {});
41
+ downstream.on("close", () => sockets.delete(downstream));
42
+ upstream.on("close", () => sockets.delete(upstream));
43
+ });
44
+ return {
45
+ server,
46
+ blackhole: () => {
47
+ blackholed = true;
48
+ },
49
+ swallowedBytes: () => swallowed,
50
+ destroy: () => {
51
+ for (const socket of sockets) socket.destroy();
52
+ server.close();
53
+ },
54
+ };
55
+ }
56
+
57
+ async function listenOn(server: net.Server, port: number): Promise<void> {
58
+ await new Promise<void>((resolve, reject) => {
59
+ server.once("error", reject);
60
+ server.listen(port, resolve);
61
+ });
62
+ }
63
+
64
+ let proxy: ReturnType<typeof startBlackholeProxy>;
65
+
66
+ afterAll(() => {
67
+ proxy?.destroy();
68
+ });
69
+
70
+ describe("createDbConnection close() bounds shutdown during a DB outage", () => {
71
+ test("close() resolves within the bounded timeout even with in-flight queries on a dead socket", async () => {
72
+ const dbUrl = new URL(testDatabaseUrl());
73
+ proxy = startBlackholeProxy(dbUrl);
74
+ await listenOn(proxy.server, 0);
75
+ const address = proxy.server.address();
76
+ if (address === null || typeof address === "string") {
77
+ throw new Error("proxy did not bind to a TCP port");
78
+ }
79
+
80
+ const proxiedUrl = new URL(dbUrl.toString());
81
+ proxiedUrl.hostname = "127.0.0.1";
82
+ proxiedUrl.port = String(address.port);
83
+
84
+ const conn = createDbConnection(proxiedUrl.toString(), {
85
+ maxConnections: 1,
86
+ closeTimeoutSeconds: 1,
87
+ });
88
+
89
+ // Establish the connection while the proxy still forwards normally —
90
+ // isolates the assertion to close()'s own timeout, not connect_timeout.
91
+ await conn.client.unsafe("select 1");
92
+
93
+ // Fired without awaiting: once the socket goes dead, end() has to
94
+ // reject these instead of waiting on them forever. .catch prevents an
95
+ // unhandled rejection when end() tears the connection down.
96
+ const inFlightBeforeBlackhole = conn.client.unsafe("select 1").catch(() => undefined);
97
+ proxy.blackhole();
98
+ const inFlightAfterBlackhole = conn.client.unsafe("select 1").catch(() => undefined);
99
+
100
+ await waitFor(() => proxy.swallowedBytes() > 0);
101
+
102
+ const start = Date.now();
103
+ await conn.close();
104
+ const elapsed = Date.now() - start;
105
+
106
+ expect(elapsed).toBeLessThan(3000);
107
+
108
+ await Promise.all([inFlightBeforeBlackhole, inFlightAfterBlackhole]);
109
+ });
110
+ });
package/src/db/api.ts CHANGED
@@ -11,8 +11,14 @@ export type DbConnectionOptions = {
11
11
  readonly maxConnections?: number;
12
12
  readonly idleTimeoutSeconds?: number;
13
13
  readonly connectTimeoutSeconds?: number;
14
+ readonly closeTimeoutSeconds?: number;
14
15
  };
15
16
 
17
+ // A DB outage can leave an in-flight query referenced on a dead connection,
18
+ // so a plain end() (no timeout = wait for queries to finish) hangs forever —
19
+ // close() must always bound how long shutdown waits.
20
+ export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
21
+
16
22
  // Connection-Handle: db für Queries, client für Legacy-Zugriff (LISTEN/NOTIFY-Peer
17
23
  // bei Bun.SQL), close für Pool-Shutdown.
18
24
  export type DbPoolHandle = {