@cosmicdrift/kumiko-server-runtime 1.0.0 → 2.0.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.
@@ -263,6 +263,31 @@ describe("runProdApp", () => {
263
263
  expect(res.status).toBe(200);
264
264
  });
265
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(
284
+ warnings.some(
285
+ (line) =>
286
+ line.includes('KUMIKO_DRY_RUN_ENV="not-a-real-mode"') && line.includes("unrecognized"),
287
+ ),
288
+ ).toBe(true);
289
+ });
290
+
266
291
  test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
267
292
  await boot();
268
293
  // First boot left tables in place. Restart on the same DB —
@@ -803,7 +828,12 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
803
828
  test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
804
829
  await expect(
805
830
  boot(undefined, {
806
- auth: { admin: ADMIN, cookieDomain: "example.eu", sessions: false },
831
+ features: [
832
+ authFoundationFeature,
833
+ createPersonalAccessTokensFeature({ scopes: {} }),
834
+ createSessionsFeature(),
835
+ ],
836
+ auth: { admin: ADMIN, cookieDomain: "example.eu" },
807
837
  allowPlaintextPii: "test: origin-guard focus, not crypto",
808
838
  }),
809
839
  ).rejects.toThrow(/allowedOrigins is empty/);
@@ -811,16 +841,20 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
811
841
 
812
842
  test("cookieDomain + allowedOrigins clears the guard — allowlist reaches buildServer", async () => {
813
843
  // Without the forwarding fix this would ALSO throw /allowedOrigins is empty/.
814
- // It may still fail later on the minimal harness (no auth tables migrated),
815
- // but never on the origin guard that is the forwarding proof.
844
+ // auth tables are migrated for this file (migrateTestDb() above); this test
845
+ // isolates only that a later boot failure is never the origin-guard message.
816
846
  let bootError: unknown;
817
847
  try {
818
848
  const handle = await boot(undefined, {
849
+ features: [
850
+ authFoundationFeature,
851
+ createPersonalAccessTokensFeature({ scopes: {} }),
852
+ createSessionsFeature(),
853
+ ],
819
854
  auth: {
820
855
  admin: ADMIN,
821
856
  cookieDomain: "example.eu",
822
857
  allowedOrigins: ["https://app.example.eu"],
823
- sessions: false,
824
858
  },
825
859
  });
826
860
  expect(handle).toBeDefined();
@@ -841,7 +875,7 @@ describe("runProdApp — session boot gate (#1262/#1275)", () => {
841
875
  memberships: [],
842
876
  };
843
877
 
844
- test("auth mounted, sessions feature missing, no opt-out → aborts boot", async () => {
878
+ test("auth mounted, sessions feature missing → aborts boot", async () => {
845
879
  await expect(
846
880
  boot(undefined, {
847
881
  auth: {
@@ -851,7 +885,7 @@ describe("runProdApp — session boot gate (#1262/#1275)", () => {
851
885
  },
852
886
  allowPlaintextPii: "test: session-gate focus, not crypto",
853
887
  }),
854
- ).rejects.toThrow(/BOOT ABORTED.*sessions.*stateless/s);
888
+ ).rejects.toThrow(/BOOT ABORTED.*sessionStore/);
855
889
  });
856
890
 
857
891
  test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
@@ -1,54 +1,22 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { assertSessionBootInvariants } from "../session-boot-gate";
3
3
 
4
- describe("assertSessionBootInvariants", () => {
5
- test("no auth mounted nothing to gate", () => {
4
+ describe("assertSessionBootInvariants (#1372)", () => {
5
+ test("no auth → no throw", () => {
6
6
  expect(() =>
7
- assertSessionBootInvariants({
8
- hasAuth: false,
9
- sessionsFeatureMounted: false,
10
- sessionsOption: undefined,
11
- }),
7
+ assertSessionBootInvariants({ hasAuth: false, sessionStoreProviderMounted: false }),
12
8
  ).not.toThrow();
13
9
  });
14
10
 
15
- test("auth mounted, sessions feature missing, no opt-out → aborts boot", () => {
11
+ test("auth + sessionStore no throw", () => {
16
12
  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
- }),
13
+ assertSessionBootInvariants({ hasAuth: true, sessionStoreProviderMounted: true }),
42
14
  ).not.toThrow();
43
15
  });
44
16
 
45
- test("auth mounted, sessions feature wired AND an expiresInMs override boots", () => {
17
+ test("auth without sessionStorethrows", () => {
46
18
  expect(() =>
47
- assertSessionBootInvariants({
48
- hasAuth: true,
49
- sessionsFeatureMounted: true,
50
- sessionsOption: { expiresInMs: 60_000 },
51
- }),
52
- ).not.toThrow();
19
+ assertSessionBootInvariants({ hasAuth: true, sessionStoreProviderMounted: false }),
20
+ ).toThrow(/BOOT ABORTED/);
53
21
  });
54
22
  });
@@ -1,51 +1,16 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import {
3
- createSessionsFeature,
4
- SESSIONS_FEATURE,
5
- } from "@cosmicdrift/kumiko-bundled-features/sessions";
6
- import { resolveProdSessionsConfig, shouldWireProdSessions } from "../session-wiring";
2
+ import { shouldWireProdSessions } from "../session-wiring";
7
3
 
8
- describe("shouldWireProdSessions — secure-by-default with opt-out (KF-1)", () => {
9
- it("wires sessions when the feature is mounted, even without an explicit config", () => {
10
- // The publicstatus bug: sessions feature mounted + auth set, but no auth.sessions —
11
- // previously left stateless (no revocation). Now it wires automatically.
12
- expect(shouldWireProdSessions(true, true, undefined)).toBe(true);
4
+ describe("shouldWireProdSessions — secure-by-default (#1372)", () => {
5
+ it("wires when auth + sessionStore provider mounted", () => {
6
+ expect(shouldWireProdSessions(true, true)).toBe(true);
13
7
  });
14
8
 
15
- it("wires sessions when a config object is given", () => {
16
- expect(shouldWireProdSessions(true, true, { expiresInMs: 1000 })).toBe(true);
9
+ it("does not wire without auth", () => {
10
+ expect(shouldWireProdSessions(false, true)).toBe(false);
17
11
  });
18
12
 
19
- it("does not wire when sessions: false (explicit opt-out)", () => {
20
- expect(shouldWireProdSessions(true, true, false)).toBe(false);
21
- });
22
-
23
- it("does not wire when the sessions feature is not mounted", () => {
24
- expect(shouldWireProdSessions(true, false, undefined)).toBe(false);
25
- });
26
-
27
- it("does not wire when the app has no auth at all", () => {
28
- expect(shouldWireProdSessions(false, true, undefined)).toBe(false);
29
- });
30
- });
31
-
32
- describe("SESSIONS_FEATURE constant matches the real feature name", () => {
33
- it("createSessionsFeature()'s name equals SESSIONS_FEATURE", () => {
34
- // shouldWireProdSessions's own arm only tests the pure boolean helper —
35
- // the actual run-prod-app.ts integration seam
36
- // (`features.some((f) => f.name === SESSIONS_FEATURE)`) drifts silently
37
- // if the feature is ever renamed without updating this constant.
38
- expect(createSessionsFeature().name).toBe(SESSIONS_FEATURE);
39
- });
40
- });
41
-
42
- describe("resolveProdSessionsConfig", () => {
43
- it("passes a config object through", () => {
44
- expect(resolveProdSessionsConfig({ expiresInMs: 5000 })).toEqual({ expiresInMs: 5000 });
45
- });
46
-
47
- it("collapses false / undefined to defaults", () => {
48
- expect(resolveProdSessionsConfig(undefined)).toEqual({});
49
- expect(resolveProdSessionsConfig(false)).toEqual({});
13
+ it("does not wire without sessionStore provider", () => {
14
+ expect(shouldWireProdSessions(true, false)).toBe(false);
50
15
  });
51
16
  });
@@ -1,21 +1,24 @@
1
- // composeFeatures — single source of truth für die Feature-Liste die
2
- // Boot UND Schema-Generator sehen.
1
+ // composeFeatures — single source of truth for the feature list that
2
+ // boot AND the schema generator see.
3
3
  //
4
- // Sowohl runDevApp als auch runProdApp mischen im auth-mode dieselben
5
- // vier Bundled-Features dazu (config + user + tenant + auth-email-pw).
6
- // Damit der drizzle-Schema-Generator pro App genau dieselbe Feature-
7
- // Liste sieht wie die Runtime, leben die Komposition hier beide
8
- // Bootstrap-Wrapper UND der per-app drizzle/generate.ts rufen sie auf.
4
+ // Both runDevApp and runProdApp mix in the same bundled features in
5
+ // auth-mode (config + user + tenant + auth-email-password, plus
6
+ // auth-self-registration when authOptions.signup is set). So the
7
+ // drizzle schema generator sees the exact same feature list per app as
8
+ // the runtime, the composition lives here both bootstrap wrappers AND
9
+ // each app's drizzle/generate.ts call it.
9
10
  //
10
- // Reihenfolge: Infrastruktur-Features (config/user/tenant) zuerst, dann
11
- // auth-email-password, dann die App-Features. Spätere Features dürfen
12
- // auf Frühere referenzieren (z.B. authClaims-Hooks an user/tenant).
11
+ // Order: infrastructure features (config/user/tenant) first, then
12
+ // auth-email-password (+ auth-self-registration when authOptions.signup
13
+ // is set), then the app features. Later features may reference earlier
14
+ // ones (e.g. authClaims hooks on user/tenant).
13
15
 
14
16
  import {
15
17
  type AccountUnlockOptions,
16
18
  type AuthEmailPasswordOptions,
17
19
  type AuthMailLocale,
18
20
  createAuthEmailPasswordFeature,
21
+ createAuthSelfRegistrationToggleFeature,
19
22
  type EmailVerificationOptions,
20
23
  type InviteOptions,
21
24
  type PasswordResetOptions,
@@ -32,7 +35,8 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
32
35
 
33
36
  export type ComposeFeaturesOptions = {
34
37
  /** When true, prepends config + user + tenant + auth-email-password
35
- * before the app features. Mirror of "auth-mode" in run{Dev,Prod}App. */
38
+ * (+ auth-self-registration when authOptions.signup is set) before the
39
+ * app features. Mirror of "auth-mode" in run{Dev,Prod}App. */
36
40
  readonly includeBundled: boolean;
37
41
  /** Optional auth-feature-options durchgereicht an
38
42
  * createAuthEmailPasswordFeature. Wenn passwordReset / emailVerification
@@ -75,6 +79,14 @@ export function composeFeatures(
75
79
  createUserFeature(),
76
80
  createTenantFeature(),
77
81
  createAuthEmailPasswordFeature(authOptions ?? {}),
82
+ // signup-request/signup-confirm are registered whenever authOptions.signup
83
+ // is set (see above), but the handler itself no-ops unless the companion
84
+ // toggle feature is mounted (ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))
85
+ // — without this, apps using the includeBundled convenience path get
86
+ // self-signup silently broken (always-200 anti-enumeration contract masks
87
+ // it as success). Mount it alongside signup, default ON, matching the
88
+ // "on unless an operator flips it off at runtime" contract.
89
+ ...(authOptions?.signup !== undefined ? [createAuthSelfRegistrationToggleFeature()] : []),
78
90
  ];
79
91
  const bundledNames = new Set(bundled.map((f) => f.name));
80
92
  const filteredApp: FeatureDefinition[] = [];
@@ -1,4 +1,5 @@
1
1
  import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
+ import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
2
3
  import { bindMfaRevokeAllOtherSessionsFromFeature } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
3
4
  import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
4
5
  import {
@@ -15,10 +16,7 @@ import {
15
16
  createSecretsContext,
16
17
  SECRETS_FEATURE_NAME,
17
18
  } from "@cosmicdrift/kumiko-bundled-features/secrets";
18
- import {
19
- bindAutoRevokeFromFeature,
20
- createSessionCallbacks,
21
- } from "@cosmicdrift/kumiko-bundled-features/sessions";
19
+ import { bindAutoRevokeFromFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
20
  import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
23
21
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
24
22
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
@@ -38,7 +36,6 @@ import type {
38
36
  PasswordResetSetup,
39
37
  SignupSetup,
40
38
  } from "./run-prod-app";
41
- import type { ProdSessionsConfig } from "./session-wiring";
42
39
 
43
40
  // Boot-time context helpers for runProdApp: ctx-extra-context wiring
44
41
  // (textContent/delivery/secrets/config-resolver), auth-mail convenience
@@ -214,33 +211,27 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
214
211
  };
215
212
  }
216
213
 
217
- export function buildProdSessionAuth(
214
+ export async function buildProdSessionAuth(
218
215
  db: DbConnection,
219
- opts: ProdSessionsConfig,
216
+ registry: Registry,
220
217
  sessionsFeature: FeatureDefinition | undefined,
221
218
  mfaFeature: FeatureDefinition | undefined,
222
- ): {
223
- readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
224
- readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
225
- readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
226
- } {
227
- const cbs = createSessionCallbacks({
228
- db,
229
- ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
230
- });
231
- // Secure-by-default: password-change/-reset mass-revokes the user's live
232
- // sessions without the app opting in via autoRevokeOnPasswordChange.
219
+ ): Promise<{
220
+ readonly sessionCreator: Awaited<ReturnType<typeof resolveSessionStore>>["creator"];
221
+ readonly sessionRevoker: Awaited<ReturnType<typeof resolveSessionStore>>["revoker"];
222
+ readonly sessionChecker: Awaited<ReturnType<typeof resolveSessionStore>>["checker"];
223
+ }> {
224
+ // Resolve the sessions feature sessionStore provider (#1372).
225
+ const store = await resolveSessionStore({ db, registry });
233
226
  if (sessionsFeature) {
234
- bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
227
+ bindAutoRevokeFromFeature(sessionsFeature)?.(store.massRevoker);
235
228
  }
236
- // MFA enable/disable/regenerate mass-revokes every OTHER live session
237
- // (stolen-session defense) — only wired when auth-mfa is mounted.
238
229
  if (mfaFeature) {
239
- bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(cbs.sessionRevokeAllOthers);
230
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(store.revokeAllOthers);
240
231
  }
241
232
  return {
242
- sessionCreator: cbs.sessionCreator,
243
- sessionRevoker: cbs.sessionRevoker,
244
- sessionChecker: cbs.sessionChecker,
233
+ sessionCreator: store.creator,
234
+ sessionRevoker: store.revoker,
235
+ sessionChecker: store.checker,
245
236
  };
246
237
  }
@@ -43,7 +43,14 @@ export async function readStaticFile(
43
43
  const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
44
44
  return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
45
45
  } catch (err) {
46
- if ((err as { code?: string }).code === "ENOENT") return undefined;
46
+ const code = (err as { code?: string }).code;
47
+ // ENOENT: no such path. EISDIR: readFile() on a directory (e.g. GET
48
+ // /assets where "assets" is a subfolder copied verbatim from public/).
49
+ // ENOTDIR: a path segment that isn't a directory is used as one (e.g.
50
+ // GET /index.html/x — "index.html" is a file, not a directory) — all
51
+ // three mean "not a servable file", so fall through to the SPA
52
+ // fallback instead of a 500.
53
+ if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR") return undefined;
47
54
  throw err;
48
55
  }
49
56
  }
@@ -27,6 +27,15 @@
27
27
  // JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
28
28
  // JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
29
29
  // still verify in-flight tokens until they expire)
30
+ // Adopting rotation for the first time (no JWT_SECRET_V<n> set yet): the
31
+ // plain JWT_SECRET above stays set and is carried into the keyring as a
32
+ // verify-only legacy key (loadJwtSecretOrKeyring), so sessions signed
33
+ // before the cutover keep verifying — no mass-logout at adoption. This
34
+ // legacy key never expires on its own (boot logs a warning while it's
35
+ // present) — once max token TTL has elapsed since cutover, unset
36
+ // JWT_SECRET to retire it. Check first whether an app relies on the
37
+ // auth.mail convenience default for passwordReset/emailVerification
38
+ // hmacSecret (falls back to this same JWT_SECRET) before unsetting it.
30
39
  // PORT=3000
31
40
  // KUMIKO_INSTANCE_ID=<stable per replica>
32
41
 
@@ -46,7 +55,9 @@ import {
46
55
  seedAdmin,
47
56
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
48
57
  import {
58
+ EXT_SESSION_STORE,
49
59
  EXT_TOKEN_VERIFIER,
60
+ resolveAnonymousAccessFromRegistry,
50
61
  resolveTokenVerifier,
51
62
  } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
52
63
  import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
@@ -140,11 +151,7 @@ import {
140
151
  import { buildStaticFallback } from "./run-prod-app-static-files";
141
152
  import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
142
153
  import { assertSessionBootInvariants } from "./session-boot-gate";
143
- import {
144
- type ProdSessionsOption,
145
- resolveProdSessionsConfig,
146
- shouldWireProdSessions,
147
- } from "./session-wiring";
154
+ import { shouldWireProdSessions } from "./session-wiring";
148
155
 
149
156
  export { buildBunServeOptions } from "./bun-serve-options";
150
157
  export {
@@ -287,14 +294,6 @@ export type RunProdAppAuthOptions = {
287
294
  readonly admin: SeedAdminOptions;
288
295
  /** Optional override of the login error → HTTP status map. */
289
296
  readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
290
- /** Opt-in: revocable server-side sessions. Caller MUSS
291
- * `createSessionsFeature()` zu `features` adden — runProdApp wired
292
- * hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
293
- * echte db-connection (sidless JWTs werden dann abgelehnt).
294
- *
295
- * Standardverhalten ohne diese Option: stateless JWTs ohne sid
296
- * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
297
- readonly sessions?: ProdSessionsOption;
298
297
  /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
299
298
  * emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
300
299
  * Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
@@ -345,6 +344,13 @@ export type RunProdAppAuthOptions = {
345
344
  * — accept the wide-cookie CSRF risk explicitly instead of setting
346
345
  * `allowedOrigins`. */
347
346
  readonly unsafeSkipOriginCheck?: boolean;
347
+ /** Number of trusted reverse-proxy hops between the client and this
348
+ * process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops,
349
+ * kumiko-framework#1539) — closes the X-Forwarded-For spoofing hole on
350
+ * the auth rate-limiters. Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS`
351
+ * env var when unset; both unset means the pre-#1539 spoofable default
352
+ * (0 hops). Set this to your real ingress hop count (typically 1). */
353
+ readonly trustedProxyHops?: number;
348
354
  };
349
355
 
350
356
  /** Hook for app-specific seeding — runs after the admin (when auth is
@@ -368,10 +374,8 @@ export type RunProdAppDeps = {
368
374
  };
369
375
 
370
376
  export type AnonymousAccessOption =
371
- | import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]
372
- | ((
373
- deps: RunProdAppDeps,
374
- ) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
377
+ | import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig
378
+ | ((deps: RunProdAppDeps) => import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig);
375
379
 
376
380
  export type ExtraContextOption =
377
381
  | Record<string, unknown>
@@ -621,6 +625,22 @@ export type ProdAppHandle = {
621
625
  readonly stop: () => Promise<void>;
622
626
  };
623
627
 
628
+ let warnedLegacyJwtSecret = false;
629
+ function warnLegacyJwtSecretOnce(): void {
630
+ // skip: already warned once this process — avoid log spam on every boot path.
631
+ if (warnedLegacyJwtSecret) return;
632
+ warnedLegacyJwtSecret = true;
633
+ // biome-ignore lint/suspicious/noConsole: boot-time ops hint, no logger configured this early
634
+ console.warn(
635
+ "[runProdApp] JWT keyring carries a legacy (pre-rotation) verify-only key — " +
636
+ "it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
637
+ "fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
638
+ "set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
639
+ "that is no longer present in any in-flight token (see resolveAuthMail / " +
640
+ "loadJwtSecretOrKeyring).",
641
+ );
642
+ }
643
+
624
644
  export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
625
645
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
626
646
  // - operators can introspect env-requirements without a real boot
@@ -691,8 +711,36 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
691
711
  // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
692
712
  const jwtSecret = requireEnv("JWT_SECRET", envSource);
693
713
  const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
714
+ if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
715
+ // Once per process — JWT_SECRET stays a required env (requireEnv above +
716
+ // resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
717
+ // means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
718
+ // to a fresh value that is no longer in any in-flight token; unsetting
719
+ // JWT_SECRET entirely hard-crashes boot.
720
+ warnLegacyJwtSecretOnce();
721
+ }
694
722
  const jwtIssuer = readEnv("JWT_ISSUER", envSource);
695
723
  const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
724
+ // kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
725
+ // to the env var so ops can close the XFF-spoofing hole per-deployment
726
+ // without a code change (mirrors instanceId's env-first pattern above).
727
+ // Fail loud on a garbage env value rather than silently coercing to NaN:
728
+ // clientIpOf treats NaN like "always short chain" and returns "unknown"
729
+ // for every request, which collapses mfa-verify/preauth-confirm's
730
+ // pure-IP-keyed rate limiter into one shared bucket for the whole
731
+ // deployment — a self-inflicted DoS, worse than staying on the default.
732
+ const trustedProxyHopsFromEnv = readEnv("KUMIKO_TRUSTED_PROXY_HOPS", envSource);
733
+ const trustedProxyHops = ((): number | undefined => {
734
+ if (options.auth?.trustedProxyHops !== undefined) return options.auth.trustedProxyHops;
735
+ if (trustedProxyHopsFromEnv === undefined) return undefined;
736
+ const parsed = Number.parseInt(trustedProxyHopsFromEnv, 10);
737
+ if (!Number.isInteger(parsed) || parsed < 0) {
738
+ throw new Error(
739
+ `runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "${trustedProxyHopsFromEnv}".`,
740
+ );
741
+ }
742
+ return parsed;
743
+ })();
696
744
  const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
697
745
 
698
746
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
@@ -726,12 +774,11 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
726
774
  mode: "prod",
727
775
  });
728
776
  const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
777
+ const registry = createRegistry(features);
729
778
  assertSessionBootInvariants({
730
779
  hasAuth: Boolean(effectiveAuth),
731
- sessionsFeatureMounted: sessionsFeature !== undefined,
732
- sessionsOption: effectiveAuth?.sessions,
780
+ sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
733
781
  });
734
- const registry = createRegistry(features);
735
782
 
736
783
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
737
784
  // is constructed at all in this branch (the eager `new Redis(...)` below
@@ -861,10 +908,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
861
908
  { ...autoExtraContext, ...resolvedExtraContext },
862
909
  registry,
863
910
  );
864
- const resolvedAnonymousAccess =
911
+ const baseAnonymousAccess =
865
912
  typeof options.anonymousAccess === "function"
866
913
  ? options.anonymousAccess(deps)
867
914
  : options.anonymousAccess;
915
+ // #1374: tenantResolver / tenantExists come from auth-foundation providers.
916
+ const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
917
+ db,
918
+ registry,
919
+ });
868
920
 
869
921
  // Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
870
922
  // also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
@@ -874,22 +926,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
874
926
  // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
875
927
  // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
876
928
  // über die auth-routes.
877
- // Secure-by-default: if the sessions feature is mounted, server-side revocation +
878
- // auto-revoke-on-password-change are wired automatically;
879
- // `auth.sessions` only overrides the config, and `auth.sessions: false` is the
880
- // explicit opt-out (back to stateless JWTs).
929
+ // Secure-by-default (#1372): sessionStore provider resolveSessionStore.
881
930
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
882
931
  const sessionAuthFragment = shouldWireProdSessions(
883
932
  Boolean(effectiveAuth),
884
- sessionsFeature !== undefined,
885
- effectiveAuth?.sessions,
933
+ registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
886
934
  )
887
- ? buildProdSessionAuth(
888
- db,
889
- resolveProdSessionsConfig(effectiveAuth?.sessions),
890
- sessionsFeature,
891
- mfaFeature,
892
- )
935
+ ? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature)
893
936
  : undefined;
894
937
 
895
938
  // Token-verifier opt-in: any provider feature (personal-access-tokens, a
@@ -970,17 +1013,32 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
970
1013
  ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
971
1014
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
972
1015
  }),
1016
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
973
1017
  ...sessionAuthFragment,
974
1018
  ...patAuthFragment,
975
1019
  ...tenantLifecycleAuthFragment,
976
1020
  ...(mfaFeature && {
977
1021
  mfaVerifyHandler: AuthMfaHandlers.verify,
1022
+ mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
1023
+ mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
978
1024
  mfaVerifyRateLimit: createRedisLoginRateLimiter(
979
1025
  redis,
980
1026
  undefined,
981
1027
  undefined,
982
1028
  "mfa-verify",
983
1029
  ),
1030
+ mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(
1031
+ redis,
1032
+ undefined,
1033
+ undefined,
1034
+ "mfa-preauth-start",
1035
+ ),
1036
+ mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(
1037
+ redis,
1038
+ undefined,
1039
+ undefined,
1040
+ "mfa-preauth-confirm",
1041
+ ),
984
1042
  }),
985
1043
  ...(effectiveAuth.passwordReset && {
986
1044
  passwordReset: {
@@ -1,29 +1,20 @@
1
- import type { ProdSessionsOption } from "./session-wiring";
2
-
3
1
  export type SessionBootGateOptions = {
4
2
  readonly hasAuth: boolean;
5
- readonly sessionsFeatureMounted: boolean;
6
- readonly sessionsOption: ProdSessionsOption | undefined;
3
+ readonly sessionStoreProviderMounted: boolean;
7
4
  };
8
5
 
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.
6
+ // Catch a forgotten sessions mount at boot instead of silently degrading
7
+ // into stateless JWTs (#1372). Mount createSessionsFeature() for revocable
8
+ // sessions; there is no auth.sessions opt-out anymore.
14
9
  export function assertSessionBootInvariants(opts: SessionBootGateOptions): void {
15
10
  // skip: no auth mounted — nothing to gate.
16
11
  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;
12
+ // skip: sessionStore provider is wired (sessions feature).
13
+ if (opts.sessionStoreProviderMounted) return;
21
14
 
22
15
  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.",
16
+ "[runProdApp] BOOT ABORTED — auth is mounted but no sessionStore provider is registered. " +
17
+ "JWTs would be stateless (no server-side revocation). Mount createSessionsFeature() " +
18
+ "(@cosmicdrift/kumiko-bundled-features/sessions) alongside auth-foundation.",
28
19
  );
29
20
  }