@cosmicdrift/kumiko-server-runtime 0.157.1 → 0.158.2

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.157.1",
3
+ "version": "0.158.2",
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>",
@@ -72,8 +72,8 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@cosmicdrift/kumiko-bundled-features": "0.157.1",
76
- "@cosmicdrift/kumiko-framework": "0.157.1",
75
+ "@cosmicdrift/kumiko-bundled-features": "0.158.2",
76
+ "@cosmicdrift/kumiko-framework": "0.158.2",
77
77
  "temporal-polyfill": "^0.3.2"
78
78
  },
79
79
  "publishConfig": {
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { resolveSecurityHeaders, withSecurityHeaders } from "../security-headers";
3
+
4
+ const okHandler = (_req: Request) => new Response("ok");
5
+ const req = new Request("http://localhost/");
6
+
7
+ describe("resolveSecurityHeaders", () => {
8
+ it("returns the four defaults without csp when unconfigured", () => {
9
+ const headers = new Map(resolveSecurityHeaders(undefined));
10
+ expect(headers.get("strict-transport-security")).toBe("max-age=31536000; includeSubDomains");
11
+ expect(headers.get("x-frame-options")).toBe("DENY");
12
+ expect(headers.get("x-content-type-options")).toBe("nosniff");
13
+ expect(headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
14
+ expect(headers.has("content-security-policy")).toBe(false);
15
+ });
16
+
17
+ it("returns nothing when disabled", () => {
18
+ expect(resolveSecurityHeaders(false)).toEqual([]);
19
+ });
20
+
21
+ it("applies per-header overrides and opt-outs", () => {
22
+ const headers = new Map(
23
+ resolveSecurityHeaders({
24
+ hsts: "max-age=60",
25
+ frameOptions: false,
26
+ referrerPolicy: "no-referrer",
27
+ csp: "default-src 'self'",
28
+ }),
29
+ );
30
+ expect(headers.get("strict-transport-security")).toBe("max-age=60");
31
+ expect(headers.has("x-frame-options")).toBe(false);
32
+ expect(headers.get("x-content-type-options")).toBe("nosniff");
33
+ expect(headers.get("referrer-policy")).toBe("no-referrer");
34
+ expect(headers.get("content-security-policy")).toBe("default-src 'self'");
35
+ });
36
+ });
37
+
38
+ describe("withSecurityHeaders", () => {
39
+ it("sets defaults on every response", async () => {
40
+ const res = await withSecurityHeaders(okHandler, undefined)(req);
41
+ expect(res.headers.get("strict-transport-security")).toBe(
42
+ "max-age=31536000; includeSubDomains",
43
+ );
44
+ expect(res.headers.get("x-frame-options")).toBe("DENY");
45
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
46
+ expect(res.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
47
+ expect(await res.text()).toBe("ok");
48
+ });
49
+
50
+ it("returns the handler unwrapped when disabled", () => {
51
+ expect(withSecurityHeaders(okHandler, false)).toBe(okHandler);
52
+ });
53
+
54
+ it("never overrides a header the response already set", async () => {
55
+ const handler = (_req: Request) =>
56
+ new Response("ok", {
57
+ headers: {
58
+ "content-security-policy": "default-src 'none'",
59
+ "x-frame-options": "SAMEORIGIN",
60
+ },
61
+ });
62
+ const res = await withSecurityHeaders(handler, { csp: "default-src 'self'" })(req);
63
+ expect(res.headers.get("content-security-policy")).toBe("default-src 'none'");
64
+ expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
65
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
66
+ });
67
+
68
+ it("re-wraps responses with immutable headers", async () => {
69
+ const immutable = new Response("ok", { status: 201, statusText: "Created" });
70
+ Object.defineProperty(immutable, "headers", {
71
+ value: new Proxy(immutable.headers, {
72
+ get(target, prop) {
73
+ if (prop === "set") {
74
+ return () => {
75
+ throw new TypeError("immutable");
76
+ };
77
+ }
78
+ const value = Reflect.get(target, prop);
79
+ return typeof value === "function" ? value.bind(target) : value;
80
+ },
81
+ }),
82
+ });
83
+ const res = await withSecurityHeaders(() => immutable, undefined)(req);
84
+ expect(res.status).toBe(201);
85
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
86
+ expect(await res.text()).toBe("ok");
87
+ });
88
+ });
package/src/index.ts CHANGED
@@ -14,3 +14,4 @@ export type {
14
14
  SignupSetup,
15
15
  } from "./run-prod-app";
16
16
  export { runProdApp } from "./run-prod-app";
17
+ export type { SecurityHeadersOption } from "./security-headers";
@@ -53,8 +53,9 @@ import {
53
53
  } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
54
54
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
55
55
  import {
56
- createInMemoryLoginRateLimiter,
56
+ createRedisLoginRateLimiter,
57
57
  createSseBroker,
58
+ type LoginRateLimiter,
58
59
  type SseBroker,
59
60
  } from "@cosmicdrift/kumiko-framework/api";
60
61
  import {
@@ -126,6 +127,7 @@ import {
126
127
  resolveAuthMail,
127
128
  } from "./run-prod-app-boot-context";
128
129
  import { buildStaticFallback } from "./run-prod-app-static-files";
130
+ import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
129
131
  import {
130
132
  type ProdSessionsOption,
131
133
  resolveProdSessionsConfig,
@@ -569,6 +571,12 @@ export type RunProdAppOptions = {
569
571
  * dieses Feld wird kein L1/L2 verdrahtet (L3 Handler-`rateLimit:`
570
572
  * funktioniert unabhängig davon bereits). */
571
573
  readonly rateLimit?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["rateLimit"];
574
+ /** Default security headers on every response (HSTS, X-Frame-Options,
575
+ * X-Content-Type-Options, Referrer-Policy; CSP opt-in). Headers already
576
+ * set by a response (e.g. hostDispatch's per-host CSP) are never
577
+ * overridden. `false` disables the whole block; per-header overrides
578
+ * via the object form — see SecurityHeadersOption. */
579
+ readonly securityHeaders?: SecurityHeadersOption;
572
580
  };
573
581
 
574
582
  export type ProdAppHandle = {
@@ -856,14 +864,14 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
856
864
  let patAuthFragment:
857
865
  | {
858
866
  patResolver: ReturnType<typeof createPatResolver>;
859
- patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
867
+ patRateLimiter: LoginRateLimiter;
860
868
  }
861
869
  | undefined;
862
870
  if (effectiveAuth && patFeature) {
863
871
  const rl = patRateLimitFromFeature(patFeature);
864
872
  patAuthFragment = {
865
873
  patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
866
- patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
874
+ patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
867
875
  };
868
876
  }
869
877
 
@@ -908,6 +916,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
908
916
  [AuthErrors.invalidCredentials]: 401,
909
917
  [AuthErrors.noMembership]: 403,
910
918
  },
919
+ // Redis-backed, not the in-memory default createAuthRoutes falls
920
+ // back to — an in-process limiter only rate-limits within a single
921
+ // replica, so a multi-instance prod deployment would silently give
922
+ // each replica its own bucket (#1262/#1274). Redis is required infra
923
+ // here already (REDIS_URL), so this is free.
924
+ loginRateLimit: createRedisLoginRateLimiter(redis),
911
925
  ...(effectiveAuth.cookieDomain !== undefined && {
912
926
  cookieDomain: effectiveAuth.cookieDomain,
913
927
  }),
@@ -920,7 +934,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
920
934
  ...sessionAuthFragment,
921
935
  ...patAuthFragment,
922
936
  ...tenantLifecycleAuthFragment,
923
- ...(mfaFeature && { mfaVerifyHandler: AuthMfaHandlers.verify }),
937
+ ...(mfaFeature && {
938
+ mfaVerifyHandler: AuthMfaHandlers.verify,
939
+ mfaVerifyRateLimit: createRedisLoginRateLimiter(
940
+ redis,
941
+ undefined,
942
+ undefined,
943
+ "mfa-verify",
944
+ ),
945
+ }),
924
946
  ...(effectiveAuth.passwordReset && {
925
947
  passwordReset: {
926
948
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -1066,14 +1088,17 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1066
1088
  // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
1067
1089
  // owns the rest. Tests use this directly; listen() wraps it in
1068
1090
  // Bun.serve.
1069
- const fetchHandler = options.staticDir
1070
- ? buildStaticFallback(
1071
- entrypoint.app.fetch.bind(entrypoint.app),
1072
- options.staticDir,
1073
- appSchemaJson,
1074
- options.hostDispatch,
1075
- )
1076
- : entrypoint.app.fetch.bind(entrypoint.app);
1091
+ const fetchHandler = withSecurityHeaders(
1092
+ options.staticDir
1093
+ ? buildStaticFallback(
1094
+ entrypoint.app.fetch.bind(entrypoint.app),
1095
+ options.staticDir,
1096
+ appSchemaJson,
1097
+ options.hostDispatch,
1098
+ )
1099
+ : entrypoint.app.fetch.bind(entrypoint.app),
1100
+ options.securityHeaders,
1101
+ );
1077
1102
 
1078
1103
  // 11. Mark lifecycle ready — health/ready flips to 200 after this.
1079
1104
  entrypoint.lifecycle.markReady();
@@ -0,0 +1,67 @@
1
+ export type SecurityHeadersOption =
2
+ | false
3
+ | {
4
+ /** `Strict-Transport-Security` value, or `false` to omit.
5
+ * Default: `max-age=31536000; includeSubDomains`. */
6
+ readonly hsts?: string | false;
7
+ /** `X-Frame-Options` value, or `false` to omit. Default: `DENY`.
8
+ * Apps that must be embeddable (iframe widgets) set `false` and
9
+ * scope framing via a `csp` frame-ancestors directive instead. */
10
+ readonly frameOptions?: string | false;
11
+ /** `false` omits `X-Content-Type-Options: nosniff`. */
12
+ readonly contentTypeOptions?: false;
13
+ /** `Referrer-Policy` value, or `false` to omit.
14
+ * Default: `strict-origin-when-cross-origin`. */
15
+ readonly referrerPolicy?: string | false;
16
+ /** `Content-Security-Policy` default for ALL responses. No built-in
17
+ * default — a wrong CSP breaks app assets, so it stays opt-in.
18
+ * A per-host CSP from `hostDispatch` wins over this value. */
19
+ readonly csp?: string;
20
+ };
21
+
22
+ const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
23
+ const DEFAULT_FRAME_OPTIONS = "DENY";
24
+ const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
25
+
26
+ export function resolveSecurityHeaders(
27
+ option: SecurityHeadersOption | undefined,
28
+ ): ReadonlyArray<readonly [string, string]> {
29
+ if (option === false) return [];
30
+ const opt = option ?? {};
31
+ const headers: Array<readonly [string, string]> = [];
32
+ const hsts = opt.hsts ?? DEFAULT_HSTS;
33
+ if (hsts !== false) headers.push(["strict-transport-security", hsts]);
34
+ const frameOptions = opt.frameOptions ?? DEFAULT_FRAME_OPTIONS;
35
+ if (frameOptions !== false) headers.push(["x-frame-options", frameOptions]);
36
+ if (opt.contentTypeOptions !== false) headers.push(["x-content-type-options", "nosniff"]);
37
+ const referrerPolicy = opt.referrerPolicy ?? DEFAULT_REFERRER_POLICY;
38
+ if (referrerPolicy !== false) headers.push(["referrer-policy", referrerPolicy]);
39
+ if (opt.csp) headers.push(["content-security-policy", opt.csp]);
40
+ return headers;
41
+ }
42
+
43
+ // Sets each header only when absent so per-response values (e.g. the
44
+ // per-host CSP from hostDispatch) always win over the runtime default.
45
+ export function withSecurityHeaders(
46
+ handler: (req: Request) => Response | Promise<Response>,
47
+ option: SecurityHeadersOption | undefined,
48
+ ): (req: Request) => Response | Promise<Response> {
49
+ const defaults = resolveSecurityHeaders(option);
50
+ if (defaults.length === 0) return handler;
51
+ return async (req: Request): Promise<Response> => {
52
+ const res = await handler(req);
53
+ try {
54
+ for (const [name, value] of defaults) {
55
+ if (!res.headers.has(name)) res.headers.set(name, value);
56
+ }
57
+ return res;
58
+ } catch {
59
+ // Immutable-headers Response (e.g. proxied fetch) — re-wrap.
60
+ const headers = new Headers(res.headers);
61
+ for (const [name, value] of defaults) {
62
+ if (!headers.has(name)) headers.set(name, value);
63
+ }
64
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
65
+ }
66
+ };
67
+ }