@cosmicdrift/kumiko-framework 0.313.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.
- 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 +30 -0
- package/src/files/__tests__/files.integration.test.ts +128 -1
- package/src/files/file-routes.ts +10 -3
- package/src/files/index.ts +3 -0
- package/src/files/types.ts +57 -2
- 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.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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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 {
|
|
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,34 @@
|
|
|
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
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.313.0",
|
|
4
34
|
"type": "fix",
|
|
@@ -8,6 +8,7 @@ import { buildServer } from "../../api/server";
|
|
|
8
8
|
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "../../crypto";
|
|
9
9
|
import {
|
|
10
10
|
createEntity,
|
|
11
|
+
createFileField,
|
|
11
12
|
createImageField,
|
|
12
13
|
createRegistry,
|
|
13
14
|
createTextField,
|
|
@@ -35,7 +36,13 @@ import type { FileRoutesOptions } from "../file-routes";
|
|
|
35
36
|
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
36
37
|
import { createLocalProvider } from "../local-provider";
|
|
37
38
|
import type { FileStorageProvider, SignedUrlOptions } from "../types";
|
|
38
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
parseMaxSize,
|
|
41
|
+
resolveUploadMimeType,
|
|
42
|
+
sniffMimeType,
|
|
43
|
+
validateFile,
|
|
44
|
+
validateFileContent,
|
|
45
|
+
} from "../types";
|
|
39
46
|
|
|
40
47
|
// UUID for "this row doesn't exist" assertions. Valid v4 format so PG accepts
|
|
41
48
|
// the query — the row just isn't there. Pre-v1 files-feature tests used
|
|
@@ -59,6 +66,7 @@ const testTenantEntity = createEntity({
|
|
|
59
66
|
fields: {
|
|
60
67
|
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
61
68
|
logo: createImageField({ maxSize: "2mb", accept: ["png", "jpg"] }),
|
|
69
|
+
importFile: createFileField({ maxSize: "2mb", accept: ["csv"] }),
|
|
62
70
|
},
|
|
63
71
|
});
|
|
64
72
|
|
|
@@ -296,6 +304,91 @@ describe("file validation", () => {
|
|
|
296
304
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
297
305
|
);
|
|
298
306
|
});
|
|
307
|
+
|
|
308
|
+
// --- .csv declared as application/vnd.ms-excel (Windows browsers) ---
|
|
309
|
+
|
|
310
|
+
test("validateFile accepts .csv declared as application/vnd.ms-excel when csv is in accept", () => {
|
|
311
|
+
expect(
|
|
312
|
+
validateFile(
|
|
313
|
+
{ fileName: "data.csv", mimeType: "application/vnd.ms-excel", size: 100 },
|
|
314
|
+
{ accept: ["csv"] },
|
|
315
|
+
),
|
|
316
|
+
).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("validateFile does not extend the vnd.ms-excel alias to extensions other than csv", () => {
|
|
320
|
+
const error = validateFile(
|
|
321
|
+
{ fileName: "photo.jpg", mimeType: "application/vnd.ms-excel", size: 100 },
|
|
322
|
+
{ accept: ["jpg"] },
|
|
323
|
+
);
|
|
324
|
+
expectErrorIncludes(error, "mime_mismatch");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("validateFile rejects a prototype-property mimeType instead of treating it as an alias match", () => {
|
|
328
|
+
const error = validateFile(
|
|
329
|
+
{ fileName: "photo.jpg", mimeType: "constructor", size: 100 },
|
|
330
|
+
{ accept: ["jpg"] },
|
|
331
|
+
);
|
|
332
|
+
expectErrorIncludes(error, "mime_mismatch");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("resolveUploadMimeType", () => {
|
|
336
|
+
const textCsvBytes = new TextEncoder().encode("name,age\nAda,36\n");
|
|
337
|
+
|
|
338
|
+
test("rewrites .csv + application/vnd.ms-excel to text/csv when bytes are text", () => {
|
|
339
|
+
expect(resolveUploadMimeType("data.csv", "application/vnd.ms-excel", textCsvBytes)).toEqual({
|
|
340
|
+
kind: "ok",
|
|
341
|
+
mimeType: "text/csv",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("rewrites regardless of charset suffix or case", () => {
|
|
346
|
+
expect(
|
|
347
|
+
resolveUploadMimeType("data.csv", "Application/VND.MS-EXCEL; charset=utf-8", textCsvBytes),
|
|
348
|
+
).toEqual({ kind: "ok", mimeType: "text/csv" });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("rejects .csv + application/vnd.ms-excel when bytes are a real OLE binary", () => {
|
|
352
|
+
const result = resolveUploadMimeType("data.csv", "application/vnd.ms-excel", docBytes);
|
|
353
|
+
expect(result.kind).toBe("rejected");
|
|
354
|
+
if (result.kind === "rejected") {
|
|
355
|
+
expectErrorIncludes(result.error, "content_mismatch");
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("rejects .csv + application/vnd.ms-excel when bytes are a ZIP container (.xlsx)", () => {
|
|
360
|
+
const result = resolveUploadMimeType("data.csv", "application/vnd.ms-excel", docxBytes);
|
|
361
|
+
expect(result.kind).toBe("rejected");
|
|
362
|
+
if (result.kind === "rejected") {
|
|
363
|
+
expectErrorIncludes(result.error, "content_mismatch");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("leaves .csv + text/plain unchanged", () => {
|
|
368
|
+
expect(resolveUploadMimeType("data.csv", "text/plain", textCsvBytes)).toEqual({
|
|
369
|
+
kind: "ok",
|
|
370
|
+
mimeType: "text/plain",
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("leaves .xls + application/vnd.ms-excel unchanged (alias is csv-only)", () => {
|
|
375
|
+
expect(resolveUploadMimeType("data.xls", "application/vnd.ms-excel", docBytes)).toEqual({
|
|
376
|
+
kind: "ok",
|
|
377
|
+
mimeType: "application/vnd.ms-excel",
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("a prototype-property extension or mimeType never resolves as an alias", () => {
|
|
382
|
+
expect(resolveUploadMimeType("x.constructor", "name", textCsvBytes)).toEqual({
|
|
383
|
+
kind: "ok",
|
|
384
|
+
mimeType: "name",
|
|
385
|
+
});
|
|
386
|
+
expect(resolveUploadMimeType("data.csv", "constructor", textCsvBytes)).toEqual({
|
|
387
|
+
kind: "ok",
|
|
388
|
+
mimeType: "constructor",
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
299
392
|
});
|
|
300
393
|
|
|
301
394
|
// --- Integration: Upload → Download → Delete via real HTTP API ---
|
|
@@ -523,6 +616,40 @@ describe("doc/docx upload is content-verified against magic bytes", () => {
|
|
|
523
616
|
});
|
|
524
617
|
});
|
|
525
618
|
|
|
619
|
+
// Bun's formData() derives File#type from the filename, so .csv never
|
|
620
|
+
// arrives as vnd.ms-excel here; the csv alias is covered by the unit tests above.
|
|
621
|
+
describe("vnd.ms-excel via HTTP (.xls)", () => {
|
|
622
|
+
const docBytes = new Uint8Array([
|
|
623
|
+
0xd0,
|
|
624
|
+
0xcf,
|
|
625
|
+
0x11,
|
|
626
|
+
0xe0,
|
|
627
|
+
0xa1,
|
|
628
|
+
0xb1,
|
|
629
|
+
0x1a,
|
|
630
|
+
0xe1,
|
|
631
|
+
...Array(20).fill(0),
|
|
632
|
+
]);
|
|
633
|
+
|
|
634
|
+
test("unattached .xls declared as application/vnd.ms-excel keeps its declared mimeType unchanged", async () => {
|
|
635
|
+
const res = await uploadFile(adminUser, "legacy.xls", docBytes, "application/vnd.ms-excel");
|
|
636
|
+
expect(res.status).toBe(201);
|
|
637
|
+
const body = await res.json();
|
|
638
|
+
expect(body.mimeType).toBe("application/vnd.ms-excel");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("attached .xls upload is rejected by the importFile field's csv-only accept list", async () => {
|
|
642
|
+
const res = await uploadFile(adminUser, "legacy.xls", docBytes, "application/vnd.ms-excel", {
|
|
643
|
+
entityType: "tenant",
|
|
644
|
+
entityId: "1",
|
|
645
|
+
fieldName: "importFile",
|
|
646
|
+
});
|
|
647
|
+
expect(res.status).toBe(400);
|
|
648
|
+
const body = await res.json();
|
|
649
|
+
expect(body.error).toContain("invalid_file_type");
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
526
653
|
// --- Cross-user access within a tenant (attached file owner-scope) ---
|
|
527
654
|
|
|
528
655
|
describe("attached file owner-scope", () => {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type { FileProviderResolver } from "./provider-resolver";
|
|
|
30
30
|
import {
|
|
31
31
|
buildStorageKey,
|
|
32
32
|
resolveServedContentType,
|
|
33
|
+
resolveUploadMimeType,
|
|
33
34
|
validateFile,
|
|
34
35
|
validateFileContent,
|
|
35
36
|
} from "./types";
|
|
@@ -240,13 +241,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
240
241
|
return c.json({ error: contentValidationError }, 400);
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
const mimeTypeResolution = resolveUploadMimeType(file.name, file.type, data);
|
|
245
|
+
if (mimeTypeResolution.kind === "rejected") {
|
|
246
|
+
return c.json({ error: mimeTypeResolution.error }, 400);
|
|
247
|
+
}
|
|
248
|
+
const mimeType = mimeTypeResolution.mimeType;
|
|
249
|
+
|
|
243
250
|
// Write binary FIRST (outside the tx — network/disk I/O doesn't belong
|
|
244
251
|
// inside a PG connection's tx window). On DB-tx rollback below the bytes
|
|
245
252
|
// are orphaned in the provider; cleanup-jobs sweep those later. Losing a
|
|
246
253
|
// row on append-failure is acceptable; corrupting a committed row with a
|
|
247
254
|
// missing binary is not.
|
|
248
255
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
249
|
-
await storageProvider.write(storageKey, data,
|
|
256
|
+
await storageProvider.write(storageKey, data, mimeType);
|
|
250
257
|
|
|
251
258
|
// Create via the standard entity executor: emits fileRef.created +
|
|
252
259
|
// materialises the file_refs row in one tx (read-your-own-write). id is
|
|
@@ -260,7 +267,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
260
267
|
id: fileRefId,
|
|
261
268
|
storageKey,
|
|
262
269
|
fileName: file.name,
|
|
263
|
-
mimeType
|
|
270
|
+
mimeType,
|
|
264
271
|
size: file.size,
|
|
265
272
|
entityType: entityType ?? null,
|
|
266
273
|
entityId: entityId ?? null,
|
|
@@ -277,7 +284,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
277
284
|
{
|
|
278
285
|
id: fileRefId,
|
|
279
286
|
fileName: file.name,
|
|
280
|
-
mimeType
|
|
287
|
+
mimeType,
|
|
281
288
|
size: file.size,
|
|
282
289
|
storageKey,
|
|
283
290
|
},
|
package/src/files/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export type {
|
|
|
37
37
|
FileStorageProvider,
|
|
38
38
|
FileValidationOptions,
|
|
39
39
|
SignedUrlOptions,
|
|
40
|
+
UploadMimeTypeResolution,
|
|
40
41
|
WriteStreamOptions,
|
|
41
42
|
} from "./types";
|
|
42
43
|
export {
|
|
@@ -44,6 +45,8 @@ export {
|
|
|
44
45
|
buildStorageKey,
|
|
45
46
|
normalizeMimeType,
|
|
46
47
|
parseMaxSize,
|
|
48
|
+
resolveUploadMimeType,
|
|
49
|
+
sniffMimeType,
|
|
47
50
|
tenantExportPrefix,
|
|
48
51
|
tenantStoragePrefixes,
|
|
49
52
|
validateFile,
|
package/src/files/types.ts
CHANGED
|
@@ -34,6 +34,10 @@ export function parseMaxSize(maxSize: string): number {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function extractExtension(fileName: string): string | undefined {
|
|
38
|
+
return fileName.split(".").pop()?.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
// Extension → acceptable MIME-type whitelist. Guards against a client
|
|
38
42
|
// uploading e.g. name="x.jpg" with mimeType="application/pdf" to slip an
|
|
39
43
|
// executable past the extension-only check. Kept small & conservative — add
|
|
@@ -54,6 +58,21 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
|
|
|
54
58
|
docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
55
59
|
} satisfies Record<string, readonly string[]>;
|
|
56
60
|
|
|
61
|
+
// Extension → declared MIME → canonical MIME. Windows resolves .csv's upload
|
|
62
|
+
// MIME from Explorer's Excel file association, sending "application/vnd.ms-excel" for plain-text CSV.
|
|
63
|
+
const EXTENSION_MIME_ALIASES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
64
|
+
csv: { "application/vnd.ms-excel": "text/csv" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Object.hasOwn at both levels: a plain `in` check on `{}` resolves to
|
|
68
|
+
// Object.prototype members ("constructor", …) for any ext/mimeType.
|
|
69
|
+
function lookupMimeAlias(ext: string, normalizedMimeType: string): string | undefined {
|
|
70
|
+
if (!Object.hasOwn(EXTENSION_MIME_ALIASES, ext)) return undefined;
|
|
71
|
+
const aliasesForExt = EXTENSION_MIME_ALIASES[ext];
|
|
72
|
+
if (!aliasesForExt || !Object.hasOwn(aliasesForExt, normalizedMimeType)) return undefined;
|
|
73
|
+
return aliasesForExt[normalizedMimeType];
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
// Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
|
|
58
77
|
// a reliable binary signature. Used at SERVE time — never trust the stored/
|
|
59
78
|
// client-declared mimeType for Content-Type on its own, sniff the actual
|
|
@@ -108,6 +127,14 @@ function startsWithAscii(bytes: Uint8Array, ascii: string, offset = 0): boolean
|
|
|
108
127
|
return true;
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Canonical MIME type for a known binary magic-byte signature (png, jpeg,
|
|
132
|
+
* gif, webp, pdf, OLE, ZIP), or null for everything else — text formats
|
|
133
|
+
* (csv, txt, json, svg, md) have no signature and always return null. Every
|
|
134
|
+
* OLE Compound File (.doc/.xls/.ppt) returns "application/msword" and every
|
|
135
|
+
* ZIP (.docx/.xlsx/.pptx/.zip) returns the docx MIME — callers can't tell
|
|
136
|
+
* those apart by bytes alone.
|
|
137
|
+
*/
|
|
111
138
|
export function sniffMimeType(bytes: Uint8Array): string | null {
|
|
112
139
|
for (const signature of MAGIC_BYTE_SIGNATURES) {
|
|
113
140
|
if (signature.matches(bytes)) return signature.mimeType;
|
|
@@ -165,7 +192,7 @@ export function validateFile(
|
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
if (options.accept && options.accept.length > 0) {
|
|
168
|
-
const ext = metadata.fileName
|
|
195
|
+
const ext = extractExtension(metadata.fileName);
|
|
169
196
|
if (!ext || !options.accept.includes(ext)) {
|
|
170
197
|
return `invalid_file_type: ".${ext}" is not in [${options.accept.join(", ")}]`;
|
|
171
198
|
}
|
|
@@ -176,7 +203,8 @@ export function validateFile(
|
|
|
176
203
|
const allowedMimes = EXTENSION_MIME_WHITELIST[ext];
|
|
177
204
|
if (allowedMimes && metadata.mimeType) {
|
|
178
205
|
const normalized = normalizeMimeType(metadata.mimeType);
|
|
179
|
-
|
|
206
|
+
const isAliasedMime = lookupMimeAlias(ext, normalized) !== undefined;
|
|
207
|
+
if (!allowedMimes.includes(normalized) && !isAliasedMime) {
|
|
180
208
|
return `mime_mismatch: extension ".${ext}" does not match mimeType "${metadata.mimeType}"`;
|
|
181
209
|
}
|
|
182
210
|
}
|
|
@@ -198,6 +226,33 @@ export function validateFileContent(mimeType: string, content: Uint8Array): stri
|
|
|
198
226
|
return null;
|
|
199
227
|
}
|
|
200
228
|
|
|
229
|
+
export type UploadMimeTypeResolution =
|
|
230
|
+
| { readonly kind: "ok"; readonly mimeType: string }
|
|
231
|
+
| { readonly kind: "rejected"; readonly error: string };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Rewrites an aliased declared mimeType to its canonical form and, in the
|
|
235
|
+
* same call, sniffs the bytes — a renamed binary (a real .xls/.xlsx saved
|
|
236
|
+
* as .csv) must fail here rather than through an optional, skippable step.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveUploadMimeType(
|
|
239
|
+
fileName: string,
|
|
240
|
+
declaredMimeType: string,
|
|
241
|
+
content: Uint8Array,
|
|
242
|
+
): UploadMimeTypeResolution {
|
|
243
|
+
const ext = extractExtension(fileName);
|
|
244
|
+
const normalizedDeclared = normalizeMimeType(declaredMimeType);
|
|
245
|
+
const canonical = ext ? lookupMimeAlias(ext, normalizedDeclared) : undefined;
|
|
246
|
+
if (!canonical) return { kind: "ok", mimeType: declaredMimeType };
|
|
247
|
+
if (sniffMimeType(content) !== null) {
|
|
248
|
+
return {
|
|
249
|
+
kind: "rejected",
|
|
250
|
+
error: `content_mismatch: bytes of ".${ext}" upload declared as "${declaredMimeType}" are a binary format`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { kind: "ok", mimeType: canonical };
|
|
254
|
+
}
|
|
255
|
+
|
|
201
256
|
export function assertSafeStorageKey(key: string): void {
|
|
202
257
|
if (key.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
203
258
|
throw new Error(`storage key contains a path-traversal segment: "${key}"`);
|
|
@@ -14,7 +14,6 @@ import * as z from "zod";
|
|
|
14
14
|
import { defineFeature } from "../../engine";
|
|
15
15
|
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
16
16
|
import { waitFor } from "../../testing";
|
|
17
|
-
import { generateId } from "../../utils";
|
|
18
17
|
|
|
19
18
|
const exponentialDefaultStarts: number[] = [];
|
|
20
19
|
const exponentialCustomStarts: number[] = [];
|
|
@@ -92,10 +91,7 @@ let stack: TestStack;
|
|
|
92
91
|
beforeAll(async () => {
|
|
93
92
|
stack = await setupTestStack({
|
|
94
93
|
features: [backoffFixtureFeature],
|
|
95
|
-
|
|
96
|
-
// Redis is consumed by every parallel test process, and a foreign worker
|
|
97
|
-
// grabbing a promoted delayed retry fails it as "Unknown job".
|
|
98
|
-
jobs: { consumerLane: "worker", queueNamePrefix: `backoff-test-${generateId()}` },
|
|
94
|
+
jobs: { consumerLane: "worker" },
|
|
99
95
|
});
|
|
100
96
|
});
|
|
101
97
|
|
|
@@ -468,7 +468,8 @@ export async function buildSearchDocument(
|
|
|
468
468
|
// die auf optimistic-update setzen merken das nicht; strictly-waiting
|
|
469
469
|
// Clients müssten poll-after-write.
|
|
470
470
|
//
|
|
471
|
-
// Tests drain
|
|
471
|
+
// Tests drain deterministically via `await stack.eventDispatcher.runOnce()`,
|
|
472
|
+
// or via `drainEventConsumers` for this consumer name specifically.
|
|
472
473
|
export const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
|
|
473
474
|
|
|
474
475
|
export function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventConsumer {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// drainEventConsumers — pass-budgeted alternative to
|
|
2
|
+
// `while ((await eventDispatcher.runOnce())?.processed ?? 0) > 0) {}`, which
|
|
3
|
+
// under-drains a backlog bigger than one dispatcher batch and gives no
|
|
4
|
+
// diagnostics when a consumer is stuck.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { insertOne } from "../../db";
|
|
8
|
+
import { defineFeature } from "../../engine";
|
|
9
|
+
import { eventsTable } from "../../event-store";
|
|
10
|
+
import { drainEventConsumers } from "../drain-event-consumers";
|
|
11
|
+
import { resetEventStore } from "../table-helpers";
|
|
12
|
+
import { setupTestStack, type TestStack } from "../test-stack";
|
|
13
|
+
import { TestUsers } from "../test-users";
|
|
14
|
+
|
|
15
|
+
const admin = TestUsers.admin;
|
|
16
|
+
const TRACKER_QN = "drain-test:projection:tracker";
|
|
17
|
+
const POISON_QN = "drain-test:projection:poison";
|
|
18
|
+
|
|
19
|
+
// Distinct from POISON_TARGET_NAME (test 3's own poison consumer) so the two
|
|
20
|
+
// scenarios can't accidentally cross-trigger each other's throw branch.
|
|
21
|
+
const CASCADE_TRIGGER_NAME = "cascade-trigger";
|
|
22
|
+
const CASCADE_POISON_NAME = "cascade-poison-must-not-be-processed";
|
|
23
|
+
const POISON_TARGET_NAME = "always-poison";
|
|
24
|
+
|
|
25
|
+
let observed: string[] = [];
|
|
26
|
+
|
|
27
|
+
const drainTestFeature = defineFeature("drain-test", (r) => {
|
|
28
|
+
r.multiStreamProjection({
|
|
29
|
+
name: "tracker",
|
|
30
|
+
apply: {
|
|
31
|
+
"drain-test.tick": async (event, tx) => {
|
|
32
|
+
const name = (event.payload as { name: string }).name;
|
|
33
|
+
if (name === CASCADE_POISON_NAME) {
|
|
34
|
+
throw new Error(`tracker must not process the post-snapshot cascade: ${name}`);
|
|
35
|
+
}
|
|
36
|
+
observed.push(name);
|
|
37
|
+
// Simulates a consumer whose own handler writes a follow-up event —
|
|
38
|
+
// drainEventConsumers snapshots the target once, so this write must
|
|
39
|
+
// never be waited on.
|
|
40
|
+
if (name === CASCADE_TRIGGER_NAME) {
|
|
41
|
+
await insertOne(tx, eventsTable, {
|
|
42
|
+
aggregateId: crypto.randomUUID(),
|
|
43
|
+
aggregateType: "drain-test-source",
|
|
44
|
+
tenantId: admin.tenantId,
|
|
45
|
+
version: 1,
|
|
46
|
+
type: "drain-test.tick",
|
|
47
|
+
eventVersion: 1,
|
|
48
|
+
payload: { name: CASCADE_POISON_NAME },
|
|
49
|
+
metadata: { userId: admin.id },
|
|
50
|
+
createdBy: admin.id,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
r.multiStreamProjection({
|
|
57
|
+
name: "poison",
|
|
58
|
+
apply: {
|
|
59
|
+
"drain-test.tick": async (event) => {
|
|
60
|
+
const name = (event.payload as { name: string }).name;
|
|
61
|
+
if (name === POISON_TARGET_NAME) {
|
|
62
|
+
throw new Error(`boom: ${name}`);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function appendTick(name: string): Promise<void> {
|
|
70
|
+
await insertOne(stack.db, eventsTable, {
|
|
71
|
+
aggregateId: crypto.randomUUID(),
|
|
72
|
+
aggregateType: "drain-test-source",
|
|
73
|
+
tenantId: admin.tenantId,
|
|
74
|
+
version: 1,
|
|
75
|
+
type: "drain-test.tick",
|
|
76
|
+
eventVersion: 1,
|
|
77
|
+
payload: { name },
|
|
78
|
+
metadata: { userId: admin.id },
|
|
79
|
+
createdBy: admin.id,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let stack: TestStack;
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
stack = await setupTestStack({ features: [drainTestFeature], systemHooks: [] });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
observed = [];
|
|
91
|
+
await resetEventStore(stack);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
await stack.cleanup();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("drainEventConsumers", () => {
|
|
99
|
+
test("backlog larger than the dispatcher batch size gets fully processed", async () => {
|
|
100
|
+
const names = Array.from({ length: 220 }, (_, i) => `evt-${i}`);
|
|
101
|
+
for (const name of names) await appendTick(name);
|
|
102
|
+
|
|
103
|
+
await drainEventConsumers({ db: stack.db, eventDispatcher: stack.eventDispatcher }, [
|
|
104
|
+
TRACKER_QN,
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
expect(observed.sort()).toEqual([...names].sort());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an event written after the HWM snapshot does not block", async () => {
|
|
111
|
+
await appendTick(CASCADE_TRIGGER_NAME);
|
|
112
|
+
|
|
113
|
+
await drainEventConsumers({ db: stack.db, eventDispatcher: stack.eventDispatcher }, [
|
|
114
|
+
TRACKER_QN,
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
// The cascade event was written by the handler DURING the drain, after
|
|
118
|
+
// the target snapshot — proven never processed (would throw otherwise).
|
|
119
|
+
expect(observed).toEqual([CASCADE_TRIGGER_NAME]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a throwing consumer exhausts the budget and the error contains lastError", async () => {
|
|
123
|
+
await appendTick(POISON_TARGET_NAME);
|
|
124
|
+
|
|
125
|
+
let caughtMessage: string | undefined;
|
|
126
|
+
try {
|
|
127
|
+
await drainEventConsumers(
|
|
128
|
+
{ db: stack.db, eventDispatcher: stack.eventDispatcher },
|
|
129
|
+
[POISON_QN],
|
|
130
|
+
{ maxPasses: 2 },
|
|
131
|
+
);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
caughtMessage = e instanceof Error ? e.message : String(e);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
expect(caughtMessage).toBeDefined();
|
|
137
|
+
expect(caughtMessage).toContain(POISON_QN);
|
|
138
|
+
expect(caughtMessage).toContain(`lastError=boom: ${POISON_TARGET_NAME}`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an unknown consumer name throws", async () => {
|
|
142
|
+
let caughtMessage: string | undefined;
|
|
143
|
+
try {
|
|
144
|
+
await drainEventConsumers({ db: stack.db, eventDispatcher: stack.eventDispatcher }, [
|
|
145
|
+
"drain-test:projection:does-not-exist",
|
|
146
|
+
]);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
caughtMessage = e instanceof Error ? e.message : String(e);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
expect(caughtMessage).toContain(
|
|
152
|
+
'consumer "drain-test:projection:does-not-exist" not registered or per-instance',
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("missing eventDispatcher raises a clear error", async () => {
|
|
157
|
+
let caughtMessage: string | undefined;
|
|
158
|
+
try {
|
|
159
|
+
await drainEventConsumers({ db: stack.db, eventDispatcher: undefined }, [TRACKER_QN]);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
caughtMessage = e instanceof Error ? e.message : String(e);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
expect(caughtMessage).toContain("eventDispatcher is undefined");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -8,6 +8,7 @@ import * as z from "zod";
|
|
|
8
8
|
import { defineFeature } from "../../engine";
|
|
9
9
|
import { InternalError, writeFailure } from "../../errors";
|
|
10
10
|
import { waitFor } from "../../testing";
|
|
11
|
+
import { generateId } from "../../utils";
|
|
11
12
|
import { setupTestStack, type TestStack } from "../test-stack";
|
|
12
13
|
import { TestUsers } from "../test-users";
|
|
13
14
|
|
|
@@ -57,11 +58,22 @@ const jobContextFeature = defineFeature("jobcontextcheck", (r) => {
|
|
|
57
58
|
});
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
const laneIsolationRuns: Array<{ from: unknown }> = [];
|
|
62
|
+
|
|
63
|
+
const laneIsolationFeature = defineFeature("laneisolation", (r) => {
|
|
64
|
+
r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload) => {
|
|
65
|
+
laneIsolationRuns.push({ from: payload["from"] });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
60
69
|
let stack: TestStack | undefined;
|
|
70
|
+
let secondStack: TestStack | undefined;
|
|
61
71
|
|
|
62
72
|
afterEach(async () => {
|
|
63
73
|
if (stack) await stack.cleanup();
|
|
74
|
+
if (secondStack) await secondStack.cleanup();
|
|
64
75
|
stack = undefined;
|
|
76
|
+
secondStack = undefined;
|
|
65
77
|
});
|
|
66
78
|
|
|
67
79
|
describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", () => {
|
|
@@ -98,6 +110,54 @@ describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", ()
|
|
|
98
110
|
});
|
|
99
111
|
});
|
|
100
112
|
|
|
113
|
+
// Stack "api" consumes only the api lane, so its worker-lane job can only run
|
|
114
|
+
// if the "worker" stack's consumer reads the same queue.
|
|
115
|
+
describe("setupTestStack({ jobs }) queue isolation between parallel stacks", () => {
|
|
116
|
+
async function dispatchFromApiStackThenRunOwnJob(): Promise<void> {
|
|
117
|
+
await stack?.jobRunner?.dispatch("laneisolation:job:record", { from: "api-stack" });
|
|
118
|
+
await secondStack?.jobRunner?.dispatch("laneisolation:job:record", { from: "worker-stack" });
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(laneIsolationRuns.some((run) => run.from === "worker-stack")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("without an explicit queueNamePrefix, one stack's consumer never runs another stack's job", async () => {
|
|
125
|
+
laneIsolationRuns.length = 0;
|
|
126
|
+
[stack, secondStack] = await Promise.all([
|
|
127
|
+
setupTestStack({ features: [laneIsolationFeature], jobs: { consumerLane: "api" } }),
|
|
128
|
+
setupTestStack({ features: [laneIsolationFeature], jobs: { consumerLane: "worker" } }),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
await dispatchFromApiStackThenRunOwnJob();
|
|
132
|
+
|
|
133
|
+
expect(laneIsolationRuns.map((run) => run.from)).toEqual(["worker-stack"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an explicit shared queueNamePrefix wins, so both stacks read one queue", async () => {
|
|
137
|
+
laneIsolationRuns.length = 0;
|
|
138
|
+
const queueNamePrefix = `laneisolation-shared-${generateId()}`;
|
|
139
|
+
[stack, secondStack] = await Promise.all([
|
|
140
|
+
setupTestStack({
|
|
141
|
+
features: [laneIsolationFeature],
|
|
142
|
+
jobs: { consumerLane: "api", queueNamePrefix },
|
|
143
|
+
}),
|
|
144
|
+
setupTestStack({
|
|
145
|
+
features: [laneIsolationFeature],
|
|
146
|
+
jobs: { consumerLane: "worker", queueNamePrefix },
|
|
147
|
+
}),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
await dispatchFromApiStackThenRunOwnJob();
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(laneIsolationRuns.map((run) => run.from).sort()).toEqual([
|
|
154
|
+
"api-stack",
|
|
155
|
+
"worker-stack",
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
101
161
|
describe("setupTestStack({ jobs }) job context matches the request-path context", () => {
|
|
102
162
|
test("job handler's context carries tracer + searchAdapter, mirrors prod's effectiveFeatures-less job context", async () => {
|
|
103
163
|
jobContextFields.length = 0;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { DbConnection } from "../db";
|
|
2
|
+
import { getEventsHighWaterMark } from "../event-store";
|
|
3
|
+
import { type ConsumerRecoveryState, getConsumerState } from "../pipeline";
|
|
4
|
+
import type { TestStack } from "./test-stack";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_PASSES = 25;
|
|
7
|
+
|
|
8
|
+
// Drives eventDispatcher.runOnce() until every named consumer has caught up
|
|
9
|
+
// to the events high-water mark captured at call time. No "no progress ⇒
|
|
10
|
+
// throw" shortcut: a pass can legitimately advance zero cursors even though
|
|
11
|
+
// work remains — runOnce's FOR UPDATE SKIP LOCKED lets a concurrent pass
|
|
12
|
+
// (the test stack's own 50ms poll timer) hold a consumer's row, and a
|
|
13
|
+
// consumer coming off a thrown-pass backoff sits idle until its retryAtMs
|
|
14
|
+
// elapses. A bounded pass budget tolerates both without pretending to detect
|
|
15
|
+
// "stuck" from a single empty pass. The target is a snapshot: events a
|
|
16
|
+
// consumer's own handler writes afterward (cascades) are not waited on.
|
|
17
|
+
export async function drainEventConsumers(
|
|
18
|
+
stack: Pick<TestStack, "db" | "eventDispatcher">,
|
|
19
|
+
consumerNames: readonly [string, ...string[]],
|
|
20
|
+
options?: { readonly maxPasses?: number },
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const { db, eventDispatcher } = stack;
|
|
23
|
+
if (!eventDispatcher) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"drainEventConsumers: stack.eventDispatcher is undefined — no system consumer or " +
|
|
26
|
+
"multiStreamProjection is wired for this stack.",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const maxPasses = options?.maxPasses ?? DEFAULT_MAX_PASSES;
|
|
30
|
+
const target = await getEventsHighWaterMark(db);
|
|
31
|
+
|
|
32
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
33
|
+
await eventDispatcher.runOnce();
|
|
34
|
+
const states = await loadConsumerStates(db, consumerNames);
|
|
35
|
+
// skip: every named consumer has already caught up to the target snapshot — the
|
|
36
|
+
// remaining budgeted passes are unnecessary, not a swallowed error.
|
|
37
|
+
if (states.every((state) => state.lastProcessedEventId >= target)) return;
|
|
38
|
+
if (pass === maxPasses - 1) {
|
|
39
|
+
throw budgetExhaustedError(maxPasses, target, states);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadConsumerStates(
|
|
45
|
+
db: DbConnection,
|
|
46
|
+
consumerNames: readonly [string, ...string[]],
|
|
47
|
+
): Promise<readonly ConsumerRecoveryState[]> {
|
|
48
|
+
return Promise.all(
|
|
49
|
+
consumerNames.map(async (name) => {
|
|
50
|
+
const state = await getConsumerState(db, name);
|
|
51
|
+
if (!state) {
|
|
52
|
+
throw new Error(`drainEventConsumers: consumer "${name}" not registered or per-instance`);
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function budgetExhaustedError(
|
|
60
|
+
maxPasses: number,
|
|
61
|
+
target: bigint,
|
|
62
|
+
states: readonly ConsumerRecoveryState[],
|
|
63
|
+
): Error {
|
|
64
|
+
const unfinished = states.filter((state) => state.lastProcessedEventId < target);
|
|
65
|
+
const lines = unfinished.map(
|
|
66
|
+
(state) =>
|
|
67
|
+
` ${state.name}: lastProcessedEventId=${state.lastProcessedEventId} target=${target} ` +
|
|
68
|
+
`status=${state.status} attempts=${state.attempts} lastError=${state.lastError ?? "null"}`,
|
|
69
|
+
);
|
|
70
|
+
return new Error(
|
|
71
|
+
`drainEventConsumers: budget of ${maxPasses} passes exhausted, consumers still behind target:\n` +
|
|
72
|
+
lines.join("\n"),
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/stack/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
createTestDb,
|
|
15
15
|
type TestDb,
|
|
16
16
|
} from "./db";
|
|
17
|
+
export { drainEventConsumers } from "./drain-event-consumers";
|
|
17
18
|
export { createEventCollector, type EventCollector } from "./event-collector";
|
|
18
19
|
export { pushEntityProjectionTables } from "./push-entity-projection-tables";
|
|
19
20
|
export { createTestRedis, type TestRedis } from "./redis";
|
package/src/stack/redis.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type TestRedis = {
|
|
|
10
10
|
// kumiko-framework #1036/2) — callers needing a fresh connection should use
|
|
11
11
|
// this raw string, not rebuild one from parsed options.
|
|
12
12
|
redisUrl: string;
|
|
13
|
+
/** The per-test `keyPrefix` on `redis`, e.g. `kt:1a2b3c4d:`. */
|
|
14
|
+
keyPrefix: string;
|
|
13
15
|
/** Delete every key this test created (prefix-scoped). Replaces the old
|
|
14
16
|
* `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
|
|
15
17
|
flushNamespace: () => Promise<void>;
|
|
@@ -43,6 +45,7 @@ export async function createTestRedis(): Promise<TestRedis> {
|
|
|
43
45
|
return {
|
|
44
46
|
redis,
|
|
45
47
|
redisUrl,
|
|
48
|
+
keyPrefix: prefix,
|
|
46
49
|
flushNamespace,
|
|
47
50
|
cleanup: async () => {
|
|
48
51
|
await flushNamespace();
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -52,7 +52,8 @@ export type TestStack = {
|
|
|
52
52
|
// event-triggered job reaching for ctx.files die where the request path works.
|
|
53
53
|
context: AppContext;
|
|
54
54
|
// Present whenever a system consumer (SSE, Search) or
|
|
55
|
-
// r.multiStreamProjection is wired. Tests drain it via runOnce()
|
|
55
|
+
// r.multiStreamProjection is wired. Tests drain it via runOnce() (or the
|
|
56
|
+
// drainEventConsumers helper for a named-consumer backlog) for
|
|
56
57
|
// deterministic assertion — no timer-induced flakiness.
|
|
57
58
|
eventDispatcher?: EventDispatcher;
|
|
58
59
|
// Only set when the caller passed `lifecycle` via options. Tests that
|
|
@@ -167,6 +168,8 @@ export type TestStackOptions = {
|
|
|
167
168
|
* `runOnBoot`/cron jobs against a caller-owned consumer). */
|
|
168
169
|
jobs?: {
|
|
169
170
|
consumerLane?: JobRunIn;
|
|
171
|
+
/** Default: unique per stack, derived from its test Redis keyPrefix. Pass
|
|
172
|
+
* the same value to every runner that has to share this stack's queues. */
|
|
170
173
|
queueNamePrefix?: string;
|
|
171
174
|
/** Source of active tenant ids for `perTenant: true` jobs. Omit when the
|
|
172
175
|
* tenant feature is among `options.features` — the framework then
|
|
@@ -188,6 +191,13 @@ export type TestStackOptions = {
|
|
|
188
191
|
|
|
189
192
|
const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
|
|
190
193
|
|
|
194
|
+
// BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
|
|
195
|
+
// parallel stacks sharing the prod default queue name would consume each
|
|
196
|
+
// other's jobs. BullMQ rejects `:` in queue names.
|
|
197
|
+
function queueNamePrefixForTestRedis(keyPrefix: string): string {
|
|
198
|
+
return keyPrefix.split(":").filter(Boolean).join("-");
|
|
199
|
+
}
|
|
200
|
+
|
|
191
201
|
export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
|
|
192
202
|
const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
|
|
193
203
|
const enabledHooks = options.systemHooks ?? ["sse", "search"];
|
|
@@ -355,9 +365,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
355
365
|
// password/username/tls/path (pr-review kumiko-framework #1036/2).
|
|
356
366
|
redisUrl: testRedis.redisUrl,
|
|
357
367
|
...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}),
|
|
368
|
+
queueNamePrefix:
|
|
369
|
+
options.jobs.queueNamePrefix ?? queueNamePrefixForTestRedis(testRedis.keyPrefix),
|
|
361
370
|
...(options.jobs.getActiveTenantIds !== undefined && {
|
|
362
371
|
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
363
372
|
}),
|
package/src/upgrade-cli.ts
CHANGED
|
@@ -304,9 +304,6 @@ function readMarkerVersion(targetDir: string): string | null {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
/** Highest pending version strictly below the earliest open manual breaking
|
|
308
|
-
* entry (no codemod). Falls back to highest non-breaking pending when every
|
|
309
|
-
* pending version is at/after that manual — never advances onto the manual. */
|
|
310
307
|
/** Highest pending version strictly below the earliest open manual breaking
|
|
311
308
|
* entry (no codemod). Falls back to highest non-breaking pending when every
|
|
312
309
|
* pending version is at/after that manual. If only manuals remain, returns
|
|
@@ -340,6 +337,21 @@ function markerVersionForPending(
|
|
|
340
337
|
return fallbackInstalled;
|
|
341
338
|
}
|
|
342
339
|
|
|
340
|
+
function logManualMarkerOutcome(
|
|
341
|
+
out: UpgradeCliOut,
|
|
342
|
+
manualEntries: readonly ChangelogEntry[],
|
|
343
|
+
markerVersion: string,
|
|
344
|
+
): void {
|
|
345
|
+
if (manualEntries.length > 0) {
|
|
346
|
+
const markerHeldBack = manualEntries.some((e) => compareVersions(e.version, markerVersion) > 0);
|
|
347
|
+
out.log(
|
|
348
|
+
markerHeldBack
|
|
349
|
+
? ` ⚠ Marker stays at ${markerVersion}, so the upgrade guard keeps listing the manual change(s) above. Migrate them by hand, then run --apply again to acknowledge them.`
|
|
350
|
+
: ` ⚠ Marker moved past the manual change(s) above to ${markerVersion}; the upgrade guard no longer lists them, so make sure they are migrated.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
343
355
|
// Runs every pending breaking entry's codemod, oldest version first (so a
|
|
344
356
|
// later codemod can assume an earlier one already ran). Stops on the first
|
|
345
357
|
// failure — no partial marker. Writes the marker whenever dryRun is false —
|
|
@@ -394,6 +406,7 @@ async function applyCodemods(
|
|
|
394
406
|
codemods: [],
|
|
395
407
|
});
|
|
396
408
|
out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
|
|
409
|
+
logManualMarkerOutcome(out, manualEntries, markerVer);
|
|
397
410
|
}
|
|
398
411
|
return 0;
|
|
399
412
|
}
|
|
@@ -442,6 +455,7 @@ async function applyCodemods(
|
|
|
442
455
|
if (pendingManual.length > 0) {
|
|
443
456
|
out.log(` ⚠ ${pendingManual.length} breaking change(s) still need manual migration.`);
|
|
444
457
|
}
|
|
458
|
+
logManualMarkerOutcome(out, manualEntries, latestVersion);
|
|
445
459
|
return 0;
|
|
446
460
|
}
|
|
447
461
|
|