@cosmicdrift/kumiko-server-runtime 0.319.0 → 0.321.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 -8
- package/src/__tests__/compose-features-wiring.integration.test.ts +159 -1
- package/src/__tests__/require-env.test.ts +5 -0
- package/src/__tests__/resolve-auth-mail.test.ts +75 -0
- package/src/__tests__/run-prod-app-spec.test.ts +56 -3
- package/src/__tests__/run-prod-app-static-files.test.ts +5 -19
- package/src/__tests__/run-prod-app.integration.test.ts +159 -38
- package/src/bun-serve-options.ts +41 -3
- package/src/changes.json +39 -0
- package/src/compose-features.ts +2 -2
- package/src/index.ts +1 -1
- package/src/run-prod-app-boot-context.ts +55 -15
- package/src/run-prod-app-static-files.ts +40 -43
- package/src/run-prod-app.ts +92 -84
- package/src/security-headers.ts +4 -4
- package/src/try-hono-first.ts +12 -6
- package/src/__tests__/inject-schema.test.ts +0 -100
- package/src/inject-schema.ts +0 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.321.0",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -62,10 +62,6 @@
|
|
|
62
62
|
"types": "./src/extra-routes-deps.ts",
|
|
63
63
|
"default": "./src/extra-routes-deps.ts"
|
|
64
64
|
},
|
|
65
|
-
"./inject-schema": {
|
|
66
|
-
"types": "./src/inject-schema.ts",
|
|
67
|
-
"default": "./src/inject-schema.ts"
|
|
68
|
-
},
|
|
69
65
|
"./resolve-tailwind-cli": {
|
|
70
66
|
"types": "./src/resolve-tailwind-cli.ts",
|
|
71
67
|
"default": "./src/resolve-tailwind-cli.ts"
|
|
@@ -80,9 +76,9 @@
|
|
|
80
76
|
}
|
|
81
77
|
},
|
|
82
78
|
"dependencies": {
|
|
83
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
79
|
+
"@cosmicdrift/kumiko-bundled-features": "0.321.0",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.321.0",
|
|
81
|
+
"@cosmicdrift/kumiko-headless": "0.321.0",
|
|
86
82
|
"temporal-polyfill": "^0.3.2"
|
|
87
83
|
},
|
|
88
84
|
"publishConfig": {
|
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
AuthErrors,
|
|
28
28
|
AuthHandlers,
|
|
29
29
|
hashPassword,
|
|
30
|
+
signToken,
|
|
31
|
+
TokenPurpose,
|
|
32
|
+
verifyToken,
|
|
30
33
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
31
34
|
import {
|
|
32
35
|
createChannelEmailFeature,
|
|
@@ -50,7 +53,9 @@ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-featu
|
|
|
50
53
|
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
51
54
|
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
52
55
|
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
56
|
+
import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
53
57
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
58
|
+
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
54
59
|
import {
|
|
55
60
|
setupTestStack,
|
|
56
61
|
type TestStack,
|
|
@@ -59,7 +64,13 @@ import {
|
|
|
59
64
|
unsafePushTables,
|
|
60
65
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
61
66
|
import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
62
|
-
import { composeFeatures } from "../compose-features";
|
|
67
|
+
import { buildComposeAuthOptions, composeFeatures } from "../compose-features";
|
|
68
|
+
import type { RunProdAppAuthOptions } from "../run-prod-app";
|
|
69
|
+
import { resolveAuthMail } from "../run-prod-app-boot-context";
|
|
70
|
+
|
|
71
|
+
// resolveMailLocale only recognizes locales with a registered mail bundle;
|
|
72
|
+
// an empty bundle is enough to make "de" negotiable here.
|
|
73
|
+
registerMailTranslations("de", {});
|
|
63
74
|
|
|
64
75
|
const RESET_HMAC = randomBytes(32).toString("base64");
|
|
65
76
|
const VERIFY_HMAC = randomBytes(32).toString("base64");
|
|
@@ -150,6 +161,79 @@ async function bootStack(
|
|
|
150
161
|
return { stack, emailTransport };
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
// Boots via resolveAuthMail's `auth.mail` convenience with its own
|
|
165
|
+
// hmacSecret, distinct from the resolveAuthMail call-site secret.
|
|
166
|
+
async function bootMailConventionStack(
|
|
167
|
+
mailHmacSecret: string,
|
|
168
|
+
callSiteSecret = "unused-call-site-secret",
|
|
169
|
+
): Promise<{ stack: TestStack; emailTransport: ReturnType<typeof createInMemoryTransport> }> {
|
|
170
|
+
const emailTransport = createInMemoryTransport();
|
|
171
|
+
|
|
172
|
+
const mailAuth = resolveAuthMail<RunProdAppAuthOptions>(
|
|
173
|
+
{
|
|
174
|
+
admin: {
|
|
175
|
+
email: "admin@example.com",
|
|
176
|
+
password: "pw-long-enough",
|
|
177
|
+
displayName: "Admin",
|
|
178
|
+
memberships: [],
|
|
179
|
+
},
|
|
180
|
+
mail: {
|
|
181
|
+
baseUrl: "https://app.example.com",
|
|
182
|
+
hmacSecret: mailHmacSecret,
|
|
183
|
+
paths: {
|
|
184
|
+
resetPassword: (locale) => (locale === "de" ? "/de/reset-password" : "/reset-password"),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
callSiteSecret,
|
|
189
|
+
{ SMTP_HOST: "localhost" },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const features = composeFeatures(
|
|
193
|
+
[
|
|
194
|
+
createTemplateResolverFeature(),
|
|
195
|
+
createRendererFoundationFeature(),
|
|
196
|
+
createDeliveryFeature(),
|
|
197
|
+
createRendererSimpleFeature(),
|
|
198
|
+
createChannelEmailFeature({
|
|
199
|
+
transport: emailTransport,
|
|
200
|
+
renderer: simpleRenderer,
|
|
201
|
+
resolveEmail: async () => "unused@test.local",
|
|
202
|
+
}),
|
|
203
|
+
],
|
|
204
|
+
{
|
|
205
|
+
includeBundled: true,
|
|
206
|
+
authOptions: buildComposeAuthOptions(mailAuth),
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const stack = await setupTestStack({
|
|
211
|
+
features,
|
|
212
|
+
extraContext: (deps) => ({
|
|
213
|
+
...createDeliveryTestContext(deps),
|
|
214
|
+
configResolver: createConfigResolver(),
|
|
215
|
+
}),
|
|
216
|
+
authConfig: {
|
|
217
|
+
membershipQuery: "tenant:query:memberships",
|
|
218
|
+
loginHandler: AuthHandlers.login,
|
|
219
|
+
passwordReset: {
|
|
220
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
221
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
227
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
228
|
+
await unsafePushTables(stack.db, {
|
|
229
|
+
configValuesTable,
|
|
230
|
+
tenantMembershipsTable,
|
|
231
|
+
notificationPreferencesTable,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return { stack, emailTransport };
|
|
235
|
+
}
|
|
236
|
+
|
|
153
237
|
async function seedUser(
|
|
154
238
|
stack: TestStack,
|
|
155
239
|
opts: { email: string; password: string },
|
|
@@ -449,3 +533,77 @@ describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
|
|
|
449
533
|
expect(suite.emailTransport.sent).toHaveLength(0);
|
|
450
534
|
});
|
|
451
535
|
});
|
|
536
|
+
|
|
537
|
+
describe("composeFeatures wiring — auth.mail convenience locale + secret", () => {
|
|
538
|
+
// mailHmacSecret and callSiteSecret are deliberately different values: a
|
|
539
|
+
// live reset roundtrip must validate against mailHmacSecret, not callSiteSecret.
|
|
540
|
+
const mailHmacSecret = randomBytes(32).toString("base64");
|
|
541
|
+
const callSiteSecret = randomBytes(32).toString("base64");
|
|
542
|
+
let suite: Awaited<ReturnType<typeof bootMailConventionStack>>;
|
|
543
|
+
|
|
544
|
+
beforeAll(async () => {
|
|
545
|
+
suite = await bootMailConventionStack(mailHmacSecret, callSiteSecret);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
afterAll(async () => {
|
|
549
|
+
await suite.stack.cleanup();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
beforeEach(async () => {
|
|
553
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
554
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
555
|
+
suite.emailTransport.sent.length = 0;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("X-Locale: de → reset link carries the localized path and its token is accepted", async () => {
|
|
559
|
+
await seedUser(suite.stack, { email: "greta@example.com", password: "old-password-1234" });
|
|
560
|
+
|
|
561
|
+
const res = await suite.stack.http.raw(
|
|
562
|
+
"POST",
|
|
563
|
+
"/api/auth/request-password-reset",
|
|
564
|
+
{ email: "greta@example.com" },
|
|
565
|
+
{ [LOCALE_HEADER_NAME]: "de" },
|
|
566
|
+
);
|
|
567
|
+
expect(res.status).toBe(200);
|
|
568
|
+
const sent = suite.emailTransport.sent[0];
|
|
569
|
+
if (!sent) throw new Error("no email sent");
|
|
570
|
+
const resetUrl = tokenUrlFromHtml(sent.html);
|
|
571
|
+
expect(resetUrl.pathname).toBe("/de/reset-password");
|
|
572
|
+
const token = resetUrl.searchParams.get("token");
|
|
573
|
+
if (!token) throw new Error("no token in mail link");
|
|
574
|
+
expect(verifyToken(token, TokenPurpose.passwordReset, mailHmacSecret).ok).toBe(true);
|
|
575
|
+
expect(verifyToken(token, TokenPurpose.passwordReset, callSiteSecret).ok).toBe(false);
|
|
576
|
+
|
|
577
|
+
const confirmRes = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
|
|
578
|
+
token,
|
|
579
|
+
newPassword: "brand-new-pw-9876",
|
|
580
|
+
});
|
|
581
|
+
expect(confirmRes.status).toBe(200);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("no X-Locale → reset link falls back to the default path", async () => {
|
|
585
|
+
await seedUser(suite.stack, { email: "henry@example.com", password: "old-password-1234" });
|
|
586
|
+
|
|
587
|
+
const res = await suite.stack.http.raw("POST", "/api/auth/request-password-reset", {
|
|
588
|
+
email: "henry@example.com",
|
|
589
|
+
});
|
|
590
|
+
expect(res.status).toBe(200);
|
|
591
|
+
const sent = suite.emailTransport.sent[0];
|
|
592
|
+
if (!sent) throw new Error("no email sent");
|
|
593
|
+
expect(tokenUrlFromHtml(sent.html).pathname).toBe("/reset-password");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("a token signed with the resolveAuthMail call-site secret is rejected", async () => {
|
|
597
|
+
const seeded = await seedUser(suite.stack, {
|
|
598
|
+
email: "ivy@example.com",
|
|
599
|
+
password: "old-password-1234",
|
|
600
|
+
});
|
|
601
|
+
const { token } = signToken(seeded.id, TokenPurpose.passwordReset, 15, callSiteSecret);
|
|
602
|
+
|
|
603
|
+
const res = await suite.stack.http.raw("POST", "/api/auth/reset-password", {
|
|
604
|
+
token,
|
|
605
|
+
newPassword: "should-not-stick-1234",
|
|
606
|
+
});
|
|
607
|
+
expect(res.status).toBe(422);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
// missing local .env var.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { requireEnv as requireEnvFromPackageExport } from "@cosmicdrift/kumiko-server-runtime";
|
|
8
9
|
import { requireEnv } from "../run-prod-app";
|
|
9
10
|
|
|
10
11
|
describe("requireEnv", () => {
|
|
12
|
+
test("is part of the package's public export, not just the internal run-prod-app module", () => {
|
|
13
|
+
expect(requireEnvFromPackageExport).toBe(requireEnv);
|
|
14
|
+
});
|
|
15
|
+
|
|
11
16
|
test("default context (runProdApp) gives container/production advice", () => {
|
|
12
17
|
expect(() => requireEnv("MISSING_VAR", {})).toThrow(/container env.*Coolify secrets/);
|
|
13
18
|
});
|
|
@@ -66,4 +66,79 @@ describe("resolveAuthMail", () => {
|
|
|
66
66
|
expect(out.passwordReset?.appUrl).toBe("https://app.example.com/pw");
|
|
67
67
|
expect(out.emailVerification?.appUrl).toBe("https://app.example.com/verify-email");
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
test("function path → locale-aware appUrl, still signed with the resolved hmacSecret", () => {
|
|
71
|
+
const localeAware: RunProdAppAuthOptions = {
|
|
72
|
+
admin,
|
|
73
|
+
mail: {
|
|
74
|
+
baseUrl: "https://app.example.com",
|
|
75
|
+
paths: {
|
|
76
|
+
resetPassword: (locale) => (locale === "de" ? "/de/reset-password" : "/reset-password"),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const out = resolveAuthMail(localeAware, "secret", { SMTP_HOST: "localhost" });
|
|
81
|
+
const appUrl = out.passwordReset?.appUrl;
|
|
82
|
+
if (typeof appUrl !== "function") throw new Error("expected a locale-aware appUrl function");
|
|
83
|
+
expect(appUrl("de")).toBe("https://app.example.com/de/reset-password");
|
|
84
|
+
expect(appUrl("en")).toBe("https://app.example.com/reset-password");
|
|
85
|
+
expect(out.passwordReset?.hmacSecret).toBe("secret");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("mail.hmacSecret overrides the call-site secret for passwordReset and emailVerification", () => {
|
|
89
|
+
const dedicatedSecret: RunProdAppAuthOptions = {
|
|
90
|
+
admin,
|
|
91
|
+
mail: { baseUrl: "https://app.example.com", hmacSecret: "mail-only-secret" },
|
|
92
|
+
};
|
|
93
|
+
const out = resolveAuthMail(dedicatedSecret, "jwt-secret", { SMTP_HOST: "localhost" });
|
|
94
|
+
expect(out.passwordReset?.hmacSecret).toBe("mail-only-secret");
|
|
95
|
+
expect(out.emailVerification?.hmacSecret).toBe("mail-only-secret");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("no mail.hmacSecret → falls back to the call-site secret", () => {
|
|
99
|
+
const out = resolveAuthMail(withMail, "jwt-secret", { SMTP_HOST: "localhost" });
|
|
100
|
+
expect(out.passwordReset?.hmacSecret).toBe("jwt-secret");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("explicit passwordReset/emailVerification without hmacSecret is backfilled from mail.hmacSecret", () => {
|
|
104
|
+
const explicitNoSecret: RunProdAppAuthOptions = {
|
|
105
|
+
admin,
|
|
106
|
+
mail: { baseUrl: "https://app.example.com", hmacSecret: "mail-only-secret" },
|
|
107
|
+
passwordReset: { appUrl: "https://custom.example.com/pw" },
|
|
108
|
+
emailVerification: { appUrl: "https://custom.example.com/verify" },
|
|
109
|
+
};
|
|
110
|
+
const out = resolveAuthMail(explicitNoSecret, "jwt-secret", { SMTP_HOST: "localhost" });
|
|
111
|
+
expect(out.passwordReset?.appUrl).toBe("https://custom.example.com/pw");
|
|
112
|
+
expect(out.passwordReset?.hmacSecret).toBe("mail-only-secret");
|
|
113
|
+
expect(out.emailVerification?.hmacSecret).toBe("mail-only-secret");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("explicit passwordReset with its own hmacSecret wins over mail.hmacSecret", () => {
|
|
117
|
+
const explicitOwnSecret: RunProdAppAuthOptions = {
|
|
118
|
+
admin,
|
|
119
|
+
mail: { baseUrl: "https://app.example.com", hmacSecret: "mail-only-secret" },
|
|
120
|
+
passwordReset: { appUrl: "https://custom.example.com/pw", hmacSecret: "app-own-secret" },
|
|
121
|
+
};
|
|
122
|
+
const out = resolveAuthMail(explicitOwnSecret, "jwt-secret", { SMTP_HOST: "localhost" });
|
|
123
|
+
expect(out.passwordReset?.hmacSecret).toBe("app-own-secret");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("explicit passwordReset without hmacSecret and no mail block falls back to the call-site secret", () => {
|
|
127
|
+
const explicitNoMail: RunProdAppAuthOptions = {
|
|
128
|
+
admin,
|
|
129
|
+
passwordReset: { appUrl: "https://custom.example.com/pw" },
|
|
130
|
+
};
|
|
131
|
+
const out = resolveAuthMail(explicitNoMail, "jwt-secret", {});
|
|
132
|
+
expect(out.passwordReset?.hmacSecret).toBe("jwt-secret");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("explicit passwordReset without hmacSecret is still backfilled when SMTP_HOST is unset", () => {
|
|
136
|
+
const explicitNoSmtp: RunProdAppAuthOptions = {
|
|
137
|
+
admin,
|
|
138
|
+
mail: { baseUrl: "https://app.example.com", hmacSecret: "mail-only-secret" },
|
|
139
|
+
passwordReset: { appUrl: "https://custom.example.com/pw" },
|
|
140
|
+
};
|
|
141
|
+
const out = resolveAuthMail(explicitNoSmtp, "jwt-secret", {});
|
|
142
|
+
expect(out.passwordReset?.hmacSecret).toBe("mail-only-secret");
|
|
143
|
+
});
|
|
69
144
|
});
|
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { SSE_HEARTBEAT_INTERVAL_MS } from "@cosmicdrift/kumiko-framework/api";
|
|
9
|
+
import {
|
|
10
|
+
createEntity,
|
|
11
|
+
createFileField,
|
|
12
|
+
createRegistry,
|
|
13
|
+
defineFeature,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { createFilesFeature, resolveMaxUploadBodyBytes } from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES,
|
|
18
|
+
resolveDerivedMaxRequestBodySize,
|
|
19
|
+
} from "../bun-serve-options";
|
|
9
20
|
import { buildBunServeOptions } from "../run-prod-app";
|
|
10
21
|
|
|
11
22
|
describe("Bun.serve options for production", () => {
|
|
@@ -19,11 +30,53 @@ describe("Bun.serve options for production", () => {
|
|
|
19
30
|
expect(opts.idleTimeout).toBe(0);
|
|
20
31
|
});
|
|
21
32
|
|
|
22
|
-
test("port
|
|
23
|
-
|
|
33
|
+
test("port wird 1:1 durchgereicht, fetch reicht req + Socket-Adresse an den Handler weiter", async () => {
|
|
34
|
+
let received: [Request, string | undefined] | undefined;
|
|
35
|
+
const fetchHandler = (req: Request, socketAddress?: string) => {
|
|
36
|
+
received = [req, socketAddress];
|
|
37
|
+
return new Response("test");
|
|
38
|
+
};
|
|
24
39
|
const opts = buildBunServeOptions(3000, fetchHandler);
|
|
25
40
|
expect(opts.port).toBe(3000);
|
|
26
|
-
|
|
41
|
+
const req = new Request("http://localhost/");
|
|
42
|
+
const fakeServer = { requestIP: () => ({ address: "203.0.113.1", port: 1, family: "IPv4" }) };
|
|
43
|
+
await opts.fetch(req, fakeServer as unknown as Bun.Server<unknown>);
|
|
44
|
+
expect(received?.[0]).toBe(req);
|
|
45
|
+
expect(received?.[1]).toBe("203.0.113.1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("maxRequestBodySize defaults well below Bun's own 128 MiB", () => {
|
|
49
|
+
// Bun.serve's undocumented-in-our-code default (128 MiB) buffers every
|
|
50
|
+
// concurrent request up to that size before any app-level body-limit
|
|
51
|
+
// middleware runs. Pins the lowered default against a silent revert to
|
|
52
|
+
// "just let Bun handle it".
|
|
53
|
+
const opts = buildBunServeOptions(0, () => new Response("ok"));
|
|
54
|
+
expect(opts.maxRequestBodySize).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES);
|
|
55
|
+
expect(opts.maxRequestBodySize).toBeLessThan(128 * 1024 * 1024);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("maxRequestBodySize is overridable for apps with larger maxUploadSize", () => {
|
|
59
|
+
const opts = buildBunServeOptions(0, () => new Response("ok"), 64 * 1024 * 1024);
|
|
60
|
+
expect(opts.maxRequestBodySize).toBe(64 * 1024 * 1024);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("resolveDerivedMaxRequestBodySize", () => {
|
|
65
|
+
test("stays above the files route's own derived bodyLimit", () => {
|
|
66
|
+
// Otherwise Bun's bare rejection would fire before the route's own,
|
|
67
|
+
// more specific 413 ever runs.
|
|
68
|
+
const registry = createRegistry([createFilesFeature({ maxUploadSize: "50mb" })]);
|
|
69
|
+
const routeBodyLimit = resolveMaxUploadBodyBytes({ registry, maxUploadSize: "50mb" });
|
|
70
|
+
expect(resolveDerivedMaxRequestBodySize(registry)).toBeGreaterThan(routeBodyLimit);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("accounts for a field maxSize configured above the global default", () => {
|
|
74
|
+
const entity = createEntity({
|
|
75
|
+
fields: { attachment: createFileField({ maxSize: "50mb" }) },
|
|
76
|
+
});
|
|
77
|
+
const feature = defineFeature("attachments", (r) => r.entity("attachment", entity));
|
|
78
|
+
const registry = createRegistry([feature, createFilesFeature()]);
|
|
79
|
+
expect(resolveDerivedMaxRequestBodySize(registry)).toBeGreaterThanOrEqual(50 * 1024 * 1024);
|
|
27
80
|
});
|
|
28
81
|
});
|
|
29
82
|
|
|
@@ -106,7 +106,6 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
106
106
|
const handler = buildStaticFallback(
|
|
107
107
|
() => noRouteMatchedResponse(),
|
|
108
108
|
tmp,
|
|
109
|
-
"{}",
|
|
110
109
|
() => ({ kind: "html", file: "gone.html" }),
|
|
111
110
|
);
|
|
112
111
|
const res = await handler(new Request("http://t/"));
|
|
@@ -118,7 +117,6 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
118
117
|
const notFound = buildStaticFallback(
|
|
119
118
|
() => noRouteMatchedResponse(),
|
|
120
119
|
tmp,
|
|
121
|
-
"{}",
|
|
122
120
|
() => ({ kind: "not-found" }),
|
|
123
121
|
);
|
|
124
122
|
expect((await notFound(new Request("http://t/"))).status).toBe(404);
|
|
@@ -126,7 +124,6 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
126
124
|
const redirect = buildStaticFallback(
|
|
127
125
|
() => noRouteMatchedResponse(),
|
|
128
126
|
tmp,
|
|
129
|
-
"{}",
|
|
130
127
|
() => ({ kind: "redirect", to: "https://example.com/", status: 301 }),
|
|
131
128
|
);
|
|
132
129
|
const res = await redirect(new Request("http://t/"));
|
|
@@ -135,7 +132,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
135
132
|
});
|
|
136
133
|
|
|
137
134
|
test("/api/* always hits apiHandler", async () => {
|
|
138
|
-
const handler = buildStaticFallback(() => new Response("from-api", { status: 200 }), tmp
|
|
135
|
+
const handler = buildStaticFallback(() => new Response("from-api", { status: 200 }), tmp);
|
|
139
136
|
const res = await handler(new Request("http://t/api/query", { method: "POST" }));
|
|
140
137
|
expect(res.status).toBe(200);
|
|
141
138
|
expect(await res.text()).toBe("from-api");
|
|
@@ -143,7 +140,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
143
140
|
|
|
144
141
|
test("serves disk asset under staticDir", async () => {
|
|
145
142
|
await writeFile(join(tmp, "logo.png"), "PNGDATA");
|
|
146
|
-
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp
|
|
143
|
+
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp);
|
|
147
144
|
const res = await handler(new Request("http://t/logo.png"));
|
|
148
145
|
expect(res.status).toBe(200);
|
|
149
146
|
expect(res.headers.get("content-type")).toBe("image/png");
|
|
@@ -153,7 +150,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
153
150
|
test("a request for a directory copied verbatim from public/ falls back to index.html instead of 500ing", async () => {
|
|
154
151
|
await mkdir(join(tmp, "sub"));
|
|
155
152
|
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
156
|
-
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp
|
|
153
|
+
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp);
|
|
157
154
|
const res = await handler(new Request("http://t/sub"));
|
|
158
155
|
expect(res.status).toBe(200);
|
|
159
156
|
expect(await res.text()).toContain("spa-shell");
|
|
@@ -164,11 +161,9 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
164
161
|
const handler = buildStaticFallback(
|
|
165
162
|
() => noRouteMatchedResponse(),
|
|
166
163
|
tmp,
|
|
167
|
-
'{"screens":[]}',
|
|
168
164
|
() => ({
|
|
169
165
|
kind: "html",
|
|
170
166
|
file: "tenant.html",
|
|
171
|
-
injectSchema: false,
|
|
172
167
|
csp: "default-src 'self'",
|
|
173
168
|
}),
|
|
174
169
|
);
|
|
@@ -185,11 +180,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
185
180
|
// matched route (e.g. file-derivatives' public-variant default-deny)
|
|
186
181
|
// that never carries NO_ROUTE_MATCH_HEADER_NAME.
|
|
187
182
|
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
188
|
-
const handler = buildStaticFallback(
|
|
189
|
-
() => new Response("not found", { status: 404 }),
|
|
190
|
-
tmp,
|
|
191
|
-
"{}",
|
|
192
|
-
);
|
|
183
|
+
const handler = buildStaticFallback(() => new Response("not found", { status: 404 }), tmp);
|
|
193
184
|
const res = await handler(new Request("http://t/files/deadbeef/thumb"));
|
|
194
185
|
expect(res.status).toBe(404);
|
|
195
186
|
expect(await res.text()).toBe("not found");
|
|
@@ -197,7 +188,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
197
188
|
|
|
198
189
|
test("an unknown SPA route with no matching handler still gets the SPA shell", async () => {
|
|
199
190
|
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
200
|
-
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp
|
|
191
|
+
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp);
|
|
201
192
|
const res = await handler(new Request("http://t/some/client-side/route"));
|
|
202
193
|
expect(res.status).toBe(200);
|
|
203
194
|
expect(await res.text()).toContain("spa-shell");
|
|
@@ -230,7 +221,6 @@ describe("buildStaticFallback resolvePageHead", () => {
|
|
|
230
221
|
const handler = buildStaticFallback(
|
|
231
222
|
() => noRouteMatchedResponse(),
|
|
232
223
|
tmp,
|
|
233
|
-
"{}",
|
|
234
224
|
() => ({ kind: "html", file: "tenant.html" }),
|
|
235
225
|
{
|
|
236
226
|
resolvePageHead: async () => ({
|
|
@@ -252,7 +242,6 @@ describe("buildStaticFallback resolvePageHead", () => {
|
|
|
252
242
|
const handler = buildStaticFallback(
|
|
253
243
|
() => noRouteMatchedResponse(),
|
|
254
244
|
tmp,
|
|
255
|
-
"{}",
|
|
256
245
|
() => ({ kind: "html", file: "tenant.html" }),
|
|
257
246
|
{
|
|
258
247
|
resolvePageHead: async () => {
|
|
@@ -270,7 +259,6 @@ describe("buildStaticFallback resolvePageHead", () => {
|
|
|
270
259
|
const handler = buildStaticFallback(
|
|
271
260
|
() => noRouteMatchedResponse(),
|
|
272
261
|
tmp,
|
|
273
|
-
"{}",
|
|
274
262
|
() => ({ kind: "html", file: "tenant.html" }),
|
|
275
263
|
{
|
|
276
264
|
resolvePageHead: () => new Promise(() => {}),
|
|
@@ -286,7 +274,6 @@ describe("buildStaticFallback resolvePageHead", () => {
|
|
|
286
274
|
const handler = buildStaticFallback(
|
|
287
275
|
() => noRouteMatchedResponse(),
|
|
288
276
|
tmp,
|
|
289
|
-
"{}",
|
|
290
277
|
() => ({ kind: "html", file: "tenant.html" }),
|
|
291
278
|
{
|
|
292
279
|
resolvePageHead: async ({ path }) => ({ title: `Title for ${path}` }),
|
|
@@ -302,7 +289,6 @@ describe("buildStaticFallback resolvePageHead", () => {
|
|
|
302
289
|
const withoutPageHead = buildStaticFallback(
|
|
303
290
|
() => noRouteMatchedResponse(),
|
|
304
291
|
tmp,
|
|
305
|
-
"{}",
|
|
306
292
|
() => ({ kind: "html", file: "tenant.html" }),
|
|
307
293
|
);
|
|
308
294
|
const res = await withoutPageHead(new Request("http://t/"));
|