@cosmicdrift/kumiko-server-runtime 0.157.2 → 0.159.1

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.2",
3
+ "version": "0.159.1",
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.2",
76
- "@cosmicdrift/kumiko-framework": "0.157.2",
75
+ "@cosmicdrift/kumiko-bundled-features": "0.159.1",
76
+ "@cosmicdrift/kumiko-framework": "0.159.1",
77
77
  "temporal-polyfill": "^0.3.2"
78
78
  },
79
79
  "publishConfig": {
@@ -13,6 +13,13 @@ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
+ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
18
+ import {
19
+ createSessionsFeature,
20
+ userSessionEntity,
21
+ } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
+ import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
16
23
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
24
  import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
18
25
  import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
@@ -26,6 +33,10 @@ import {
26
33
  createArchivedStreamsTable,
27
34
  createEventsTable,
28
35
  } from "@cosmicdrift/kumiko-framework/event-store";
36
+ import {
37
+ createNoopProvider,
38
+ createPrometheusMeter,
39
+ } from "@cosmicdrift/kumiko-framework/observability";
29
40
  import {
30
41
  createEventConsumerStateTable,
31
42
  createProjectionStateTable,
@@ -196,6 +207,8 @@ async function migrateTestDb(): Promise<void> {
196
207
  await createProjectionStateTable(db);
197
208
  await createEventConsumerStateTable(db);
198
209
  await unsafeEnsureEntityTable(db, widgetEntity, "widget");
210
+ await unsafeEnsureEntityTable(db, userEntity, "user");
211
+ await unsafeEnsureEntityTable(db, userSessionEntity, "user-session");
199
212
  await asRawClient(db).unsafe(
200
213
  `CREATE TABLE IF NOT EXISTS prod_probe_pings (
201
214
  id BIGSERIAL PRIMARY KEY,
@@ -250,6 +263,26 @@ describe("runProdApp", () => {
250
263
  expect(res.status).toBe(200);
251
264
  });
252
265
 
266
+ test("unrecognized KUMIKO_DRY_RUN_ENV value warns and falls through to a normal boot", async () => {
267
+ const originalWarn = console.warn;
268
+ const warnings: string[] = [];
269
+ console.warn = (...args: unknown[]) => {
270
+ warnings.push(args.map(String).join(" "));
271
+ };
272
+ const original = process.env["KUMIKO_DRY_RUN_ENV"];
273
+ process.env["KUMIKO_DRY_RUN_ENV"] = "not-a-real-mode";
274
+ try {
275
+ const handle = await boot();
276
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/health"));
277
+ expect(res.status).toBe(200);
278
+ } finally {
279
+ console.warn = originalWarn;
280
+ if (original === undefined) delete process.env["KUMIKO_DRY_RUN_ENV"];
281
+ else process.env["KUMIKO_DRY_RUN_ENV"] = original;
282
+ }
283
+ expect(warnings.some((line) => line.includes("unrecognized"))).toBe(true);
284
+ });
285
+
253
286
  test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
254
287
  await boot();
255
288
  // First boot left tables in place. Restart on the same DB —
@@ -790,7 +823,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
790
823
  test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
791
824
  await expect(
792
825
  boot(undefined, {
793
- auth: { admin: ADMIN, cookieDomain: "example.eu" },
826
+ auth: { admin: ADMIN, cookieDomain: "example.eu", sessions: false },
794
827
  allowPlaintextPii: "test: origin-guard focus, not crypto",
795
828
  }),
796
829
  ).rejects.toThrow(/allowedOrigins is empty/);
@@ -807,6 +840,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
807
840
  admin: ADMIN,
808
841
  cookieDomain: "example.eu",
809
842
  allowedOrigins: ["https://app.example.eu"],
843
+ sessions: false,
810
844
  },
811
845
  });
812
846
  expect(handle).toBeDefined();
@@ -819,6 +853,48 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
819
853
  });
820
854
  });
821
855
 
856
+ describe("runProdApp — session boot gate (#1262/#1275)", () => {
857
+ const ADMIN = {
858
+ email: "session-gate@example.eu",
859
+ password: "test-pw-strong-1234",
860
+ displayName: "Admin",
861
+ memberships: [],
862
+ };
863
+
864
+ test("auth mounted, sessions feature missing, no opt-out → aborts boot", async () => {
865
+ await expect(
866
+ boot(undefined, {
867
+ auth: {
868
+ admin: ADMIN,
869
+ cookieDomain: "example.eu",
870
+ allowedOrigins: ["https://app.example.eu"],
871
+ },
872
+ allowPlaintextPii: "test: session-gate focus, not crypto",
873
+ }),
874
+ ).rejects.toThrow(/BOOT ABORTED.*sessions.*stateless/s);
875
+ });
876
+
877
+ test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
878
+ const handle = await boot(undefined, {
879
+ // "user" is auto-mounted via includeBundled whenever auth.admin is set.
880
+ // sessions requires auth-foundation, which needs a tokenVerifier
881
+ // provider — PAT.
882
+ features: [
883
+ authFoundationFeature,
884
+ createPersonalAccessTokensFeature({ scopes: {} }),
885
+ createSessionsFeature(),
886
+ ],
887
+ auth: {
888
+ admin: ADMIN,
889
+ cookieDomain: "example.eu",
890
+ allowedOrigins: ["https://app.example.eu"],
891
+ },
892
+ allowPlaintextPii: "test: session-gate focus, not crypto",
893
+ });
894
+ expect(handle).toBeDefined();
895
+ });
896
+ });
897
+
822
898
  describe("runProdApp job-lane wiring (runSingleInstance)", () => {
823
899
  // Red-then-green for the export bug: on createApiEntrypoint (old default) the
824
900
  // worker-lane cron was never registered. createAllInOneEntrypoint (new
@@ -932,3 +1008,36 @@ describe("hard PII boot gate (#818 step 2)", () => {
932
1008
  expect(handle).toBeDefined();
933
1009
  });
934
1010
  });
1011
+
1012
+ // Regression for fw#1352: runProdApp wires the metrics route through two
1013
+ // independently forwarded options (observability, metrics). Wrong nesting
1014
+ // or a renamed key in ApiEntrypointOptions would silently no-op instead of
1015
+ // erroring the boot, and /metrics would stay 404 or empty.
1016
+ describe("runProdApp — /metrics endpoint (fw#1352)", () => {
1017
+ test("observability (PrometheusMeter) + metrics.token wired → GET /metrics mit Bearer liefert OpenMetrics-Body", async () => {
1018
+ const meter = createPrometheusMeter();
1019
+ meter.registerMetric({ name: "kumiko_probe_total", type: "counter" });
1020
+ meter.counter("kumiko_probe_total").inc(2);
1021
+
1022
+ const handle = await boot(undefined, {
1023
+ observability: { ...createNoopProvider(), meter },
1024
+ metrics: { token: "t" },
1025
+ });
1026
+
1027
+ const res = await handle.entrypoint.app.fetch(
1028
+ new Request("http://test/metrics", { headers: { Authorization: "Bearer t" } }),
1029
+ );
1030
+ expect(res.status).toBe(200);
1031
+ expect(res.headers.get("Content-Type")).toMatch(/openmetrics-text/);
1032
+ const body = await res.text();
1033
+ expect(body).toContain("kumiko_probe_total 2");
1034
+ expect(body).toMatch(/# EOF\n$/);
1035
+ });
1036
+
1037
+ test("ohne observability und ohne metrics-Option → /metrics ist keine Route (404)", async () => {
1038
+ const handle = await boot();
1039
+
1040
+ const res = await handle.entrypoint.app.fetch(new Request("http://test/metrics"));
1041
+ expect(res.status).toBe(404);
1042
+ });
1043
+ });
@@ -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
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { assertSessionBootInvariants } from "../session-boot-gate";
3
+
4
+ describe("assertSessionBootInvariants", () => {
5
+ test("no auth mounted → nothing to gate", () => {
6
+ expect(() =>
7
+ assertSessionBootInvariants({
8
+ hasAuth: false,
9
+ sessionsFeatureMounted: false,
10
+ sessionsOption: undefined,
11
+ }),
12
+ ).not.toThrow();
13
+ });
14
+
15
+ test("auth mounted, sessions feature missing, no opt-out → aborts boot", () => {
16
+ expect(() =>
17
+ assertSessionBootInvariants({
18
+ hasAuth: true,
19
+ sessionsFeatureMounted: false,
20
+ sessionsOption: undefined,
21
+ }),
22
+ ).toThrow(/BOOT ABORTED.*sessions.*stateless/s);
23
+ });
24
+
25
+ test("auth mounted, sessions feature missing, explicit sessions:false → boots", () => {
26
+ expect(() =>
27
+ assertSessionBootInvariants({
28
+ hasAuth: true,
29
+ sessionsFeatureMounted: false,
30
+ sessionsOption: false,
31
+ }),
32
+ ).not.toThrow();
33
+ });
34
+
35
+ test("auth mounted, sessions feature wired → boots", () => {
36
+ expect(() =>
37
+ assertSessionBootInvariants({
38
+ hasAuth: true,
39
+ sessionsFeatureMounted: true,
40
+ sessionsOption: undefined,
41
+ }),
42
+ ).not.toThrow();
43
+ });
44
+
45
+ test("auth mounted, sessions feature wired AND an expiresInMs override → boots", () => {
46
+ expect(() =>
47
+ assertSessionBootInvariants({
48
+ hasAuth: true,
49
+ sessionsFeatureMounted: true,
50
+ sessionsOption: { expiresInMs: 60_000 },
51
+ }),
52
+ ).not.toThrow();
53
+ });
54
+ });
@@ -5,7 +5,6 @@
5
5
  // buildBootExtraContext + applyBootSeeds so resolver, set-handler and
6
6
  // seeds share the same cipher instance (and DEK cache).
7
7
 
8
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
9
8
  import {
10
9
  createDekCache,
11
10
  createEnvelopeCipher,
@@ -27,12 +26,10 @@ export function envHasMasterKek(env: Record<string, string | undefined>): boolea
27
26
  export type BootCrypto = {
28
27
  readonly masterKeyProvider?: MasterKeyProvider;
29
28
  // Cipher for encrypted config keys. Present exactly when a master key is
30
- // available. Decrypts legacy CONFIG_ENCRYPTION_KEY values as fallback
31
- // until the config re-encrypt job migrated them.
29
+ // available.
32
30
  readonly configCipher?: EnvelopeCipher;
33
- // Cipher for `encrypted: true` entity fields — same master key, but the
34
- // legacy fallback reads the pre-envelope ENCRYPTION_KEY format. Identical
35
- // to configCipher when no ENCRYPTION_KEY is set.
31
+ // Cipher for `encrypted: true` entity fields — same master key and
32
+ // instance as configCipher (kept as a separate field for API stability).
36
33
  readonly entityFieldCipher?: EnvelopeCipher;
37
34
  readonly dekCache: DekCache;
38
35
  };
@@ -56,22 +53,10 @@ export function resolveBootCrypto(
56
53
  : undefined);
57
54
 
58
55
  const dekCache = createDekCache();
59
- const legacyConfigKey = envSource["CONFIG_ENCRYPTION_KEY"];
60
56
  const configCipher = masterKeyProvider
61
- ? createEnvelopeCipher(masterKeyProvider, {
62
- dekCache,
63
- ...(legacyConfigKey ? { legacy: createEncryptionProvider(legacyConfigKey) } : {}),
64
- })
57
+ ? createEnvelopeCipher(masterKeyProvider, { dekCache })
65
58
  : undefined;
66
-
67
- const legacyEntityKey = envSource["ENCRYPTION_KEY"];
68
- const entityFieldCipher =
69
- masterKeyProvider && legacyEntityKey
70
- ? createEnvelopeCipher(masterKeyProvider, {
71
- dekCache,
72
- legacy: createEncryptionProvider(legacyEntityKey),
73
- })
74
- : configCipher;
59
+ const entityFieldCipher = configCipher;
75
60
 
76
61
  return {
77
62
  ...(masterKeyProvider && { masterKeyProvider }),
@@ -12,6 +12,7 @@
12
12
  // auf Frühere referenzieren (z.B. authClaims-Hooks an user/tenant).
13
13
 
14
14
  import {
15
+ type AccountUnlockOptions,
15
16
  type AuthEmailPasswordOptions,
16
17
  type AuthMailLocale,
17
18
  createAuthEmailPasswordFeature,
@@ -102,6 +103,7 @@ export type AuthOptionsCarrier = {
102
103
  readonly emailVerification?: EmailVerificationOptions;
103
104
  readonly signup?: SignupOptions;
104
105
  readonly invite?: InviteOptions;
106
+ readonly accountUnlock?: AccountUnlockOptions;
105
107
  };
106
108
 
107
109
  /** Baut den authOptions-Block für composeFeatures aus einem
@@ -158,7 +160,17 @@ export function buildComposeAuthOptions(
158
160
  if (auth.invite) {
159
161
  opts.invite = pickMailFields(auth.invite);
160
162
  }
161
- return opts.passwordReset || opts.emailVerification || opts.signup || opts.invite
163
+ if (auth.accountUnlock) {
164
+ opts.accountUnlock = {
165
+ hmacSecret: auth.accountUnlock.hmacSecret,
166
+ ...pickMailFields(auth.accountUnlock),
167
+ };
168
+ }
169
+ return opts.passwordReset ||
170
+ opts.emailVerification ||
171
+ opts.signup ||
172
+ opts.invite ||
173
+ opts.accountUnlock
162
174
  ? opts
163
175
  : undefined;
164
176
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // Dev-Tooling mehr in ihre node_modules.
5
5
  export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features";
6
6
  export type {
7
+ AccountUnlockSetup,
7
8
  EmailVerificationSetup,
8
9
  InviteSetup,
9
10
  PasswordResetSetup,
@@ -14,3 +15,4 @@ export type {
14
15
  SignupSetup,
15
16
  } from "./run-prod-app";
16
17
  export { runProdApp } from "./run-prod-app";
18
+ export type { SecurityHeadersOption } from "./security-headers";
@@ -160,6 +160,13 @@ type AuthMailNormalizable = {
160
160
  readonly invite?: InviteSetup;
161
161
  };
162
162
 
163
+ // accountUnlock (#1266) deliberately does NOT join this convenience block —
164
+ // unlike reset/verify/signup/invite it's only meaningful paired with
165
+ // `accountLockout`, which itself isn't wired through RunProdAppAuthOptions
166
+ // today. Apps that mount `accountLockout` set `auth.accountUnlock` alongside
167
+ // it explicitly (same shape as `passwordReset`), so `mail` alone can't
168
+ // silently expose a new public endpoint an app didn't ask for.
169
+
163
170
  export function resolveAuthMail<T extends AuthMailNormalizable>(
164
171
  auth: T,
165
172
  hmacSecret: string,
@@ -216,7 +223,6 @@ export function buildProdSessionAuth(
216
223
  readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
217
224
  readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
218
225
  readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
219
- readonly sessionStrictMode: true;
220
226
  } {
221
227
  const cbs = createSessionCallbacks({
222
228
  db,
@@ -236,6 +242,5 @@ export function buildProdSessionAuth(
236
242
  sessionCreator: cbs.sessionCreator,
237
243
  sessionRevoker: cbs.sessionRevoker,
238
244
  sessionChecker: cbs.sessionChecker,
239
- sessionStrictMode: true,
240
245
  };
241
246
  }
@@ -20,11 +20,18 @@
20
20
  // Container/Coolify setzt:
21
21
  // DATABASE_URL=postgresql://...
22
22
  // REDIS_URL=redis://...
23
- // JWT_SECRET=<random-32+>
23
+ // JWT_SECRET=<random-32+> (always required — also signs the password-
24
+ // reset/email-verification HMAC tokens, a separate non-rotating family)
25
+ // — additionally, for zero-downtime rotation of the session-JWT
26
+ // signing key specifically:
27
+ // JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
28
+ // JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
29
+ // still verify in-flight tokens until they expire)
24
30
  // PORT=3000
25
31
  // KUMIKO_INSTANCE_ID=<stable per replica>
26
32
 
27
33
  import {
34
+ type AccountUnlockOptions,
28
35
  AuthErrors,
29
36
  AuthHandlers,
30
37
  type AuthMailLocale,
@@ -38,12 +45,14 @@ import {
38
45
  type SeedAdminOptions,
39
46
  seedAdmin,
40
47
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
48
+ import {
49
+ EXT_TOKEN_VERIFIER,
50
+ resolveTokenVerifier,
51
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
41
52
  import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
42
53
  import {
43
- createPatResolver,
44
54
  PAT_FEATURE,
45
55
  patRateLimitFromFeature,
46
- patScopesFromFeature,
47
56
  } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
48
57
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
49
58
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
@@ -53,9 +62,12 @@ import {
53
62
  } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
54
63
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
55
64
  import {
56
- createInMemoryLoginRateLimiter,
65
+ createRedisLoginRateLimiter,
57
66
  createSseBroker,
67
+ type LoginRateLimiter,
68
+ loadJwtSecretOrKeyring,
58
69
  type SseBroker,
70
+ type TokenVerifier,
59
71
  } from "@cosmicdrift/kumiko-framework/api";
60
72
  import {
61
73
  configureBlindIndexKey,
@@ -126,6 +138,8 @@ import {
126
138
  resolveAuthMail,
127
139
  } from "./run-prod-app-boot-context";
128
140
  import { buildStaticFallback } from "./run-prod-app-static-files";
141
+ import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
142
+ import { assertSessionBootInvariants } from "./session-boot-gate";
129
143
  import {
130
144
  type ProdSessionsOption,
131
145
  resolveProdSessionsConfig,
@@ -242,6 +256,13 @@ export type SignupSetup = SignupOptions;
242
256
  * AuthHandlers (analog signup). */
243
257
  export type InviteSetup = InviteOptions;
244
258
 
259
+ /** Wrapper API for the account-unlock flow (#1266). = AccountUnlockOptions
260
+ * (appUrl via delivery, symmetric to PasswordResetSetup). Self-service
261
+ * escape hatch for accountLockout's monotonic failure counter — only
262
+ * meaningful when `auth.accountLockout` is also set, but wired
263
+ * independently like the other flows. */
264
+ export type AccountUnlockSetup = AccountUnlockOptions;
265
+
245
266
  /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
246
267
  * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
247
268
  * Templates (siehe `auth.mail` + resolveAuthMail). */
@@ -269,7 +290,7 @@ export type RunProdAppAuthOptions = {
269
290
  /** Opt-in: revocable server-side sessions. Caller MUSS
270
291
  * `createSessionsFeature()` zu `features` adden — runProdApp wired
271
292
  * hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
272
- * echte db-connection, plus sessionStrictMode=true.
293
+ * echte db-connection (sidless JWTs werden dann abgelehnt).
273
294
  *
274
295
  * Standardverhalten ohne diese Option: stateless JWTs ohne sid
275
296
  * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
@@ -306,6 +327,11 @@ export type RunProdAppAuthOptions = {
306
327
  * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
307
328
  * are mounted. */
308
329
  readonly invite?: InviteSetup;
330
+ /** Account-unlock flow (#1266). When set, /api/auth/request-account-unlock
331
+ * + /api/auth/confirm-account-unlock are mounted. Self-service escape
332
+ * hatch for accountLockout's monotonic failure-counter — confirming
333
+ * clears the Redis lockout state, no entity write. */
334
+ readonly accountUnlock?: AccountUnlockSetup;
309
335
  /** Domain attribute for both auth cookies (see
310
336
  * AuthRoutesConfig.cookieDomain). Set to the registrable parent
311
337
  * domain when login and app live on different subdomains. */
@@ -569,6 +595,12 @@ export type RunProdAppOptions = {
569
595
  * dieses Feld wird kein L1/L2 verdrahtet (L3 Handler-`rateLimit:`
570
596
  * funktioniert unabhängig davon bereits). */
571
597
  readonly rateLimit?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["rateLimit"];
598
+ /** Default security headers on every response (HSTS, X-Frame-Options,
599
+ * X-Content-Type-Options, Referrer-Policy; CSP opt-in). Headers already
600
+ * set by a response (e.g. hostDispatch's per-host CSP) are never
601
+ * overridden. `false` disables the whole block; per-header overrides
602
+ * via the object form — see SecurityHeadersOption. */
603
+ readonly securityHeaders?: SecurityHeadersOption;
572
604
  };
573
605
 
574
606
  export type ProdAppHandle = {
@@ -652,7 +684,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
652
684
  // configured.
653
685
  const databaseUrl = requireEnv("DATABASE_URL", envSource);
654
686
  const redisUrl = requireEnv("REDIS_URL", envSource);
687
+ // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
688
+ // fallback below — a separate, non-rotating HMAC token family, not the
689
+ // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
690
+ // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
691
+ // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
655
692
  const jwtSecret = requireEnv("JWT_SECRET", envSource);
693
+ const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
656
694
  const jwtIssuer = readEnv("JWT_ISSUER", envSource);
657
695
  const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
658
696
  const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
@@ -687,6 +725,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
687
725
  allowPlaintextPii: options.allowPlaintextPii,
688
726
  mode: "prod",
689
727
  });
728
+ const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
729
+ assertSessionBootInvariants({
730
+ hasAuth: Boolean(effectiveAuth),
731
+ sessionsFeatureMounted: sessionsFeature !== undefined,
732
+ sessionsOption: effectiveAuth?.sessions,
733
+ });
690
734
  const registry = createRegistry(features);
691
735
 
692
736
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
@@ -824,17 +868,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
824
868
 
825
869
  // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
826
870
  // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
827
- // sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
828
- // von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
871
+ // Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
872
+ // Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
829
873
  // (4. callback aus createSessionCallbacks) ist nicht Teil der
830
874
  // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
831
875
  // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
832
876
  // über die auth-routes.
833
877
  // Secure-by-default: if the sessions feature is mounted, server-side revocation +
834
- // sessionStrictMode + auto-revoke-on-password-change are wired automatically;
878
+ // auto-revoke-on-password-change are wired automatically;
835
879
  // `auth.sessions` only overrides the config, and `auth.sessions: false` is the
836
880
  // explicit opt-out (back to stateless JWTs).
837
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
838
881
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
839
882
  const sessionAuthFragment = shouldWireProdSessions(
840
883
  Boolean(effectiveAuth),
@@ -849,21 +892,25 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
849
892
  )
850
893
  : undefined;
851
894
 
852
- // PAT opt-in: if the personal-access-tokens feature is mounted, wire its
853
- // resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
854
- // the feature's exports the same declaration its handlers use.
895
+ // Token-verifier opt-in: any provider feature (personal-access-tokens, a
896
+ // future auth-provider-jwt, ...) self-registers via
897
+ // r.useExtension(EXT_TOKEN_VERIFIER, ...)wire one generic resolver
898
+ // whenever at least one is mounted, resolved by shape at request-time.
899
+ // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
900
+ // verification.
855
901
  const patFeature = features.find((f) => f.name === PAT_FEATURE);
902
+ const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
856
903
  let patAuthFragment:
857
904
  | {
858
- patResolver: ReturnType<typeof createPatResolver>;
859
- patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
905
+ tokenVerifier: TokenVerifier;
906
+ patRateLimiter: LoginRateLimiter;
860
907
  }
861
908
  | undefined;
862
- if (effectiveAuth && patFeature) {
909
+ if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
863
910
  const rl = patRateLimitFromFeature(patFeature);
864
911
  patAuthFragment = {
865
- patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
866
- patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
912
+ tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
913
+ patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
867
914
  };
868
915
  }
869
916
 
@@ -887,7 +934,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
887
934
  ...extraContext,
888
935
  },
889
936
  sseBroker,
890
- jwtSecret,
937
+ jwtSecret: jwtSecretOrKeyring,
891
938
  ...(jwtIssuer && { jwtIssuer }),
892
939
  ...(instanceId && { instanceId }),
893
940
  dispatcherOptions: {
@@ -908,6 +955,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
908
955
  [AuthErrors.invalidCredentials]: 401,
909
956
  [AuthErrors.noMembership]: 403,
910
957
  },
958
+ // Redis-backed, not the in-memory default createAuthRoutes falls
959
+ // back to — an in-process limiter only rate-limits within a single
960
+ // replica, so a multi-instance prod deployment would silently give
961
+ // each replica its own bucket (#1262/#1274). Redis is required infra
962
+ // here already (REDIS_URL), so this is free.
963
+ loginRateLimit: createRedisLoginRateLimiter(redis),
911
964
  ...(effectiveAuth.cookieDomain !== undefined && {
912
965
  cookieDomain: effectiveAuth.cookieDomain,
913
966
  }),
@@ -920,7 +973,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
920
973
  ...sessionAuthFragment,
921
974
  ...patAuthFragment,
922
975
  ...tenantLifecycleAuthFragment,
923
- ...(mfaFeature && { mfaVerifyHandler: AuthMfaHandlers.verify }),
976
+ ...(mfaFeature && {
977
+ mfaVerifyHandler: AuthMfaHandlers.verify,
978
+ mfaVerifyRateLimit: createRedisLoginRateLimiter(
979
+ redis,
980
+ undefined,
981
+ undefined,
982
+ "mfa-verify",
983
+ ),
984
+ }),
924
985
  ...(effectiveAuth.passwordReset && {
925
986
  passwordReset: {
926
987
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -933,6 +994,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
933
994
  confirmHandler: AuthHandlers.verifyEmail,
934
995
  },
935
996
  }),
997
+ ...(effectiveAuth.accountUnlock && {
998
+ accountUnlock: {
999
+ requestHandler: AuthHandlers.requestAccountUnlock,
1000
+ confirmHandler: AuthHandlers.confirmAccountUnlock,
1001
+ },
1002
+ }),
936
1003
  ...(effectiveAuth.signup && {
937
1004
  signup: {
938
1005
  requestHandler: AuthHandlers.signupRequest,
@@ -1066,14 +1133,17 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1066
1133
  // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
1067
1134
  // owns the rest. Tests use this directly; listen() wraps it in
1068
1135
  // 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);
1136
+ const fetchHandler = withSecurityHeaders(
1137
+ options.staticDir
1138
+ ? buildStaticFallback(
1139
+ entrypoint.app.fetch.bind(entrypoint.app),
1140
+ options.staticDir,
1141
+ appSchemaJson,
1142
+ options.hostDispatch,
1143
+ )
1144
+ : entrypoint.app.fetch.bind(entrypoint.app),
1145
+ options.securityHeaders,
1146
+ );
1077
1147
 
1078
1148
  // 11. Mark lifecycle ready — health/ready flips to 200 after this.
1079
1149
  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
+ }
@@ -0,0 +1,29 @@
1
+ import type { ProdSessionsOption } from "./session-wiring";
2
+
3
+ export type SessionBootGateOptions = {
4
+ readonly hasAuth: boolean;
5
+ readonly sessionsFeatureMounted: boolean;
6
+ readonly sessionsOption: ProdSessionsOption | undefined;
7
+ };
8
+
9
+ // Mirrors pii-boot-gate.ts: catch a forgotten wiring at boot instead of
10
+ // letting it degrade silently into stateless JWTs (no server-side
11
+ // revocation, valid for the full 24h token TTL). `auth.sessions: false` is
12
+ // already the sanctioned opt-out (see session-wiring.ts) — reusing it here
13
+ // instead of inventing a second acknowledgment param.
14
+ export function assertSessionBootInvariants(opts: SessionBootGateOptions): void {
15
+ // skip: no auth mounted — nothing to gate.
16
+ if (!opts.hasAuth) return;
17
+ // skip: explicit opt-out, operator acknowledged stateless JWTs.
18
+ if (opts.sessionsOption === false) return;
19
+ // skip: sessions feature is wired.
20
+ if (opts.sessionsFeatureMounted) return;
21
+
22
+ throw new Error(
23
+ "[runProdApp] BOOT ABORTED — auth is mounted but the `sessions` feature is not. " +
24
+ "JWTs would be stateless (no server-side revocation, valid until the 24h expiry) " +
25
+ "with no warning. Mount createSessionsFeature() " +
26
+ "(@cosmicdrift/kumiko-bundled-features/sessions) for revocable sessions, or pass " +
27
+ "{ auth: { sessions: false } } to acknowledge stateless JWTs are intentional.",
28
+ );
29
+ }
@@ -3,7 +3,7 @@
3
3
  * full prod boot).
4
4
  *
5
5
  * Secure-by-default: mounting the `sessions` feature turns server-side session
6
- * revocation + sessionStrictMode ON automatically — there is no separate opt-in. The
6
+ * revocation ON automatically — there is no separate opt-in. The
7
7
  * `auth.sessions` option only overrides the config, and `auth.sessions: false` is the
8
8
  * explicit opt-out (back to stateless JWTs).
9
9
  */