@cosmicdrift/kumiko-server-runtime 0.386.0 → 0.388.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.
Files changed (53) hide show
  1. package/LICENSE +21 -57
  2. package/dist/boot/boot-crypto.js +4 -5
  3. package/dist/boot/shared-boot-steps.d.ts +43 -0
  4. package/dist/boot/shared-boot-steps.js +154 -0
  5. package/dist/boot/wait-for-database.d.ts +3 -0
  6. package/dist/boot/wait-for-database.js +3 -3
  7. package/dist/boot/worker-boot-core.js +20 -40
  8. package/dist/boot-options/billing-option.d.ts +18 -9
  9. package/dist/boot-options/billing-option.js +44 -18
  10. package/dist/boot-options/file-storage-option.js +6 -6
  11. package/dist/boot-options/option-env-schema.js +11 -2
  12. package/dist/boot-options/resolve-boot-options.js +9 -3
  13. package/dist/boot-options/stack-boot-requirements.js +1 -1
  14. package/dist/build-prod-bundle.d.ts +16 -15
  15. package/dist/build-prod-bundle.js +106 -108
  16. package/dist/csp-hashes.js +1 -1
  17. package/dist/extra-routes-deps.d.ts +1 -1
  18. package/dist/fatal-error-handlers.js +7 -0
  19. package/dist/index.js +4 -4
  20. package/dist/pii-boot-gate.js +1 -1
  21. package/dist/require-env.d.ts +2 -0
  22. package/dist/require-env.js +21 -0
  23. package/dist/resolve-tailwind-cli.js +5 -7
  24. package/dist/run-bootstrap.d.ts +1 -1
  25. package/dist/run-bootstrap.js +1 -1
  26. package/dist/run-prod-app-auth.d.ts +17 -0
  27. package/dist/run-prod-app-auth.js +83 -0
  28. package/dist/run-prod-app-boot-context.d.ts +2 -2
  29. package/dist/run-prod-app-boot-context.js +28 -25
  30. package/dist/run-prod-app-config.d.ts +36 -0
  31. package/dist/run-prod-app-config.js +169 -0
  32. package/dist/run-prod-app-connections.d.ts +16 -0
  33. package/dist/run-prod-app-connections.js +55 -0
  34. package/dist/run-prod-app-entrypoint.d.ts +14 -0
  35. package/dist/run-prod-app-entrypoint.js +147 -0
  36. package/dist/run-prod-app-handle.d.ts +20 -0
  37. package/dist/run-prod-app-handle.js +104 -0
  38. package/dist/run-prod-app-request-context.d.ts +15 -0
  39. package/dist/run-prod-app-request-context.js +103 -0
  40. package/dist/run-prod-app-seeds.d.ts +13 -0
  41. package/dist/run-prod-app-seeds.js +86 -0
  42. package/dist/run-prod-app-static-files.js +43 -50
  43. package/dist/run-prod-app.d.ts +93 -103
  44. package/dist/run-prod-app.js +75 -772
  45. package/dist/run-worker-app.d.ts +1 -1
  46. package/dist/run-worker-app.js +5 -5
  47. package/dist/session-boot-gate.d.ts +1 -1
  48. package/dist/session-boot-gate.js +1 -1
  49. package/dist/session-wiring.d.ts +1 -1
  50. package/dist/session-wiring.js +1 -1
  51. package/dist/try-hono-first.js +1 -1
  52. package/package.json +16 -8
  53. package/src/changes.json +25 -0
@@ -16,6 +16,7 @@ export function installFatalErrorHandlers(options) {
16
16
  const exit = options.exit ?? ((code) => process.exit(code));
17
17
  const hardExitMs = options.hardExitMs ?? DEFAULT_HARD_EXIT_MS;
18
18
  let handling = false;
19
+ let stopRunning = false;
19
20
  const handle = (source) => (reason) => {
20
21
  // skip: a second fault during shutdown is already covered by the running stop and hard-exit timer
21
22
  if (handling)
@@ -28,11 +29,13 @@ export function installFatalErrorHandlers(options) {
28
29
  });
29
30
  const hardExitTimer = setTimeout(() => exit(1), hardExitMs);
30
31
  hardExitTimer.unref();
32
+ stopRunning = true;
31
33
  void stop()
32
34
  .catch((stopError) => {
33
35
  logger.error("shutdown after fatal error failed", describeFatal(stopError));
34
36
  })
35
37
  .finally(() => {
38
+ stopRunning = false;
36
39
  clearTimeout(hardExitTimer);
37
40
  exit(1);
38
41
  });
@@ -42,6 +45,10 @@ export function installFatalErrorHandlers(options) {
42
45
  process.on("uncaughtException", onUncaughtException);
43
46
  process.on("unhandledRejection", onUnhandledRejection);
44
47
  return () => {
48
+ // stop() uninstalls first; during a fatal shutdown the listeners must stay so a second fault cannot hit the default crash path mid-flush
49
+ // skip: fatal shutdown in progress, see above.
50
+ if (stopRunning)
51
+ return;
45
52
  process.off("uncaughtException", onUncaughtException);
46
53
  process.off("unhandledRejection", onUnhandledRejection);
47
54
  };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- // Public API für den Kumiko-Production-Server-Boot. Symmetrisch zu
2
- // runDevApp (kumiko-dev-server), aber ohne Dev-/Scaffold-/Codegen-
3
- // Tooling (ts-morph) als Dependency — Prod-Apps ziehen so kein
4
- // Dev-Tooling mehr in ihre node_modules.
1
+ // Public API for Kumiko's production-server boot. Symmetric with
2
+ // runDevApp (kumiko-dev-server), but without Dev/Scaffold/Codegen
3
+ // tooling (ts-morph) as a dependency, so production apps do not pull
4
+ // development tooling into their node_modules.
5
5
  export { resolveBillingOption } from "./boot-options/billing-option.js";
6
6
  export { buildDemoModeRoute, withDemoReadOnlyFetch, } from "./boot-options/demo-mode.js";
7
7
  export { resolveFeatureTogglesOption, } from "./boot-options/feature-toggles-option.js";
@@ -13,7 +13,7 @@ export function assertPiiBootInvariants(features, opts) {
13
13
  const piiEntities = features.flatMap((feature) => Object.entries(feature.entities ?? {})
14
14
  .filter(([, entity]) => collectPiiSubjectFields(entity).length > 0)
15
15
  .map(([name]) => name));
16
- // fw#2776: a declared event stance is worth as much as an entity annotation —
16
+ // A declared event stance is worth as much as an entity annotation —
17
17
  // encryptEventPayloadPii no-ops without a KMS, so a catalogued event writes
18
18
  // plaintext PII into kumiko_events with nothing to crypto-shred later.
19
19
  const piiEvents = features.flatMap((feature) => Object.values(feature.events ?? {})
@@ -0,0 +1,2 @@
1
+ export declare function requireEnv(name: string, src?: Record<string, string | undefined>, context?: string): string;
2
+ export declare function readEnv(name: string, src?: Record<string, string | undefined>): string | undefined;
@@ -0,0 +1,21 @@
1
+ // Strict env-var read. Throws with a clear hint when missing — better
2
+ // than discovering a Postgres-connection-refused 30s into the boot.
3
+ // `src` defaults to process.env but is threaded from the caller's envSource
4
+ // so the boot-path reads the SAME env-quelle that was validated above —
5
+ // injected dummies in test-mode must not silently fall back to process.env.
6
+ export function requireEnv(name, src = process.env, context = "runProdApp") {
7
+ const value = src[name];
8
+ if (value === undefined || value === "") {
9
+ const advice = context === "runProdApp"
10
+ ? "Set it in your container env / .env.production / Coolify secrets."
11
+ : "Set it in your .env / shell before running the dev server.";
12
+ throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
13
+ }
14
+ return value;
15
+ }
16
+ // Optional env helper — returns undefined for missing, string for set.
17
+ // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
18
+ export function readEnv(name, src = process.env) {
19
+ const value = src[name];
20
+ return value === undefined || value === "" ? undefined : value;
21
+ }
@@ -1,11 +1,9 @@
1
- // Resolved den lokal installierten @tailwindcss/cli-Bin auf seinen
2
- // absoluten Dateipfad. Wir vermeiden `bunx`, weil das auch bei lokal
3
- // installiertem Package noch das Registry-Manifest fragt und ohne Netz
4
- // mit `FailedToOpenSocket` stirbt.
1
+ // Resolves the locally installed @tailwindcss/cli binary to its absolute path.
2
+ // We avoid `bunx` because it still requests the registry manifest for a locally
3
+ // installed package and fails with `FailedToOpenSocket` when offline.
5
4
  //
6
- // Ausgelagert aus create-kumiko-server.ts, damit unter vitest/Node
7
- // testbar (Bun-Branch + Resolve-Fail-Branch) ohne den Server zu
8
- // booten.
5
+ // Kept separate from create-kumiko-server.ts so the Bun and resolution-failure
6
+ // branches can be tested under Vitest/Node without booting the server.
9
7
  import { dirname, resolve } from "node:path";
10
8
  export function resolveTailwindCli(deps) {
11
9
  if (deps.bun === undefined)
@@ -1,5 +1,5 @@
1
1
  import { type BootstrapPlan, type BootstrapReport } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
- import { type RunProdAppAuthOptions } from "./run-prod-app.ts";
2
+ import type { RunProdAppAuthOptions } from "./run-prod-app.ts";
3
3
  import type { RunWorkerAppOptions } from "./run-worker-app.ts";
4
4
  export type RunBootstrapOptions = Omit<RunWorkerAppOptions, "wireComponents" | "includeBundled" | "jobs" | "eventDispatcher" | "metrics"> & BootstrapPlan & {
5
5
  /** The same auth block the app passes to runProdApp (`admin` is ignored).
@@ -12,7 +12,7 @@
12
12
  import { bootstrapTenants, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
13
13
  import { bootWorkerProcess, resolveWorkerEnvSource } from "./boot/worker-boot-core.js";
14
14
  import { buildComposeAuthOptions } from "./compose-features.js";
15
- import { requireEnv } from "./run-prod-app.js";
15
+ import { requireEnv } from "./require-env.js";
16
16
  import { resolveAuthMail } from "./run-prod-app-boot-context.js";
17
17
  const PROCESS_NAME = "runBootstrap";
18
18
  function maskEmailForLog(email) {
@@ -0,0 +1,17 @@
1
+ import { type AuthRoutesConfig, type LoginRateLimiter, type TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { buildProdSessionAuth } from "./run-prod-app-boot-context.ts";
4
+ import type { ProdBootConfig, ProdComposedFeatures } from "./run-prod-app-config.ts";
5
+ import type { ProdConnections } from "./run-prod-app-connections.ts";
6
+ type PatAuthFragment = {
7
+ readonly tokenVerifier: TokenVerifier;
8
+ readonly patRateLimiter: LoginRateLimiter;
9
+ };
10
+ export type ProdAuthWiring = {
11
+ readonly mfaFeature: FeatureDefinition | undefined;
12
+ readonly sessionAuthFragment: Awaited<ReturnType<typeof buildProdSessionAuth>> | undefined;
13
+ readonly patAuthFragment: PatAuthFragment | undefined;
14
+ };
15
+ export declare function wireProdAuth(config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections): Promise<ProdAuthWiring>;
16
+ export declare function buildProdAuthRoutes(effectiveAuth: NonNullable<ProdComposedFeatures["effectiveAuth"]>, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, authWiring: ProdAuthWiring): AuthRoutesConfig;
17
+ export {};
@@ -0,0 +1,83 @@
1
+ import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
2
+ import { AUTH_MFA_FEATURE, bindMfaTokenSecretsFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
3
+ import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
4
+ import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
5
+ import { createRedisLoginRateLimiter, } from "@cosmicdrift/kumiko-framework/api";
6
+ import { assertMfaSwitchTenantGate, buildSharedAuthRoutes } from "./boot/shared-boot-steps.js";
7
+ import { buildProdSessionAuth, wireProdPatAutoRevoke } from "./run-prod-app-boot-context.js";
8
+ import { shouldWireProdSessions } from "./session-wiring.js";
9
+ // Sessions are wired directly because db has already been created; unlike
10
+ // runDevApp, no late binding is needed. Once a sessionChecker is wired, JWTs
11
+ // without sid are rejected, so production sessions cannot be bypassed.
12
+ // sessionMassRevoker (the fourth callback from createSessionCallbacks) is not
13
+ // part of AuthRoutesConfig; bindAutoRevokeFromFeature hands it to the sessions
14
+ // feature so password change and reset revoke all sessions. Secure by default: the sessionStore provider
15
+ // goes through resolveSessionStore.
16
+ export async function wireProdAuth(config, composed, connections) {
17
+ const { features, registry, effectiveAuth, sessionsFeature } = composed;
18
+ const { db, redis } = connections;
19
+ const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
20
+ if (mfaFeature) {
21
+ bindMfaTokenSecretsFromFeature(mfaFeature)?.(config.jwtSecret, config.envSource);
22
+ assertMfaSwitchTenantGate(registry, "runProdApp");
23
+ }
24
+ // Hoisted above buildProdSessionAuth: that call also wires MFA-enable/
25
+ // disable → PAT-revoke (bindRevokeAllPatTokensFromFeature), so it needs
26
+ // patFeature too.
27
+ const patFeature = features.find((f) => f.name === PAT_FEATURE);
28
+ const sessionAuthFragment = shouldWireProdSessions(Boolean(effectiveAuth), registry.getExtensionUsages(EXT_SESSION_STORE).length > 0)
29
+ ? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature)
30
+ : undefined;
31
+ // Password-change → PAT-revoke: independent of sessions/MFA being mounted
32
+ // (buildProdSessionAuth only runs when a sessionStore provider is
33
+ // registered), so wired unconditionally here whenever PAT itself is.
34
+ if (patFeature) {
35
+ wireProdPatAutoRevoke(db, patFeature);
36
+ }
37
+ // Token-verifier opt-in: any provider feature (personal-access-tokens, a
38
+ // future auth-provider-jwt, ...) self-registers via
39
+ // r.useExtension(EXT_TOKEN_VERIFIER, ...) — wire one generic resolver
40
+ // whenever at least one is mounted, resolved by shape at request-time.
41
+ // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
42
+ // verification.
43
+ const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
44
+ let patAuthFragment;
45
+ if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
46
+ const rl = patRateLimitFromFeature(patFeature);
47
+ patAuthFragment = {
48
+ tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
49
+ patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
50
+ };
51
+ }
52
+ return { mfaFeature, sessionAuthFragment, patAuthFragment };
53
+ }
54
+ function buildMfaRateLimits(redis) {
55
+ return {
56
+ mfaVerifyRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-verify"),
57
+ mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-start"),
58
+ mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-confirm"),
59
+ };
60
+ }
61
+ export function buildProdAuthRoutes(effectiveAuth, config, composed, connections, authWiring) {
62
+ const { redis } = connections;
63
+ const { authPublicOrigin } = composed;
64
+ const { trustedProxyHops } = config;
65
+ return {
66
+ ...buildSharedAuthRoutes(effectiveAuth, Boolean(authWiring.mfaFeature)),
67
+ userQuery: UserQueries.findForAuth,
68
+ // Redis-backed, not the in-memory default createAuthRoutes falls
69
+ // back to — an in-process limiter only rate-limits within a single
70
+ // replica, so a multi-instance prod deployment would silently give
71
+ // each replica its own bucket. Redis is required infra
72
+ // here already (REDIS_URL), so this is free.
73
+ loginRateLimit: createRedisLoginRateLimiter(redis),
74
+ ...(effectiveAuth.secureCookies !== undefined && {
75
+ secureCookies: effectiveAuth.secureCookies,
76
+ }),
77
+ ...(authPublicOrigin !== undefined && { publicOrigin: authPublicOrigin }),
78
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
79
+ ...authWiring.sessionAuthFragment,
80
+ ...authWiring.patAuthFragment,
81
+ ...(authWiring.mfaFeature && buildMfaRateLimits(redis)),
82
+ };
83
+ }
@@ -39,8 +39,8 @@ type BootExtraContextOptions = {
39
39
  readonly appOverrides?: ConfigOverridesInput;
40
40
  };
41
41
  export declare function buildBootExtraContext(opts: BootExtraContextOptions): Record<string, unknown>;
42
- /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
43
- * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
42
+ /** The auth fields read and normalized by resolveAuthMail; both
43
+ * app auth types (prod + dev) satisfy this structurally. */
44
44
  type AuthMailNormalizable = {
45
45
  readonly mail?: AuthMailOptions;
46
46
  readonly passwordReset?: PasswordResetSetup;
@@ -17,14 +17,14 @@ import { resolveBootCrypto } from "./boot/boot-crypto.js";
17
17
  // Boot-time context helpers for runProdApp: ctx-extra-context wiring
18
18
  // (templateResolver/delivery/secrets/config-resolver), auth-mail convenience
19
19
  // normalization, and prod session-auth wiring. Split out of run-prod-app.ts
20
- // (#1005, Welle 2) — mechanical relocation, these are self-contained pure
20
+ // — mechanical relocation, these are self-contained pure
21
21
  // functions, no closure over runProdApp's local boot state.
22
22
  // Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
23
23
  export function addConfigAccessorFactory(resolved, registry) {
24
24
  if (!resolved.configResolver)
25
25
  return resolved;
26
26
  // Both keys, always overriding an app-supplied value: the factory must derive from the
27
- // effective resolver (fw#3313); the underscore alias stays until the next major.
27
+ // effective resolver; the underscore alias stays until the next major.
28
28
  const configAccessorFactory = createConfigAccessorFactory(registry, resolved.configResolver);
29
29
  return {
30
30
  ...resolved,
@@ -32,20 +32,19 @@ export function addConfigAccessorFactory(resolved, registry) {
32
32
  _configAccessorFactory: configAccessorFactory,
33
33
  };
34
34
  }
35
- // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
36
- // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
37
- // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
38
- // Ausnahme. templateResolver ist unbedingt (createTemplateResolverApi wirft nie, baut
39
- // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
40
- // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
41
- // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
42
- // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
43
- // (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
44
- // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
45
- // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
46
- // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
47
- // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
48
- // passiert beim Caller (App gewinnt).
35
+ // Framework-default providers for AppContext, using the same mechanism as
36
+ // tenantTierResolver auto-wiring (findTierResolverUsage): a mounted feature declares
37
+ // the need, the default comes from db/env, and the app can override the exception.
38
+ // templateResolver is always wired (createTemplateResolverApi never throws; it only
39
+ // creates a db-bound accessor). secrets is auto-wired only when the secrets feature is
40
+ // mounted AND a KEK is available (masterKey override OR env KEK present); otherwise it
41
+ // is skipped so eager KEK detection and provider/cipher construction remain in
42
+ // boot/boot-crypto.ts (envHasMasterKek, resolveBootCrypto), shared with runDevApp.
43
+ // A production misconfiguration (secrets mounted without a KEK) is caught by
44
+ // secretsEnvSchema during boot; without the env-schema path, requireSecretsContext
45
+ // throws with a wiring hint on the first ctx.secrets access. configResolver is only
46
+ // wired in auth mode. Exported and pure for unit tests; the caller merges it with
47
+ // app values (the app wins).
49
48
  // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
50
49
  // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
51
50
  // and every notification silently skips. sseBroker optional (email/push don't
@@ -134,9 +133,13 @@ function createBootConfigResolver(registry, envSource, crypto, appOverrides) {
134
133
  // layouts), so apps without auth still get a resolver for it; the ctx entry
135
134
  // stays auth-only.
136
135
  function resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto) {
137
- return opts.hasAuth || hasDeliveryFeature
138
- ? createBootConfigResolver(opts.registry, opts.envSource, crypto, opts.appOverrides)
139
- : undefined;
136
+ if (opts.hasAuth || hasDeliveryFeature) {
137
+ return createBootConfigResolver(opts.registry, opts.envSource, crypto, opts.appOverrides);
138
+ }
139
+ if (opts.appOverrides) {
140
+ throw new Error("[runProdApp] appOverrides needs `auth` or a delivery feature: without one no config resolver is built and the overrides would be ignored.");
141
+ }
142
+ return undefined;
140
143
  }
141
144
  function bootDeliveryContext(opts, parts) {
142
145
  if (!opts.features.some((f) => f.name === DELIVERY_FEATURE))
@@ -219,17 +222,17 @@ export function resolveAuthMail(auth, hmacSecret, envSource, mailWiring = {}) {
219
222
  const withResolvedSecrets = backfillTokenSecrets(auth, tokenSecret);
220
223
  if (!auth.mail)
221
224
  return withResolvedSecrets;
222
- // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
223
- // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
224
- // (channel-email), nicht über diesen Transport — er ist nur der Detektor
225
- // "ist Mail konfiguriert?".
225
+ // SMTP-presence gate: without SMTP_HOST in the environment, no flow is wired
226
+ // (the routes would otherwise return 500). Mail delivery itself runs through
227
+ // delivery (channel-email), not this transport; it only detects whether mail is
228
+ // configured.
226
229
  // An app transport from runProdApp's `mail` option replaces the probe.
227
230
  if (mailWiring.mailConfigured !== true &&
228
231
  !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })) {
229
232
  return withResolvedSecrets;
230
233
  }
231
234
  const paths = makeAuthPaths(auth.mail.paths);
232
- // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
235
+ // appName/locale flow into all four flow options because they all send through delivery.
233
236
  const mailPresentation = {
234
237
  ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
235
238
  ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
@@ -266,7 +269,7 @@ export function resolveAuthMail(auth, hmacSecret, envSource, mailWiring = {}) {
266
269
  };
267
270
  }
268
271
  export async function buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature) {
269
- // Resolve the sessions feature sessionStore provider (#1372).
272
+ // Resolve the sessions feature sessionStore provider.
270
273
  const store = await resolveSessionStore({ db, registry });
271
274
  if (sessionsFeature) {
272
275
  bindAutoRevokeFromFeature(sessionsFeature)?.(store.massRevoker);
@@ -0,0 +1,36 @@
1
+ import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
2
+ import { type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { resolveBootCrypto } from "./boot/boot-crypto.ts";
5
+ import { type BootOptionWiring } from "./boot-options/resolve-boot-options.ts";
6
+ import { resolveRateLimit } from "./rate-limit-defaults.ts";
7
+ import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.ts";
8
+ import type { RunProdAppOptions } from "./run-prod-app.ts";
9
+ import { resolveAuthMail } from "./run-prod-app-boot-context.ts";
10
+ export type BootAbortCleanup = () => void | Promise<void>;
11
+ export type RegisterBootAbortCleanup = (cleanup: BootAbortCleanup) => void;
12
+ export declare function runWithBootAbortCleanups<T>(boot: (register: RegisterBootAbortCleanup) => Promise<T>): Promise<T>;
13
+ export type ProdBootConfig = {
14
+ readonly bootOptionWiring: BootOptionWiring;
15
+ readonly envSource: Record<string, string | undefined>;
16
+ readonly bootKms: BootOptionWiring["kms"];
17
+ readonly databaseUrl: string;
18
+ readonly redisUrl: string;
19
+ readonly jwtSecret: string;
20
+ readonly jwtSecretOrKeyring: ReturnType<typeof loadJwtSecretOrKeyring>;
21
+ readonly jwtIssuer: string | undefined;
22
+ readonly instanceId: string | undefined;
23
+ readonly trustedProxyHops: number | undefined;
24
+ readonly resolvedRateLimit: ReturnType<typeof resolveRateLimit>;
25
+ readonly port: number;
26
+ };
27
+ export type ProdComposedFeatures = ReturnType<typeof resolveUnsubscribeWiring> & {
28
+ readonly effectiveAuth: ReturnType<typeof resolveAuthMail<NonNullable<RunProdAppOptions["auth"]>>> | undefined;
29
+ readonly authPublicOrigin: string | undefined;
30
+ readonly features: readonly FeatureDefinition[];
31
+ readonly bootCrypto: ReturnType<typeof resolveBootCrypto>;
32
+ readonly sessionsFeature: FeatureDefinition | undefined;
33
+ readonly registry: Registry;
34
+ };
35
+ export declare function resolveProdBootConfig(options: RunProdAppOptions, decryptedEnvSource: Record<string, string | undefined>, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup): Promise<ProdBootConfig>;
36
+ export declare function composeProdFeatures(options: RunProdAppOptions, config: ProdBootConfig, log: Logger): ProdComposedFeatures;
@@ -0,0 +1,169 @@
1
+ import { EXT_SESSION_STORE } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
2
+ import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
3
+ import { loadJwtSecretOrKeyring, TRUSTED_PROXY_HOPS_ENV } from "@cosmicdrift/kumiko-framework/api";
4
+ import { createRegistry, } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
+ import { resolveBootCrypto } from "./boot/boot-crypto.js";
7
+ import { composeAppFeatures, validateComposedFeatures, withEnvDefaults, } from "./boot/shared-boot-steps.js";
8
+ import { resolveBootOptionWiring, } from "./boot-options/resolve-boot-options.js";
9
+ import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
10
+ import { assertPiiBootInvariants } from "./pii-boot-gate.js";
11
+ import { resolveRateLimit } from "./rate-limit-defaults.js";
12
+ import { readEnv, requireEnv } from "./require-env.js";
13
+ import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
14
+ import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
15
+ import { resolveAuthMail } from "./run-prod-app-boot-context.js";
16
+ import { assertSessionBootInvariants } from "./session-boot-gate.js";
17
+ import { resolveTrustedProxyHops } from "./trusted-proxy-hops.js";
18
+ export async function runWithBootAbortCleanups(boot) {
19
+ const abortCleanups = [];
20
+ try {
21
+ return await boot((cleanup) => abortCleanups.push(cleanup));
22
+ }
23
+ catch (error) {
24
+ // Pools and clients opened before the failing step would otherwise keep the process alive.
25
+ for (const cleanup of abortCleanups.reverse()) {
26
+ await Promise.resolve()
27
+ .then(cleanup)
28
+ .catch(() => undefined);
29
+ }
30
+ throw error;
31
+ }
32
+ }
33
+ let warnedLegacyJwtSecret = false;
34
+ function warnLegacyJwtSecretOnce() {
35
+ // skip: already warned once this process — avoid log spam on every boot path.
36
+ if (warnedLegacyJwtSecret)
37
+ return;
38
+ warnedLegacyJwtSecret = true;
39
+ createFallbackLogger("runProdApp").warn("JWT keyring carries a legacy (pre-rotation) verify-only key — " +
40
+ "it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
41
+ "fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
42
+ "set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
43
+ "that is no longer present in any in-flight token (see resolveAuthMail / " +
44
+ "loadJwtSecretOrKeyring).");
45
+ }
46
+ export async function resolveProdBootConfig(options, decryptedEnvSource, log, registerAbortCleanup) {
47
+ // Stacks whose boot options are missing and the declarative boot options
48
+ // (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
49
+ // the env so an explicitly set variable wins.
50
+ assertStackBootRequirements(options);
51
+ const bootOptionWiring = await resolveBootOptionWiring(options, decryptedEnvSource);
52
+ const envSource = withEnvDefaults(bootOptionWiring.envDefaults, decryptedEnvSource);
53
+ const bootKms = bootOptionWiring.kms;
54
+ registerAbortCleanup(() => bootKms.close());
55
+ // Polyfill before anything else — feature code references Temporal.
56
+ const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
57
+ await ensureTemporalPolyfill();
58
+ // Env-vars: fail-fast. Better a 0s boot crash with a clear error
59
+ // than a 30s timeout chasing a Postgres connection that was never
60
+ // configured.
61
+ const databaseUrl = requireEnv("DATABASE_URL", envSource);
62
+ const redisUrl = requireEnv("REDIS_URL", envSource);
63
+ // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
64
+ // fallback below — a separate, non-rotating HMAC token family, not the
65
+ // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
66
+ // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
67
+ // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
68
+ const jwtSecret = requireEnv("JWT_SECRET", envSource);
69
+ const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
70
+ if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
71
+ // Once per process — JWT_SECRET stays a required env (requireEnv above +
72
+ // resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
73
+ // means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
74
+ // to a fresh value that is no longer in any in-flight token; unsetting
75
+ // JWT_SECRET entirely hard-crashes boot.
76
+ warnLegacyJwtSecretOnce();
77
+ }
78
+ const jwtIssuer = readEnv("JWT_ISSUER", envSource);
79
+ const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
80
+ // options.auth.trustedProxyHops wins; falls back
81
+ // to the env var so ops can close the XFF-spoofing hole per-deployment
82
+ // without a code change (mirrors instanceId's env-first pattern above).
83
+ const trustedProxyHops = resolveTrustedProxyHops({
84
+ option: options.trustedProxyHops,
85
+ authOption: options.auth?.trustedProxyHops,
86
+ envValue: readEnv(TRUSTED_PROXY_HOPS_ENV, envSource),
87
+ nodeEnv: process.env["NODE_ENV"],
88
+ });
89
+ if (trustedProxyHops === undefined) {
90
+ // With 0 hops every request appears to come from the proxy pod, so all IP-keyed
91
+ // rate limiters collapse into one shared bucket behind a reverse proxy.
92
+ log.warn(`trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
93
+ "Behind a reverse proxy all clients share one rate-limit bucket, so the default L1/L2 limits throttle the whole site at once.");
94
+ }
95
+ const resolvedRateLimit = resolveRateLimit(options.rateLimit);
96
+ const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
97
+ log.info(`booting Kumiko stack on port ${port}`);
98
+ return {
99
+ bootOptionWiring,
100
+ envSource,
101
+ bootKms,
102
+ databaseUrl,
103
+ redisUrl,
104
+ jwtSecret,
105
+ jwtSecretOrKeyring,
106
+ jwtIssuer,
107
+ instanceId,
108
+ trustedProxyHops,
109
+ resolvedRateLimit,
110
+ port,
111
+ };
112
+ }
113
+ export function composeProdFeatures(options, config, log) {
114
+ const { bootOptionWiring, envSource, bootKms, jwtSecret } = config;
115
+ // auth.mail is expanded into the explicit flow fields before feeding both
116
+ // the feature side (buildComposeAuthOptions) and the routes fragment below.
117
+ // From here on, always read effectiveAuth instead of options.auth.
118
+ const effectiveAuth = options.auth
119
+ ? resolveAuthMail(options.auth, jwtSecret, envSource, {
120
+ mailConfigured: bootOptionWiring.mailConfigured,
121
+ })
122
+ : undefined;
123
+ const authPublicOrigin = effectiveAuth
124
+ ? resolveAuthPublicOrigin(effectiveAuth, envSource, (message) => {
125
+ log.warn(message);
126
+ })
127
+ : undefined;
128
+ const { unsubscribeLinks, unsubscribeRoutes } = resolveUnsubscribeWiring({
129
+ unsubscribe: options.unsubscribe,
130
+ publicOrigin: effectiveAuth?.publicOrigin,
131
+ mailBaseUrl: effectiveAuth?.mail?.baseUrl,
132
+ envSource,
133
+ warn: (message) => createFallbackLogger("runProdApp").warn(message),
134
+ });
135
+ // Feature registry. Auth mode automatically mixes config/user/tenant/
136
+ // auth-email-password via composeFeatures, using the same feature list as
137
+ // runDevApp and the per-app schema generator. Build it before any connection
138
+ // so boot mode can validate wiring without opening Postgres or Redis sockets.
139
+ const features = composeAppFeatures(options.features, bootOptionWiring.features, effectiveAuth);
140
+ const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
141
+ validateComposedFeatures(features, {
142
+ env: envSource,
143
+ ...(bootCrypto.entityFieldCipher && { entityFieldCipher: bootCrypto.entityFieldCipher }),
144
+ ...options.validateBootOptions,
145
+ });
146
+ assertPiiBootInvariants(features, {
147
+ kms: bootKms.kms,
148
+ blindIndexKey: bootKms.blindIndexKey,
149
+ allowPlaintextPii: bootKms.allowPlaintextPii,
150
+ mode: "prod",
151
+ });
152
+ const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
153
+ const registry = createRegistry(features);
154
+ assertSessionBootInvariants({
155
+ hasAuth: Boolean(effectiveAuth),
156
+ sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
157
+ mode: "prod",
158
+ });
159
+ return {
160
+ effectiveAuth,
161
+ authPublicOrigin,
162
+ unsubscribeLinks,
163
+ unsubscribeRoutes,
164
+ features,
165
+ bootCrypto,
166
+ sessionsFeature,
167
+ registry,
168
+ };
169
+ }
@@ -0,0 +1,16 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
3
+ import { createRedisCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
4
+ import { Redis } from "ioredis";
5
+ import type { RunProdAppOptions } from "./run-prod-app.ts";
6
+ import type { ProdBootConfig, RegisterBootAbortCleanup } from "./run-prod-app-config.ts";
7
+ export type ProdConnections = {
8
+ readonly db: DbConnection;
9
+ readonly closeDb: () => Promise<void>;
10
+ readonly outsideTransactionDb: DbConnection;
11
+ readonly closeOutsideTransactionDb: () => Promise<void>;
12
+ readonly redis: Redis;
13
+ readonly cacheSync: ReturnType<typeof createRedisCacheSyncBus>;
14
+ };
15
+ export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup): Promise<ProdConnections>;
16
+ export declare function assertProdSchemaCurrent(db: DbConnection, migrations: RunProdAppOptions["migrations"], log: Logger): Promise<void>;
@@ -0,0 +1,55 @@
1
+ import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
2
+ import { createRedisCacheSyncBus, logRedisConnectionState, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
3
+ import { Redis } from "ioredis";
4
+ import { openDatabasePools, waitForKmsHealthy } from "./boot/shared-boot-steps.js";
5
+ import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./boot/wait-for-database.js";
6
+ // Postgres + Redis. The Redis client is shared by idempotency, event-dedup,
7
+ // entity-cache, rate-limit; failing to construct here surfaces the misconfig
8
+ // immediately. `new Redis(...)` connects eagerly, so this must stay AFTER the
9
+ // boot-mode exit.
10
+ export async function connectProdDependencies(config, log, registerAbortCleanup) {
11
+ const { bootKms, databaseUrl, redisUrl, envSource } = config;
12
+ // KMS health gate: an app configured for crypto-shredding must not boot
13
+ // against an unreachable key store — a silent start would answer every
14
+ // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
15
+ const databaseWaitDeadline = Date.now() + WAIT_FOR_DATABASE_DEADLINE_MS;
16
+ if (bootKms.kms) {
17
+ await waitForKmsHealthy(bootKms.kms, "runProdApp", databaseWaitDeadline);
18
+ }
19
+ const pools = openDatabasePools(databaseUrl, envSource);
20
+ const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
21
+ registerAbortCleanup(pools.closeDb);
22
+ registerAbortCleanup(pools.closeOutsideTransactionDb);
23
+ registerAbortCleanup(() => redis.disconnect());
24
+ logRedisConnectionState(redis, log);
25
+ await waitForDatabase(pools.db, { processName: "runProdApp", deadline: databaseWaitDeadline });
26
+ // One bus per process: the tier resolver, the dispatcher and the file-provider
27
+ // resolver all subscribe to it (invalidation across the API pods).
28
+ const cacheSync = createRedisCacheSyncBus({
29
+ redisUrl,
30
+ clientOptions: redisClientOptionsFromEnv(envSource),
31
+ });
32
+ return { ...pools, redis, cacheSync };
33
+ }
34
+ // Schema-drift gate (without drizzle, using kumiko/migrations). `kumiko
35
+ // schema apply` runs as a deploy step before container rollout. Boot only
36
+ // verifies that every kumiko/migrations/*.sql file is applied in
37
+ // _kumiko_migrations with an unchanged checksum and that every table in
38
+ // kumiko/migrations/.snapshot.json exists. Drift aborts boot rather than being auto-healed, avoiding
39
+ // races between container replicas. `migrations: false` opts out for custom
40
+ // schema setups.
41
+ export async function assertProdSchemaCurrent(db, migrations, log) {
42
+ if (migrations !== false) {
43
+ const migrationsDir = migrations?.dir ?? "./kumiko/migrations";
44
+ log.info(`checking schema drift (${migrationsDir})`);
45
+ try {
46
+ await assertKumikoSchemaCurrent(db, migrationsDir);
47
+ }
48
+ catch (err) {
49
+ if (err instanceof SchemaDriftError) {
50
+ log.error(`BOOT ABORTED — ${err.message}`);
51
+ }
52
+ throw err;
53
+ }
54
+ }
55
+ }