@cosmicdrift/kumiko-server-runtime 0.387.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 (38) hide show
  1. package/dist/boot/boot-crypto.js +3 -3
  2. package/dist/boot/shared-boot-steps.d.ts +43 -0
  3. package/dist/boot/shared-boot-steps.js +154 -0
  4. package/dist/boot/wait-for-database.d.ts +3 -0
  5. package/dist/boot/wait-for-database.js +3 -3
  6. package/dist/boot/worker-boot-core.js +18 -38
  7. package/dist/boot-options/billing-option.d.ts +18 -9
  8. package/dist/boot-options/billing-option.js +44 -18
  9. package/dist/boot-options/file-storage-option.js +6 -6
  10. package/dist/boot-options/option-env-schema.js +11 -2
  11. package/dist/boot-options/resolve-boot-options.js +9 -3
  12. package/dist/boot-options/stack-boot-requirements.js +1 -1
  13. package/dist/csp-hashes.js +1 -1
  14. package/dist/fatal-error-handlers.js +7 -0
  15. package/dist/require-env.d.ts +2 -0
  16. package/dist/require-env.js +21 -0
  17. package/dist/run-bootstrap.d.ts +1 -1
  18. package/dist/run-bootstrap.js +1 -1
  19. package/dist/run-prod-app-auth.d.ts +17 -0
  20. package/dist/run-prod-app-auth.js +83 -0
  21. package/dist/run-prod-app-boot-context.js +7 -3
  22. package/dist/run-prod-app-config.d.ts +36 -0
  23. package/dist/run-prod-app-config.js +169 -0
  24. package/dist/run-prod-app-connections.d.ts +16 -0
  25. package/dist/run-prod-app-connections.js +55 -0
  26. package/dist/run-prod-app-entrypoint.d.ts +14 -0
  27. package/dist/run-prod-app-entrypoint.js +147 -0
  28. package/dist/run-prod-app-handle.d.ts +20 -0
  29. package/dist/run-prod-app-handle.js +104 -0
  30. package/dist/run-prod-app-request-context.d.ts +15 -0
  31. package/dist/run-prod-app-request-context.js +103 -0
  32. package/dist/run-prod-app-seeds.d.ts +13 -0
  33. package/dist/run-prod-app-seeds.js +86 -0
  34. package/dist/run-prod-app.d.ts +20 -18
  35. package/dist/run-prod-app.js +46 -726
  36. package/dist/run-worker-app.d.ts +1 -1
  37. package/package.json +15 -7
  38. package/src/changes.json +25 -0
@@ -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,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
+ }
@@ -133,9 +133,13 @@ function createBootConfigResolver(registry, envSource, crypto, appOverrides) {
133
133
  // layouts), so apps without auth still get a resolver for it; the ctx entry
134
134
  // stays auth-only.
135
135
  function resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto) {
136
- return opts.hasAuth || hasDeliveryFeature
137
- ? createBootConfigResolver(opts.registry, opts.envSource, crypto, opts.appOverrides)
138
- : 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;
139
143
  }
140
144
  function bootDeliveryContext(opts, parts) {
141
145
  if (!opts.features.some((f) => f.name === DELIVERY_FEATURE))
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ import type { EffectiveFeaturesResolver } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type AllInOneEntrypoint, type ApiEntrypoint, type ApiEntrypointOptions } from "@cosmicdrift/kumiko-framework/entrypoint";
3
+ import type { RunProdAppOptions } from "./run-prod-app.ts";
4
+ import { type ProdAuthWiring } from "./run-prod-app-auth.ts";
5
+ import type { ProdBootConfig, ProdComposedFeatures } from "./run-prod-app-config.ts";
6
+ import type { ProdConnections } from "./run-prod-app-connections.ts";
7
+ import type { ProdRequestContext } from "./run-prod-app-request-context.ts";
8
+ export type ProdEntrypoint = ApiEntrypoint | AllInOneEntrypoint;
9
+ export declare function buildProdEntrypointOptions(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, requestContext: ProdRequestContext, authWiring: ProdAuthWiring, effectiveFeatures: EffectiveFeaturesResolver | undefined): ApiEntrypointOptions;
10
+ export type ProdEntrypointOptions = ReturnType<typeof buildProdEntrypointOptions>;
11
+ export declare function createProdEntrypoint(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, entrypointOptions: ProdEntrypointOptions): ProdEntrypoint;
12
+ export declare function registerProdShutdownHooks(entrypoint: ProdEntrypoint, cacheSync: ProdConnections["cacheSync"], ownedRedisSseBroker: ProdRequestContext["ownedRedisSseBroker"]): void;
13
+ export declare function runProdWireHook(options: RunProdAppOptions, composed: ProdComposedFeatures, connections: ProdConnections, entrypoint: ProdEntrypoint): Promise<void>;
14
+ export declare function startProdBackgroundWork(entrypoint: ProdEntrypoint, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections): Promise<void>;
@@ -0,0 +1,147 @@
1
+ import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
2
+ import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observability";
3
+ import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
4
+ import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
5
+ import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
6
+ import { makeDispatchSystemQuery, makeDispatchSystemWrite } from "./extra-routes-deps.js";
7
+ import { buildProdAuthRoutes } from "./run-prod-app-auth.js";
8
+ function collectProdExtraRoutes(options, config, composed) {
9
+ const bootExtraRoutes = config.bootOptionWiring.extraRoutes;
10
+ if (!options.extraRoutes && !options.unsubscribe && bootExtraRoutes.length === 0) {
11
+ return undefined;
12
+ }
13
+ return [...(options.extraRoutes ?? []), ...composed.unsubscribeRoutes, ...bootExtraRoutes];
14
+ }
15
+ export function buildProdEntrypointOptions(options, config, composed, connections, requestContext, authWiring, effectiveFeatures) {
16
+ const { registry, effectiveAuth } = composed;
17
+ const { db, redis, outsideTransactionDb, cacheSync } = connections;
18
+ const { jwtIssuer, instanceId, trustedProxyHops, resolvedRateLimit } = config;
19
+ const extraRoutes = collectProdExtraRoutes(options, config, composed);
20
+ return {
21
+ registry,
22
+ context: {
23
+ db,
24
+ redis,
25
+ entityCache: requestContext.entityCache,
26
+ registry,
27
+ ...requestContext.extraContext,
28
+ },
29
+ sseBroker: requestContext.sseBroker,
30
+ jwtSecret: config.jwtSecretOrKeyring,
31
+ ...(jwtIssuer && { jwtIssuer }),
32
+ ...(instanceId && { instanceId }),
33
+ dispatcherOptions: {
34
+ idempotency: requestContext.idempotency,
35
+ cacheSync,
36
+ outsideTransactionDb,
37
+ ...(effectiveFeatures && { effectiveFeatures }),
38
+ },
39
+ eventDedup: requestContext.eventDedup,
40
+ ...(options.observability && { observability: options.observability }),
41
+ ...(options.metrics && { metrics: options.metrics }),
42
+ ...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
43
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
44
+ ...(options.errorDocs && { errorDocs: options.errorDocs }),
45
+ ...(extraRoutes && { extraRoutes }),
46
+ ...(effectiveAuth && {
47
+ auth: buildProdAuthRoutes(effectiveAuth, config, composed, connections, authWiring),
48
+ }),
49
+ ...(requestContext.resolvedAnonymousAccess && {
50
+ anonymousAccess: requestContext.resolvedAnonymousAccess,
51
+ }),
52
+ };
53
+ }
54
+ function prodJobOptions(options, composed, connections) {
55
+ const queueNamePrefix = options.jobs?.queueNamePrefix;
56
+ return {
57
+ ...jobRunLoggerCallbacks(composed.registry, connections.db),
58
+ ...(queueNamePrefix !== undefined && { queueNamePrefix }),
59
+ ...(options.jobs?.getActiveTenantIds !== undefined && {
60
+ getActiveTenantIds: options.jobs.getActiveTenantIds,
61
+ }),
62
+ };
63
+ }
64
+ // Deployment topology. By default, createAllInOneEntrypoint runs HTTP, both
65
+ // job lanes (two runners, each scheduling only its own lane's crons, so
66
+ // nothing fires twice) and the event dispatcher inline. That way worker-lane
67
+ // crons (run-export-jobs defaults to runIn:"worker") and MSPs run without a
68
+ // separate worker process, the gap the data export used to stall on.
69
+ // runSingleInstance:false means API-only: a dedicated worker must then take
70
+ // over the worker lane and MSPs, while API-lane jobs keep running locally.
71
+ // The legacy eventDispatcher.disabled maps to runSingleInstance:false so
72
+ // existing callers keep working; the explicit flag takes precedence.
73
+ export function createProdEntrypoint(options, config, composed, connections, entrypointOptions) {
74
+ const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
75
+ const hasJobs = composed.registry.getAllJobs().size > 0;
76
+ const jobOptions = prodJobOptions(options, composed, connections);
77
+ const dispatcherTunables = options.eventDispatcher?.pollIntervalMs !== undefined
78
+ ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
79
+ : {};
80
+ return runSingleInstance
81
+ ? createAllInOneEntrypoint({
82
+ ...entrypointOptions,
83
+ redisUrl: config.redisUrl,
84
+ ...jobOptions,
85
+ ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
86
+ })
87
+ : createApiEntrypoint({
88
+ ...entrypointOptions,
89
+ ...(hasJobs && {
90
+ jobs: {
91
+ redisUrl: config.redisUrl,
92
+ runLocalJobs: true,
93
+ ...jobOptions,
94
+ },
95
+ }),
96
+ });
97
+ }
98
+ // The broker built above is always ours (runProdApp never accepts a
99
+ // caller-supplied sseBroker), so — same reasoning as buildServer's own
100
+ // ownedRedisSseBroker hook — closing it on shutdown is our job, not the
101
+ // entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
102
+ // (which already registered the eventDispatcher/jobRunner hooks above),
103
+ // so it drains LIFO before those, matching the close-before-teardown
104
+ // order established for buildServer's own hook.
105
+ export function registerProdShutdownHooks(entrypoint, cacheSync, ownedRedisSseBroker) {
106
+ entrypoint.lifecycle.registerShutdownHook("prodAppCacheSync", async () => {
107
+ await cacheSync.close();
108
+ });
109
+ if (ownedRedisSseBroker) {
110
+ const broker = ownedRedisSseBroker;
111
+ entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
112
+ await broker.close();
113
+ });
114
+ }
115
+ }
116
+ // `extraRoutes` itself is already mounted — buildServer wires it inside
117
+ // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
118
+ // runs. `wire` is the remaining escape-hatch for app-wired co-running
119
+ // components that need the system-write dispatcher but don't declare a
120
+ // route.
121
+ export async function runProdWireHook(options, composed, connections, entrypoint) {
122
+ const { wire } = options;
123
+ if (wire) {
124
+ const { bootCrypto } = composed;
125
+ await wire({
126
+ db: connections.db,
127
+ redis: connections.redis,
128
+ registry: composed.registry,
129
+ ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
130
+ ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
131
+ metricsFor: createMetricsForFactory(entrypoint.observability.meter),
132
+ dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
133
+ dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
134
+ });
135
+ }
136
+ }
137
+ export async function startProdBackgroundWork(entrypoint, config, composed, connections) {
138
+ const { envSource } = config;
139
+ await entrypoint.start();
140
+ await recordAppStartedOnBoot({ db: connections.db, envSource });
141
+ startPiiEventBackfillOnBoot({
142
+ db: connections.db,
143
+ registry: composed.registry,
144
+ lifecycle: entrypoint.lifecycle,
145
+ envSource,
146
+ });
147
+ }
@@ -0,0 +1,20 @@
1
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import type { ProdAppHandle, RunProdAppOptions } from "./run-prod-app.ts";
3
+ import type { ProdBootConfig, ProdComposedFeatures } from "./run-prod-app-config.ts";
4
+ import type { ProdConnections } from "./run-prod-app-connections.ts";
5
+ import type { ProdEntrypoint } from "./run-prod-app-entrypoint.ts";
6
+ export type ProdFetchHandler = {
7
+ readonly fetchHandler: ProdAppHandle["fetch"];
8
+ readonly cspScriptHashes: readonly string[];
9
+ };
10
+ export declare function buildProdFetchHandler(options: RunProdAppOptions, config: ProdBootConfig, entrypoint: ProdEntrypoint): Promise<ProdFetchHandler>;
11
+ export type ProdAppHandleInput = {
12
+ readonly options: RunProdAppOptions;
13
+ readonly config: ProdBootConfig;
14
+ readonly composed: ProdComposedFeatures;
15
+ readonly connections: ProdConnections;
16
+ readonly entrypoint: ProdEntrypoint;
17
+ readonly fetch: ProdFetchHandler;
18
+ readonly log: Logger;
19
+ };
20
+ export declare function createProdAppHandle(input: ProdAppHandleInput): ProdAppHandle;