@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 +4 -4
- package/src/__tests__/upgrade-cli.test.ts +28 -0
- package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
- package/src/api/auth-middleware.ts +30 -1
- package/src/api/origin-middleware.ts +23 -20
- package/src/api/server.ts +10 -1
- package/src/changes.json +36 -0
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +4 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +10 -2
- package/src/files/__tests__/file-field-pipeline.integration.test.ts +24 -10
- package/src/files/__tests__/files.integration.test.ts +252 -20
- package/src/files/__tests__/storage-tracking.integration.test.ts +5 -3
- package/src/files/file-routes.ts +11 -4
- package/src/files/index.ts +3 -0
- package/src/files/types.ts +89 -30
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
- package/src/pipeline/system-hooks.ts +2 -1
- package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
- package/src/stack/drain-event-consumers.ts +74 -0
- package/src/stack/index.ts +1 -0
- package/src/stack/redis.ts +3 -0
- package/src/stack/test-stack.ts +13 -4
- 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.
|
|
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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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 {
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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([
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
111
|
-
const imageId = await uploadFile("cover.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",
|
|
139
|
-
const newFile = await uploadFile("v2.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",
|
|
181
|
-
const b = await uploadFile("b.jpg",
|
|
182
|
-
const c = await uploadFile("notes.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",
|
|
194
|
-
const e = await uploadFile("more.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",
|
|
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"] },
|