@cosmicdrift/kumiko-framework 0.313.0 → 0.315.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.313.0",
3
+ "version": "0.315.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.313.0",
202
- "@cosmicdrift/kumiko-types": "0.313.0",
201
+ "@cosmicdrift/kumiko-http": "0.315.0",
202
+ "@cosmicdrift/kumiko-types": "0.315.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.313.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.315.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
@@ -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,40 @@
1
1
  [
2
+ {
3
+ "version": "0.315.0",
4
+ "type": "breaking",
5
+ "title": "files: content verification is now keyed by filename extension, covering every signature-bearing type",
6
+ "migration": "validateFileContent's signature changes from (mimeType: string, content: Uint8Array) to (fileName: string, content: Uint8Array): string | null. It now content-verifies every extension whose EXTENSION_MIME_WHITELIST entry overlaps MAGIC_BYTE_SIGNATURES (jpg/jpeg/png/gif/webp/pdf/doc/docx), not just doc/docx, and derives the check declaratively from the whitelist instead of a hardcoded per-format list. file-routes.ts now passes file.name instead of file.type (Bun derives File#type from the filename anyway, so the mimeType-keyed call was already comparing an extension-derived value to itself). A malicious extension shaped like a JS prototype property (constructor, __proto__, toString) resolves to \"unknown extension\" (no error) instead of crashing with a 500 — the same Object.hasOwn-guarded whitelist lookup is now shared by validateFile and validateFileContent. mime_mismatch remains a metadata-only pre-check; it is not real evidence of file content. Any test that uploads placeholder bytes (e.g. [1,2,3]) through the real upload route under one of the now-verified extensions must switch to real minimal signature bytes or a differently-named/unchecked extension."
7
+ },
8
+ {
9
+ "version": "0.314.0",
10
+ "type": "improvement",
11
+ "title": "drainEventConsumers helper for test stacks with a named consumer backlog",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.314.0",
16
+ "type": "improvement",
17
+ "title": "validateFile/resolveUploadMimeType accept .csv declared as application/vnd.ms-excel",
18
+ "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`."
19
+ },
20
+ {
21
+ "version": "0.314.0",
22
+ "type": "fix",
23
+ "title": "Foreign-origin cookie on an anonymousAccess server is treated as anonymous, not 403",
24
+ "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."
25
+ },
26
+ {
27
+ "version": "0.314.0",
28
+ "type": "fix",
29
+ "title": "setupTestStack isolates job queues per stack by default",
30
+ "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."
31
+ },
32
+ {
33
+ "version": "0.314.0",
34
+ "type": "improvement",
35
+ "title": "kumiko-upgrade --apply says where the marker landed relative to manual breaking changes",
36
+ "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."
37
+ },
2
38
  {
3
39
  "version": "0.313.0",
4
40
  "type": "fix",
@@ -103,7 +103,7 @@ afterAll(async () => {
103
103
 
104
104
  async function uploadFile(
105
105
  asUser = user,
106
- file = new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }),
106
+ file = new File([Buffer.from([0xff, 0xd8, 0xff, 0xe0])], "photo.jpg", { type: "image/jpeg" }),
107
107
  ): Promise<string> {
108
108
  const token = await stack.jwt.sign(asUser);
109
109
  const fd = new FormData();
@@ -214,7 +214,9 @@ describe("ctx.derivatives — typed errors from variant()", () => {
214
214
  test("no renderer registered for the mimeType throws InternalError with httpStatus 500", async () => {
215
215
  const fileId = await uploadFile(
216
216
  user,
217
- new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }),
217
+ new File([Buffer.from(new TextEncoder().encode("%PDF-1.4 minimal"))], "doc.pdf", {
218
+ type: "application/pdf",
219
+ }),
218
220
  );
219
221
 
220
222
  const result = await stack.http.writeOk<{
@@ -73,7 +73,10 @@ async function uploadFile(
73
73
  ): Promise<string> {
74
74
  const token = await stack.jwt.sign(asUser);
75
75
  const fd = new FormData();
76
- fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.jpg", { type: "image/jpeg" }));
76
+ fd.append(
77
+ "file",
78
+ new File([Buffer.from([0xff, 0xd8, 0xff, 0xe0])], "avatar.jpg", { type: "image/jpeg" }),
79
+ );
77
80
  if (attach !== null) {
78
81
  fd.append("entityType", attach.entityType);
79
82
  fd.append("fieldName", attach.fieldName);
@@ -198,7 +201,12 @@ describe("GET /api/files/:id/variant/:name", () => {
198
201
  test("a source mimeType with no matching renderer (client uploaded a non-image) returns 415, not a 500", async () => {
199
202
  const token = await stack.jwt.sign(user);
200
203
  const fd = new FormData();
201
- fd.append("file", new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }));
204
+ fd.append(
205
+ "file",
206
+ new File([Buffer.from(new TextEncoder().encode("%PDF-1.4 minimal"))], "doc.pdf", {
207
+ type: "application/pdf",
208
+ }),
209
+ );
202
210
  fd.append("entityType", "photo");
203
211
  fd.append("fieldName", "avatar");
204
212
  const { body, contentType } = await buildMultipartBody(fd);
@@ -89,6 +89,20 @@ beforeEach(async () => {
89
89
  await asRawClient(stack.db).unsafe(`TRUNCATE pipeline_documents`);
90
90
  });
91
91
 
92
+ const PDF_BYTES = new TextEncoder().encode("%PDF-1.4 minimal");
93
+ const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
94
+ const PNG_BYTES = new Uint8Array([
95
+ 0x89,
96
+ 0x50,
97
+ 0x4e,
98
+ 0x47,
99
+ 0x0d,
100
+ 0x0a,
101
+ 0x1a,
102
+ 0x0a,
103
+ ...Array(10).fill(0),
104
+ ]);
105
+
92
106
  async function uploadFile(fileName: string, body: Uint8Array, mimeType: string): Promise<string> {
93
107
  const token = await stack.jwt.sign(user);
94
108
  const fd = new FormData();
@@ -107,8 +121,8 @@ async function uploadFile(fileName: string, body: Uint8Array, mimeType: string):
107
121
 
108
122
  describe("file/image field through the CRUD pipeline", () => {
109
123
  test("create entity with file-field UUID → detail round-trips the UUID", async () => {
110
- const fileId = await uploadFile("doc.pdf", new Uint8Array([1, 2, 3]), "application/pdf");
111
- const imageId = await uploadFile("cover.png", new Uint8Array([4, 5, 6]), "image/png");
124
+ const fileId = await uploadFile("doc.pdf", PDF_BYTES, "application/pdf");
125
+ const imageId = await uploadFile("cover.png", PNG_BYTES, "image/png");
112
126
 
113
127
  // Create through the standard write pipeline — this is the path where the
114
128
  // pre-fix validation (z.number() for file/image) would have rejected the
@@ -135,8 +149,8 @@ describe("file/image field through the CRUD pipeline", () => {
135
149
  });
136
150
 
137
151
  test("update entity swaps file-field UUIDs cleanly", async () => {
138
- const oldFile = await uploadFile("v1.pdf", new Uint8Array([1]), "application/pdf");
139
- const newFile = await uploadFile("v2.pdf", new Uint8Array([2]), "application/pdf");
152
+ const oldFile = await uploadFile("v1.pdf", PDF_BYTES, "application/pdf");
153
+ const newFile = await uploadFile("v2.pdf", PDF_BYTES, "application/pdf");
140
154
 
141
155
  const created = await stack.http.writeOk<{ id: string }>(
142
156
  "pipeline-documents:write:document:create",
@@ -177,9 +191,9 @@ describe("file/image field through the CRUD pipeline", () => {
177
191
  // files/images) — the array of UUIDs lives in the event payload. The
178
192
  // pipeline still has to validate + accept it. Pre-fix this was
179
193
  // z.array(z.number()) which would have rejected every UUID array.
180
- const a = await uploadFile("a.jpg", new Uint8Array([1]), "image/jpeg");
181
- const b = await uploadFile("b.jpg", new Uint8Array([2]), "image/jpeg");
182
- const c = await uploadFile("notes.pdf", new Uint8Array([3]), "application/pdf");
194
+ const a = await uploadFile("a.jpg", JPEG_BYTES, "image/jpeg");
195
+ const b = await uploadFile("b.jpg", JPEG_BYTES, "image/jpeg");
196
+ const c = await uploadFile("notes.pdf", PDF_BYTES, "application/pdf");
183
197
 
184
198
  const created = await stack.http.writeOk<{ id: string }>(
185
199
  "pipeline-documents:write:document:create",
@@ -190,8 +204,8 @@ describe("file/image field through the CRUD pipeline", () => {
190
204
 
191
205
  // Follow-up update: swap one photo out, add a second doc. Proves the
192
206
  // update-path handles plural arrays too, not just create.
193
- const d = await uploadFile("c.jpg", new Uint8Array([4]), "image/jpeg");
194
- const e = await uploadFile("more.pdf", new Uint8Array([5]), "application/pdf");
207
+ const d = await uploadFile("c.jpg", JPEG_BYTES, "image/jpeg");
208
+ const e = await uploadFile("more.pdf", PDF_BYTES, "application/pdf");
195
209
 
196
210
  const updated = await stack.http.writeOk<{ id: string }>(
197
211
  "pipeline-documents:write:document:update",
@@ -204,7 +218,7 @@ describe("file/image field through the CRUD pipeline", () => {
204
218
  });
205
219
 
206
220
  test("plural files field rejects non-UUID element (schema validates EACH array element)", async () => {
207
- const valid = await uploadFile("ok.pdf", new Uint8Array([1]), "application/pdf");
221
+ const valid = await uploadFile("ok.pdf", PDF_BYTES, "application/pdf");
208
222
  const err = await stack.http.writeErr(
209
223
  "pipeline-documents:write:document:create",
210
224
  { title: "Bad array", docs: [valid, "not-a-uuid"] },