@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.319.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.319.0",
84
- "@cosmicdrift/kumiko-framework": "0.319.0",
85
- "@cosmicdrift/kumiko-headless": "0.319.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 + fetch werden 1:1 durchgereicht", () => {
23
- const fetchHandler = (_req: Request) => new Response("test");
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
- expect(opts.fetch).toBe(fetchHandler);
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/"));