@cosmicdrift/kumiko-dev-server 0.100.0 → 0.102.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-dev-server",
3
- "version": "0.100.0",
3
+ "version": "0.102.1",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -50,8 +50,8 @@
50
50
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "0.100.0",
54
- "@cosmicdrift/kumiko-framework": "0.100.0",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.102.1",
54
+ "@cosmicdrift/kumiko-framework": "0.102.1",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
5
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
6
+ import { buildBootExtraContext } from "../run-prod-app";
7
+
8
+ // Pins runProdApp's framework-default-provider autowire (buildBootExtraContext):
9
+ // textContent unconditional, secrets feature-gated, KEK-env trap avoided, and
10
+ // the money-horse regression (no secrets feature → no forced KEK → no throw).
11
+
12
+ // db is never touched at construction time — createTextContentApi /
13
+ // createSecretsContext only store the handle and query lazily. A bare stub
14
+ // keeps this a fast unit test (no Postgres) instead of a full boot.
15
+ const fakeDb = {} as unknown as DbConnection;
16
+ const registry = createRegistry([]);
17
+ const KEK = Buffer.alloc(32, 7).toString("base64");
18
+
19
+ const otherFeature = defineFeature("widgets", () => {});
20
+
21
+ describe("buildBootExtraContext — framework-default provider autowire", () => {
22
+ test("textContent is wired unconditionally (no secrets feature, no auth)", () => {
23
+ const ctx = buildBootExtraContext({
24
+ db: fakeDb,
25
+ features: [otherFeature],
26
+ envSource: {},
27
+ registry,
28
+ hasAuth: false,
29
+ });
30
+ expect(typeof (ctx["textContent"] as { getBlock?: unknown }).getBlock).toBe("function");
31
+ expect(ctx["secrets"]).toBeUndefined();
32
+ expect(ctx["configResolver"]).toBeUndefined();
33
+ });
34
+
35
+ test("secrets feature mounted + valid KEK env → ctx.secrets wired", () => {
36
+ const ctx = buildBootExtraContext({
37
+ db: fakeDb,
38
+ features: [createSecretsFeature()],
39
+ envSource: {
40
+ KUMIKO_SECRETS_MASTER_KEY_V1: KEK,
41
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
42
+ },
43
+ registry,
44
+ hasAuth: false,
45
+ });
46
+ const secrets = ctx["secrets"] as {
47
+ get?: unknown;
48
+ set?: unknown;
49
+ has?: unknown;
50
+ delete?: unknown;
51
+ };
52
+ expect(typeof secrets.get).toBe("function");
53
+ expect(typeof secrets.set).toBe("function");
54
+ });
55
+
56
+ test("secrets feature mounted, only V1 set (no CURRENT_VERSION) → wired (default '1')", () => {
57
+ const ctx = buildBootExtraContext({
58
+ db: fakeDb,
59
+ features: [createSecretsFeature()],
60
+ envSource: { KUMIKO_SECRETS_MASTER_KEY_V1: KEK },
61
+ registry,
62
+ hasAuth: false,
63
+ });
64
+ expect(ctx["secrets"]).toBeDefined();
65
+ });
66
+
67
+ test("secrets feature mounted but NO KEK + no override → skipped, no throw (dev DEV_KEY path)", () => {
68
+ // dev wires its own DEV_KEY secrets explicitly; the auto-wire must not
69
+ // eagerly construct an env-provider and crash the boot when no env KEK
70
+ // exists. ctx.secrets stays unset so the app's explicit wiring wins.
71
+ let ctx: Record<string, unknown> | undefined;
72
+ expect(() => {
73
+ ctx = buildBootExtraContext({
74
+ db: fakeDb,
75
+ features: [createSecretsFeature()],
76
+ envSource: {},
77
+ registry,
78
+ hasAuth: false,
79
+ });
80
+ }).not.toThrow();
81
+ expect(ctx?.["secrets"]).toBeUndefined();
82
+ });
83
+
84
+ test("no secrets feature → no KEK ever read, boots green (money-horse regression)", () => {
85
+ const ctx = buildBootExtraContext({
86
+ db: fakeDb,
87
+ features: [otherFeature],
88
+ envSource: {}, // no KEK — must NOT matter when secrets isn't mounted
89
+ registry,
90
+ hasAuth: false,
91
+ });
92
+ expect(ctx["secrets"]).toBeUndefined();
93
+ });
94
+
95
+ test("masterKey override is used verbatim — no env-provider built", () => {
96
+ const override: MasterKeyProvider = {
97
+ wrapDek: async () => ({ encryptedDek: Buffer.alloc(0), kekVersion: 1 }),
98
+ unwrapDek: async () => Buffer.alloc(0),
99
+ currentVersion: () => 1,
100
+ isAvailable: async () => true,
101
+ };
102
+ // envSource is empty: if the override weren't honoured, createEnvMasterKeyProvider
103
+ // would throw "no KEK". It doesn't → the override path is taken.
104
+ const ctx = buildBootExtraContext({
105
+ db: fakeDb,
106
+ features: [createSecretsFeature()],
107
+ envSource: {},
108
+ registry,
109
+ hasAuth: false,
110
+ masterKey: override,
111
+ });
112
+ expect(ctx["secrets"]).toBeDefined();
113
+ });
114
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type RunProdAppAuthOptions, resolveAuthMail } from "../run-prod-app";
3
+
4
+ // Pins the auth.mail convenience (resolveAuthMail): one mail block expands
5
+ // into the four explicit flow setups built from DEFAULT_AUTH_PATHS, the
6
+ // null-transport guard (no SMTP_HOST → flows stay unwired), and explicit
7
+ // per-flow setups winning over the mail default.
8
+
9
+ const admin: RunProdAppAuthOptions["admin"] = {
10
+ email: "admin@example.com",
11
+ password: "pw-long-enough",
12
+ displayName: "Admin",
13
+ memberships: [],
14
+ };
15
+
16
+ const withMail: RunProdAppAuthOptions = {
17
+ admin,
18
+ mail: { baseUrl: "https://app.example.com", appName: "Test" },
19
+ };
20
+
21
+ describe("resolveAuthMail", () => {
22
+ test("no mail block → auth returned unchanged", () => {
23
+ const noMail: RunProdAppAuthOptions = { admin };
24
+ const out = resolveAuthMail(noMail, "secret", { SMTP_HOST: "localhost" });
25
+ expect(out.passwordReset).toBeUndefined();
26
+ expect(out.signup).toBeUndefined();
27
+ });
28
+
29
+ test("mail + SMTP_HOST → all four flows wired from DEFAULT_AUTH_PATHS", () => {
30
+ const out = resolveAuthMail(withMail, "secret", { SMTP_HOST: "localhost" });
31
+ expect(out.passwordReset?.appResetUrl).toBe("https://app.example.com/reset-password");
32
+ expect(out.emailVerification?.appVerifyUrl).toBe("https://app.example.com/verify-email");
33
+ expect(out.signup?.appActivationUrl).toBe("https://app.example.com/signup/complete");
34
+ expect(out.invite?.appAcceptUrl).toBe("https://app.example.com/invite/accept");
35
+ expect(typeof out.passwordReset?.sendResetEmail).toBe("function");
36
+ });
37
+
38
+ test("mail but NO SMTP_HOST → null-transport guard, flows stay unwired", () => {
39
+ const out = resolveAuthMail(withMail, "secret", {});
40
+ expect(out.passwordReset).toBeUndefined();
41
+ expect(out.signup).toBeUndefined();
42
+ });
43
+
44
+ test("explicit per-flow setup wins over the mail default", () => {
45
+ const explicit: RunProdAppAuthOptions = {
46
+ ...withMail,
47
+ passwordReset: {
48
+ hmacSecret: "h",
49
+ appResetUrl: "https://custom.example.com/pw",
50
+ sendResetEmail: async () => {},
51
+ },
52
+ };
53
+ const out = resolveAuthMail(explicit, "secret", { SMTP_HOST: "localhost" });
54
+ expect(out.passwordReset?.appResetUrl).toBe("https://custom.example.com/pw");
55
+ // other flows still come from the mail default
56
+ expect(out.signup?.appActivationUrl).toBe("https://app.example.com/signup/complete");
57
+ });
58
+
59
+ test("paths override only affects the named path", () => {
60
+ const pathsOverride: RunProdAppAuthOptions = {
61
+ admin,
62
+ mail: { baseUrl: "https://app.example.com", paths: { resetPassword: "/pw" } },
63
+ };
64
+ const out = resolveAuthMail(pathsOverride, "secret", { SMTP_HOST: "localhost" });
65
+ expect(out.passwordReset?.appResetUrl).toBe("https://app.example.com/pw");
66
+ expect(out.emailVerification?.appVerifyUrl).toBe("https://app.example.com/verify-email");
67
+ });
68
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { resolveProdSessionsConfig, shouldWireProdSessions } from "../session-wiring";
3
+
4
+ describe("shouldWireProdSessions — secure-by-default with opt-out (KF-1)", () => {
5
+ it("wires sessions when the feature is mounted, even without an explicit config", () => {
6
+ // The publicstatus bug: sessions feature mounted + auth set, but no auth.sessions —
7
+ // previously left stateless (no revocation). Now it wires automatically.
8
+ expect(shouldWireProdSessions(true, true, undefined)).toBe(true);
9
+ });
10
+
11
+ it("wires sessions when a config object is given", () => {
12
+ expect(shouldWireProdSessions(true, true, { expiresInMs: 1000 })).toBe(true);
13
+ });
14
+
15
+ it("does not wire when sessions: false (explicit opt-out)", () => {
16
+ expect(shouldWireProdSessions(true, true, false)).toBe(false);
17
+ });
18
+
19
+ it("does not wire when the sessions feature is not mounted", () => {
20
+ expect(shouldWireProdSessions(true, false, undefined)).toBe(false);
21
+ });
22
+
23
+ it("does not wire when the app has no auth at all", () => {
24
+ expect(shouldWireProdSessions(false, true, undefined)).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("resolveProdSessionsConfig", () => {
29
+ it("passes a config object through", () => {
30
+ expect(resolveProdSessionsConfig({ expiresInMs: 5000 })).toEqual({ expiresInMs: 5000 });
31
+ });
32
+
33
+ it("collapses false / undefined to defaults", () => {
34
+ expect(resolveProdSessionsConfig(undefined)).toEqual({});
35
+ expect(resolveProdSessionsConfig(false)).toEqual({});
36
+ });
37
+ });
@@ -41,6 +41,7 @@ import {
41
41
  validateAppCustomScreenWriteQns,
42
42
  validateBoot,
43
43
  } from "@cosmicdrift/kumiko-framework/engine";
44
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
44
45
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
45
46
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
46
47
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
@@ -58,6 +59,7 @@ import { renderWelcomeBanner } from "./welcome-banner";
58
59
  // brauchen. PasswordResetSetup / EmailVerificationSetup leben in
59
60
  // run-prod-app.ts (single source of truth) — hier nur durchgereicht.
60
61
  export type {
62
+ AuthMailOptions,
61
63
  EmailVerificationSetup,
62
64
  InviteSetup,
63
65
  PasswordResetSetup,
@@ -65,12 +67,13 @@ export type {
65
67
  } from "./run-prod-app";
66
68
 
67
69
  import type {
70
+ AuthMailOptions,
68
71
  EmailVerificationSetup,
69
72
  InviteSetup,
70
73
  PasswordResetSetup,
71
74
  SignupSetup,
72
75
  } from "./run-prod-app";
73
- import { addConfigAccessorFactory } from "./run-prod-app";
76
+ import { addConfigAccessorFactory, buildBootExtraContext, resolveAuthMail } from "./run-prod-app";
74
77
 
75
78
  export type RunDevAppAuthOptions = {
76
79
  /** Admin user to seed at boot. Idempotent — re-runs in persistent-DB
@@ -90,6 +93,12 @@ export type RunDevAppAuthOptions = {
90
93
  readonly sessions?: {
91
94
  readonly expiresInMs?: number;
92
95
  };
96
+ /** Auth-Mail-Convenience — symmetrisch zu RunProdAppAuthOptions.mail.
97
+ * Verdrahtet alle 4 Mail-Flows aus env-SMTP + Standard-Templates;
98
+ * hmacSecret = `JWT_SECRET`-env (Dev-Fallback wenn ungesetzt). Ohne
99
+ * `SMTP_HOST`-env bleiben die Flows unverdrahtet (Null-Transport-Guard).
100
+ * Explizite Flow-Setups gewinnen über den mail-Default. */
101
+ readonly mail?: AuthMailOptions;
93
102
  /** Password-reset flow. Wenn gesetzt werden /api/auth/request-password-
94
103
  * reset + /api/auth/reset-password als Public-Routes gemounted UND
95
104
  * der request/confirm-Handler im auth-email-password-Feature wird
@@ -154,9 +163,14 @@ export type RunDevAppOptions = {
154
163
  /** Eigene Seed-Funktionen, laufen nach dem Admin (wenn auth) in
155
164
  * Array-Reihenfolge. */
156
165
  readonly seeds?: readonly SeedFn[];
157
- /** Extra-AppContext-Keys. Im auth-mode wird `configResolver` automatisch
158
- * hinzugefügt kein Override durch den Caller nötig. */
166
+ /** Extra-AppContext-Keys. `textContent` (immer) + `secrets` (wenn das
167
+ * secrets-Feature gemountet ist) + `configResolver` (auth-mode) werden
168
+ * automatisch ergänzt — App-Werte gewinnen. Symmetrisch zu runProdApp. */
159
169
  readonly extraContext?: CreateKumikoServerOptions["extraContext"];
170
+ /** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
171
+ * `createEnvMasterKeyProvider`. Override für KMS-Backends. Nur relevant
172
+ * wenn das secrets-Feature gemountet ist. Symmetrisch zu runProdApp. */
173
+ readonly masterKey?: MasterKeyProvider;
160
174
  /** Env-Quelle für die ENV→config-app-override-Brücke (Keys mit `env:`
161
175
  * bekommen ihren env-Wert als app-override-Default — symmetrisch zu
162
176
  * runProdApp). Default `process.env`. Injizierbar als Test-Seam, damit
@@ -194,9 +208,21 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
194
208
  // source of truth — auch runProdApp und der per-app drizzle-Schema-
195
209
  // Generator nutzen denselben Helper, damit Migration und Runtime nie
196
210
  // auseinanderdriften können).
197
- const composeAuthOptions = buildComposeAuthOptions(options.auth);
211
+ const envSource = options.envSource ?? process.env;
212
+ // auth.mail → normalisiert in die expliziten Flow-Felder (symmetrisch zu
213
+ // runProdApp). hmacSecret = JWT_SECRET-env mit Dev-Fallback (der Dev-Server
214
+ // mintet JWTs selbst; der Reset-Token-HMAC teilt denselben Key wie in den
215
+ // Apps). Ab hier IMMER effectiveAuth statt options.auth.
216
+ const effectiveAuth = options.auth
217
+ ? resolveAuthMail(
218
+ options.auth,
219
+ envSource["JWT_SECRET"] ?? "kumiko-dev-auth-mail-hmac-fallback-secret",
220
+ envSource,
221
+ )
222
+ : undefined;
223
+ const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
198
224
  const features = composeFeatures(options.features, {
199
- includeBundled: !!options.auth,
225
+ includeBundled: !!effectiveAuth,
200
226
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
201
227
  });
202
228
 
@@ -277,13 +303,26 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
277
303
  // throwaway-Registry hier extrahiert nur die config-Keys, weil der
278
304
  // configResolver vor dem Server-Boot konstruiert wird (stack.registry
279
305
  // gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
280
- const extraContext = options.auth
281
- ? mergeConfigResolverDefault(
282
- options.extraContext,
283
- createRegistry(features),
284
- options.envSource ?? process.env,
285
- )
306
+ const cfgExtra = effectiveAuth
307
+ ? mergeConfigResolverDefault(options.extraContext, createRegistry(features), envSource)
286
308
  : options.extraContext;
309
+ // Auto-wire textContent (immer) + secrets (feature-gated), symmetrisch zu
310
+ // runProdApp. Anders als prod existiert die db hier erst im Factory-deps
311
+ // (createKumikoServer baut den Stack), darum als Factory: buildBootExtraContext
312
+ // mit hasAuth:false (configResolver kommt schon aus cfgExtra), App-Werte
313
+ // (cfgExtra) gewinnen über die Boot-Defaults.
314
+ const extraContext: CreateKumikoServerOptions["extraContext"] = (deps) => {
315
+ const boot = buildBootExtraContext({
316
+ db: deps.db,
317
+ features,
318
+ envSource,
319
+ registry: deps.registry,
320
+ hasAuth: false,
321
+ ...(options.masterKey && { masterKey: options.masterKey }),
322
+ });
323
+ const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
324
+ return { ...boot, ...base };
325
+ };
287
326
 
288
327
  // Sessions opt-in: Holder lebt im closure, `createSessionCallbacks`
289
328
  // kennt erst nach setupTestStack die echte db-connection. Inline
@@ -300,7 +339,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
300
339
  return sessionCallbacks;
301
340
  };
302
341
  const sessionAuthFragment =
303
- options.auth?.sessions !== undefined
342
+ effectiveAuth?.sessions !== undefined
304
343
  ? {
305
344
  sessionCreator: (user: SessionUser, meta: SessionMetadata) =>
306
345
  requireSessions().sessionCreator(user, meta),
@@ -334,55 +373,55 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
334
373
  ...(finalEffectiveFeatures !== undefined && {
335
374
  effectiveFeatures: finalEffectiveFeatures,
336
375
  }),
337
- ...(options.auth && {
376
+ ...(effectiveAuth && {
338
377
  auth: {
339
378
  membershipQuery: TenantQueries.memberships,
340
379
  loginHandler: AuthHandlers.login,
341
- loginErrorStatusMap: options.auth.loginErrorStatusMap ?? {
380
+ loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
342
381
  [AuthErrors.invalidCredentials]: 401,
343
382
  [AuthErrors.noMembership]: 403,
344
383
  },
345
- ...(options.auth.cookieDomain !== undefined && {
346
- cookieDomain: options.auth.cookieDomain,
384
+ ...(effectiveAuth.cookieDomain !== undefined && {
385
+ cookieDomain: effectiveAuth.cookieDomain,
347
386
  }),
348
- ...(options.auth.allowedOrigins !== undefined && {
349
- allowedOrigins: options.auth.allowedOrigins,
387
+ ...(effectiveAuth.allowedOrigins !== undefined && {
388
+ allowedOrigins: effectiveAuth.allowedOrigins,
350
389
  }),
351
- ...(options.auth.unsafeSkipOriginCheck !== undefined && {
352
- unsafeSkipOriginCheck: options.auth.unsafeSkipOriginCheck,
390
+ ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
391
+ unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
353
392
  }),
354
393
  ...sessionAuthFragment,
355
- ...(options.auth.passwordReset && {
394
+ ...(effectiveAuth.passwordReset && {
356
395
  passwordReset: {
357
396
  requestHandler: AuthHandlers.requestPasswordReset,
358
397
  confirmHandler: AuthHandlers.resetPassword,
359
- sendResetEmail: options.auth.passwordReset.sendResetEmail,
360
- appResetUrl: options.auth.passwordReset.appResetUrl,
398
+ sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
399
+ appResetUrl: effectiveAuth.passwordReset.appResetUrl,
361
400
  },
362
401
  }),
363
- ...(options.auth.emailVerification && {
402
+ ...(effectiveAuth.emailVerification && {
364
403
  emailVerification: {
365
404
  requestHandler: AuthHandlers.requestEmailVerification,
366
405
  confirmHandler: AuthHandlers.verifyEmail,
367
- sendVerificationEmail: options.auth.emailVerification.sendVerificationEmail,
368
- appVerifyUrl: options.auth.emailVerification.appVerifyUrl,
406
+ sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
407
+ appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
369
408
  },
370
409
  }),
371
- ...(options.auth.signup && {
410
+ ...(effectiveAuth.signup && {
372
411
  signup: {
373
412
  requestHandler: AuthHandlers.signupRequest,
374
413
  confirmHandler: AuthHandlers.signupConfirm,
375
- sendActivationEmail: options.auth.signup.sendActivationEmail,
376
- appActivationUrl: options.auth.signup.appActivationUrl,
414
+ sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
415
+ appActivationUrl: effectiveAuth.signup.appActivationUrl,
377
416
  },
378
417
  }),
379
- ...(options.auth.invite && {
418
+ ...(effectiveAuth.invite && {
380
419
  invite: {
381
420
  acceptHandler: AuthHandlers.inviteAccept,
382
421
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
383
422
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
384
- sendInviteEmail: options.auth.invite.sendInviteEmail,
385
- appAcceptUrl: options.auth.invite.appAcceptUrl,
423
+ sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
424
+ appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
386
425
  },
387
426
  }),
388
427
  },
@@ -398,15 +437,15 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
398
437
  registry: stack.registry,
399
438
  });
400
439
  }
401
- if (options.auth?.sessions !== undefined) {
402
- const expiresInMs = options.auth.sessions.expiresInMs;
440
+ if (effectiveAuth?.sessions !== undefined) {
441
+ const expiresInMs = effectiveAuth.sessions.expiresInMs;
403
442
  sessionCallbacks = createSessionCallbacks({
404
443
  db: stack.db,
405
444
  ...(expiresInMs !== undefined && { expiresInMs }),
406
445
  });
407
446
  }
408
- if (options.auth) {
409
- await seedAdmin(stack.db, options.auth.admin);
447
+ if (effectiveAuth) {
448
+ await seedAdmin(stack.db, effectiveAuth.admin);
410
449
  }
411
450
  // Apply r.config({ seeds }) declared by any registered feature.
412
451
  // Runs before user-supplied seed callbacks so those can read /
@@ -424,10 +463,10 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
424
463
  const port = handle.server?.port ?? options.port ?? 3000;
425
464
  const banner = renderWelcomeBanner({
426
465
  url: `http://localhost:${port}`,
427
- ...(options.auth?.admin && {
466
+ ...(effectiveAuth?.admin && {
428
467
  admin: {
429
- email: options.auth.admin.email,
430
- password: options.auth.admin.password,
468
+ email: effectiveAuth.admin.email,
469
+ password: effectiveAuth.admin.password,
431
470
  },
432
471
  }),
433
472
  ...(overrides.featuresDir !== undefined && { featuresDir: overrides.featuresDir }),
@@ -27,8 +27,13 @@
27
27
  import {
28
28
  AuthErrors,
29
29
  AuthHandlers,
30
+ type AuthMailerConfig,
31
+ type AuthMailLocale,
32
+ type AuthPaths,
33
+ createAuthMailerConfig,
30
34
  type EmailVerificationOptions,
31
35
  type InviteOptions,
36
+ makeAuthPaths,
32
37
  type PasswordResetOptions,
33
38
  type SignupOptions,
34
39
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
@@ -36,13 +41,22 @@ import {
36
41
  type SeedAdminOptions,
37
42
  seedAdmin,
38
43
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
44
+ import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
39
45
  import {
40
46
  buildEnvConfigOverrides,
41
47
  createConfigAccessorFactory,
42
48
  createConfigResolver,
43
49
  } from "@cosmicdrift/kumiko-bundled-features/config";
44
- import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/sessions";
50
+ import {
51
+ createSecretsContext,
52
+ SECRETS_FEATURE_NAME,
53
+ } from "@cosmicdrift/kumiko-bundled-features/secrets";
54
+ import {
55
+ createSessionCallbacks,
56
+ SESSIONS_FEATURE,
57
+ } from "@cosmicdrift/kumiko-bundled-features/sessions";
45
58
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
59
+ import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
46
60
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
47
61
  import {
48
62
  type CachePolicy,
@@ -52,7 +66,11 @@ import {
52
66
  createSseBroker,
53
67
  type SseBroker,
54
68
  } from "@cosmicdrift/kumiko-framework/api";
55
- import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
69
+ import {
70
+ createDbConnection,
71
+ type DbConnection,
72
+ type DbRunner,
73
+ } from "@cosmicdrift/kumiko-framework/db";
56
74
  import {
57
75
  buildAppSchema,
58
76
  type ConfigResolver,
@@ -94,6 +112,10 @@ import {
94
112
  createEventDedup,
95
113
  createIdempotencyGuard,
96
114
  } from "@cosmicdrift/kumiko-framework/pipeline";
115
+ import {
116
+ createEnvMasterKeyProvider,
117
+ type MasterKeyProvider,
118
+ } from "@cosmicdrift/kumiko-framework/secrets";
97
119
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
98
120
  import Redis from "ioredis";
99
121
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
@@ -101,6 +123,12 @@ import { ASSETS_DIR } from "./build-prod-bundle";
101
123
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
102
124
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
103
125
  import { injectSchema } from "./inject-schema";
126
+ import {
127
+ type ProdSessionsConfig,
128
+ type ProdSessionsOption,
129
+ resolveProdSessionsConfig,
130
+ shouldWireProdSessions,
131
+ } from "./session-wiring";
104
132
  import { tryHonoFirst } from "./try-hono-first";
105
133
 
106
134
  /**
@@ -258,6 +286,24 @@ export type InviteSetup = InviteOptions & {
258
286
  readonly appAcceptUrl: string;
259
287
  };
260
288
 
289
+ /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
290
+ * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
291
+ * Templates (siehe `auth.mail` + resolveAuthMail). */
292
+ export type AuthMailOptions = {
293
+ /** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
294
+ readonly baseUrl: string;
295
+ /** App-Name für Mail-Subject + Body. Default "Account". */
296
+ readonly appName?: string;
297
+ /** Locale für die Mail-Templates. Default "de". */
298
+ readonly locale?: AuthMailLocale;
299
+ /** "strict" blockt Login bis emailVerified; "off" mountet ohne Gate. */
300
+ readonly emailVerificationMode?: "strict" | "off";
301
+ /** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
302
+ readonly from?: string;
303
+ /** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
304
+ readonly paths?: Partial<AuthPaths>;
305
+ };
306
+
261
307
  export type RunProdAppAuthOptions = {
262
308
  /** Initial admin user. Seeded once (idempotent — re-boots check first
263
309
  * whether the email is already in the users table). */
@@ -271,13 +317,27 @@ export type RunProdAppAuthOptions = {
271
317
  *
272
318
  * Standardverhalten ohne diese Option: stateless JWTs ohne sid
273
319
  * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
274
- readonly sessions?: {
275
- readonly expiresInMs?: number;
276
- };
320
+ readonly sessions?: ProdSessionsOption;
321
+ /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
322
+ * emailVerification, signup, invite) aus einem SMTP-Transport-aus-env +
323
+ * Standard-Templates. Ersetzt das per-App hand-gerollte
324
+ * `createSmtpTransportFromEnv` + `createAuthMailerConfig`-Wrapper-Trio.
325
+ *
326
+ * Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
327
+ * verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
328
+ * Flow custom braucht, setzt `passwordReset`/`signup`/… explizit —
329
+ * der explizite Block gewinnt über den mail-Default.
330
+ *
331
+ * Bewusste Entscheidung: `mail` mountet ALLE 4 Flows inkl. signup +
332
+ * invite (Self-Registration on). Wer nur reset+verify will, lässt `mail`
333
+ * weg und setzt die gewünschten Flows explizit — kein impliziter
334
+ * Self-Signup. */
335
+ readonly mail?: AuthMailOptions;
277
336
  /** Password-reset flow. When set, /api/auth/request-password-reset +
278
337
  * /api/auth/reset-password are mounted as public routes UND der
279
338
  * request/confirm-Handler im auth-email-password-Feature wird
280
- * registriert (sonst dispatchen die Routes ins Leere → 500). */
339
+ * registriert (sonst dispatchen die Routes ins Leere → 500).
340
+ * Überschreibt den `mail`-Default für genau diesen Flow. */
281
341
  readonly passwordReset?: PasswordResetSetup;
282
342
  /** Email-verification flow. Symmetric to passwordReset. */
283
343
  readonly emailVerification?: EmailVerificationSetup;
@@ -421,13 +481,22 @@ export type RunProdAppOptions = {
421
481
  * Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
422
482
  * ORM). Standard-Apps lassen das default. */
423
483
  readonly migrations?: { readonly dir: string } | false;
424
- /** Extra AppContext keys. configResolver is auto-set in auth-mode.
425
- * Akzeptiert entweder einen statischen Object ODER eine Factory
484
+ /** Extra AppContext keys. Framework-Defaults werden zur Boot-Zeit
485
+ * unter den Factory-Werten ergänzt, App-Werte gewinnen immer:
486
+ * - `textContent` (createTextContentApi(db)) — immer
487
+ * - `secrets` (createSecretsContext) — nur wenn das `secrets`-Feature
488
+ * gemountet ist (sonst kein KEK-env-Zwang für Apps ohne secrets)
489
+ * - `configResolver` — im Auth-Mode (env-config-overrides)
490
+ * Akzeptiert einen statischen Object ODER eine Factory
426
491
  * `({db, redis, registry}) => Record<string, unknown>` — gleiches
427
- * Pattern wie `anonymousAccess`. Im Auth-Mode wird `configResolver`
428
- * weiterhin automatisch ergänzt; Factory-Result + auto-resolver
429
- * werden gemerged (Factory-Werte überschreiben). */
492
+ * Pattern wie `anonymousAccess`. Eine App die einen dieser Keys selbst
493
+ * setzt (z.B. eigener configResolver) überschreibt den Default. */
430
494
  readonly extraContext?: ExtraContextOption;
495
+ /** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
496
+ * `createEnvMasterKeyProvider` (KEK aus `KUMIKO_SECRETS_MASTER_KEY_V<n>`).
497
+ * Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
498
+ * wenn das `secrets`-Feature gemountet ist. */
499
+ readonly masterKey?: MasterKeyProvider;
431
500
  /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
432
501
  * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
433
502
  * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
@@ -547,6 +616,109 @@ export function addConfigAccessorFactory<T extends { readonly configResolver?: C
547
616
  };
548
617
  }
549
618
 
619
+ // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
620
+ // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
621
+ // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
622
+ // Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
623
+ // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
624
+ // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
625
+ // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
626
+ // createEnvMasterKeyProvider nicht wirft. Das deckt zwei Fälle: (a) App ohne
627
+ // secrets → kein KEK-Zwang; (b) dev mit App-eigenem DEV-KEK in extraContext
628
+ // (kein env-KEK) → kein Boot-Crash, die App-explizite secrets-Wiring gewinnt.
629
+ // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
630
+ // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
631
+ // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
632
+ // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
633
+ // passiert beim Caller (App gewinnt).
634
+ const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
635
+ function envHasMasterKek(env: Record<string, string | undefined>): boolean {
636
+ return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
637
+ }
638
+
639
+ export function buildBootExtraContext(opts: {
640
+ readonly db: DbConnection;
641
+ readonly features: readonly FeatureDefinition[];
642
+ readonly envSource: Record<string, string | undefined>;
643
+ readonly registry: Registry;
644
+ readonly hasAuth: boolean;
645
+ readonly masterKey?: MasterKeyProvider;
646
+ }): Record<string, unknown> {
647
+ const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
648
+ const wireSecrets =
649
+ hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
650
+ return {
651
+ textContent: createTextContentApi(opts.db),
652
+ ...(wireSecrets && {
653
+ secrets: createSecretsContext({
654
+ db: opts.db,
655
+ masterKeyProvider:
656
+ opts.masterKey ??
657
+ createEnvMasterKeyProvider({
658
+ // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
659
+ // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
660
+ env: {
661
+ ...opts.envSource,
662
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
663
+ opts.envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
664
+ },
665
+ }),
666
+ }),
667
+ }),
668
+ ...(opts.hasAuth && {
669
+ configResolver: createConfigResolver({
670
+ appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
671
+ }),
672
+ }),
673
+ };
674
+ }
675
+
676
+ // auth.mail-Convenience → normalisiert in die expliziten passwordReset/
677
+ // emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
678
+ // (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
679
+ // so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
680
+ // den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
681
+ // unverdrahtet (sonst lieferten die reset/verify-Routes 500).
682
+ /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
683
+ * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
684
+ type AuthMailNormalizable = {
685
+ readonly mail?: AuthMailOptions;
686
+ readonly passwordReset?: PasswordResetSetup;
687
+ readonly emailVerification?: EmailVerificationSetup;
688
+ readonly signup?: SignupSetup;
689
+ readonly invite?: InviteSetup;
690
+ };
691
+
692
+ export function resolveAuthMail<T extends AuthMailNormalizable>(
693
+ auth: T,
694
+ hmacSecret: string,
695
+ envSource: Record<string, string | undefined>,
696
+ ): T {
697
+ if (!auth.mail) return auth;
698
+ const mailSender = createSmtpTransportFromEnv(envSource, {
699
+ fallbackFrom: auth.mail.from ?? "noreply@localhost",
700
+ });
701
+ if (!mailSender) return auth;
702
+ const mc: AuthMailerConfig = createAuthMailerConfig({
703
+ mailSender,
704
+ hmacSecret,
705
+ baseUrl: auth.mail.baseUrl,
706
+ paths: makeAuthPaths(auth.mail.paths),
707
+ ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
708
+ ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
709
+ ...(auth.mail.emailVerificationMode !== undefined && {
710
+ emailVerificationMode: auth.mail.emailVerificationMode,
711
+ }),
712
+ });
713
+ return {
714
+ ...auth,
715
+ passwordReset: auth.passwordReset ?? mc.passwordReset,
716
+ emailVerification: auth.emailVerification ?? mc.emailVerification,
717
+ signup: auth.signup ?? mc.signup,
718
+ invite: auth.invite ?? mc.invite,
719
+ };
720
+ }
721
+
550
722
  export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
551
723
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
552
724
  // - operators can introspect env-requirements without a real boot
@@ -618,14 +790,21 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
618
790
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
619
791
  console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
620
792
 
793
+ // auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
794
+ // die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
795
+ // unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
796
+ const effectiveAuth = options.auth
797
+ ? resolveAuthMail(options.auth, jwtSecret, envSource)
798
+ : undefined;
799
+
621
800
  // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
622
801
  // password via composeFeatures — same source-of-truth as runDevApp
623
802
  // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
624
803
  // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
625
804
  // can validate wiring and exit without opening a Postgres/Redis socket.
626
- const composeAuthOptions = buildComposeAuthOptions(options.auth);
805
+ const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
627
806
  const features = composeFeatures(options.features, {
628
- includeBundled: !!options.auth,
807
+ includeBundled: !!effectiveAuth,
629
808
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
630
809
  });
631
810
 
@@ -719,15 +898,19 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
719
898
  typeof options.extraContext === "function"
720
899
  ? options.extraContext(deps)
721
900
  : (options.extraContext ?? {});
901
+
902
+ // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
903
+ // gewinnen immer (z.B. money-horse's eigener configResolver).
904
+ const autoExtraContext = buildBootExtraContext({
905
+ db,
906
+ features,
907
+ envSource,
908
+ registry,
909
+ hasAuth: !!effectiveAuth,
910
+ ...(options.masterKey && { masterKey: options.masterKey }),
911
+ });
722
912
  const extraContext = addConfigAccessorFactory(
723
- options.auth
724
- ? {
725
- configResolver: createConfigResolver({
726
- appOverrides: buildEnvConfigOverrides(registry, envSource),
727
- }),
728
- ...resolvedExtraContext,
729
- }
730
- : resolvedExtraContext,
913
+ { ...autoExtraContext, ...resolvedExtraContext },
731
914
  registry,
732
915
  );
733
916
  const resolvedAnonymousAccess =
@@ -743,8 +926,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
743
926
  // AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
744
927
  // die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
745
928
  // auth-routes.
746
- const sessionAuthFragment = options.auth?.sessions
747
- ? buildProdSessionAuth(db, options.auth.sessions)
929
+ // Secure-by-default: if the sessions feature is mounted, server-side revocation +
930
+ // sessionStrictMode are wired automatically; `auth.sessions` only overrides the config,
931
+ // and `auth.sessions: false` is the explicit opt-out (back to stateless JWTs).
932
+ const sessionsFeatureMounted = features.some((f) => f.name === SESSIONS_FEATURE);
933
+ const sessionAuthFragment = shouldWireProdSessions(
934
+ Boolean(effectiveAuth),
935
+ sessionsFeatureMounted,
936
+ effectiveAuth?.sessions,
937
+ )
938
+ ? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
748
939
  : undefined;
749
940
 
750
941
  const baseEntrypointOptions = {
@@ -765,56 +956,56 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
765
956
  ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
766
957
  },
767
958
  eventDedup,
768
- ...(options.auth && {
959
+ ...(effectiveAuth && {
769
960
  auth: {
770
961
  membershipQuery: TenantQueries.memberships,
771
962
  userQuery: UserQueries.findForAuth,
772
963
  loginHandler: AuthHandlers.login,
773
- loginErrorStatusMap: options.auth.loginErrorStatusMap ?? {
964
+ loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
774
965
  [AuthErrors.invalidCredentials]: 401,
775
966
  [AuthErrors.noMembership]: 403,
776
967
  },
777
- ...(options.auth.cookieDomain !== undefined && {
778
- cookieDomain: options.auth.cookieDomain,
968
+ ...(effectiveAuth.cookieDomain !== undefined && {
969
+ cookieDomain: effectiveAuth.cookieDomain,
779
970
  }),
780
- ...(options.auth.allowedOrigins !== undefined && {
781
- allowedOrigins: options.auth.allowedOrigins,
971
+ ...(effectiveAuth.allowedOrigins !== undefined && {
972
+ allowedOrigins: effectiveAuth.allowedOrigins,
782
973
  }),
783
- ...(options.auth.unsafeSkipOriginCheck !== undefined && {
784
- unsafeSkipOriginCheck: options.auth.unsafeSkipOriginCheck,
974
+ ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
975
+ unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
785
976
  }),
786
977
  ...sessionAuthFragment,
787
- ...(options.auth.passwordReset && {
978
+ ...(effectiveAuth.passwordReset && {
788
979
  passwordReset: {
789
980
  requestHandler: AuthHandlers.requestPasswordReset,
790
981
  confirmHandler: AuthHandlers.resetPassword,
791
- sendResetEmail: options.auth.passwordReset.sendResetEmail,
792
- appResetUrl: options.auth.passwordReset.appResetUrl,
982
+ sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
983
+ appResetUrl: effectiveAuth.passwordReset.appResetUrl,
793
984
  },
794
985
  }),
795
- ...(options.auth.emailVerification && {
986
+ ...(effectiveAuth.emailVerification && {
796
987
  emailVerification: {
797
988
  requestHandler: AuthHandlers.requestEmailVerification,
798
989
  confirmHandler: AuthHandlers.verifyEmail,
799
- sendVerificationEmail: options.auth.emailVerification.sendVerificationEmail,
800
- appVerifyUrl: options.auth.emailVerification.appVerifyUrl,
990
+ sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
991
+ appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
801
992
  },
802
993
  }),
803
- ...(options.auth.signup && {
994
+ ...(effectiveAuth.signup && {
804
995
  signup: {
805
996
  requestHandler: AuthHandlers.signupRequest,
806
997
  confirmHandler: AuthHandlers.signupConfirm,
807
- sendActivationEmail: options.auth.signup.sendActivationEmail,
808
- appActivationUrl: options.auth.signup.appActivationUrl,
998
+ sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
999
+ appActivationUrl: effectiveAuth.signup.appActivationUrl,
809
1000
  },
810
1001
  }),
811
- ...(options.auth.invite && {
1002
+ ...(effectiveAuth.invite && {
812
1003
  invite: {
813
1004
  acceptHandler: AuthHandlers.inviteAccept,
814
1005
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
815
1006
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
816
- sendInviteEmail: options.auth.invite.sendInviteEmail,
817
- appAcceptUrl: options.auth.invite.appAcceptUrl,
1007
+ sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
1008
+ appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
818
1009
  },
819
1010
  }),
820
1011
  },
@@ -880,8 +1071,8 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
880
1071
  // "first boot" via flag, every seed-step checks its own
881
1072
  // preconditions. Config-seeds rely on a deterministic
882
1073
  // aggregate-id so re-boot becomes a version_conflict skip.
883
- if (options.auth) {
884
- await seedAdmin(db, options.auth.admin);
1074
+ if (effectiveAuth) {
1075
+ await seedAdmin(db, effectiveAuth.admin);
885
1076
  }
886
1077
  await applyBootSeeds({ registry, db });
887
1078
  for (const seed of options.seeds ?? []) {
@@ -1270,7 +1461,7 @@ export function staticCachePolicy(pathname: string): CachePolicy {
1270
1461
 
1271
1462
  function buildProdSessionAuth(
1272
1463
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
1273
- opts: NonNullable<RunProdAppAuthOptions["sessions"]>,
1464
+ opts: ProdSessionsConfig,
1274
1465
  ): {
1275
1466
  readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
1276
1467
  readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * runProdApp session-wiring decision (extracted pure so it is testable without a
3
+ * full prod boot).
4
+ *
5
+ * Secure-by-default: mounting the `sessions` feature turns server-side session
6
+ * revocation + sessionStrictMode ON automatically — there is no separate opt-in. The
7
+ * `auth.sessions` option only overrides the config, and `auth.sessions: false` is the
8
+ * explicit opt-out (back to stateless JWTs).
9
+ */
10
+
11
+ export type ProdSessionsConfig = { readonly expiresInMs?: number };
12
+
13
+ /** Config object to override defaults, or `false` to disable session wiring entirely. */
14
+ export type ProdSessionsOption = ProdSessionsConfig | false;
15
+
16
+ export function shouldWireProdSessions(
17
+ hasAuth: boolean,
18
+ sessionsFeatureMounted: boolean,
19
+ sessionsOption: ProdSessionsOption | undefined,
20
+ ): boolean {
21
+ return hasAuth && sessionsFeatureMounted && sessionsOption !== false;
22
+ }
23
+
24
+ /** The config passed to buildProdSessionAuth — `false`/absent collapse to defaults. */
25
+ export function resolveProdSessionsConfig(
26
+ sessionsOption: ProdSessionsOption | undefined,
27
+ ): ProdSessionsConfig {
28
+ return sessionsOption || {};
29
+ }