@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.
- package/LICENSE +21 -57
- package/dist/boot/boot-crypto.js +4 -5
- package/dist/boot/shared-boot-steps.d.ts +43 -0
- package/dist/boot/shared-boot-steps.js +154 -0
- package/dist/boot/wait-for-database.d.ts +3 -0
- package/dist/boot/wait-for-database.js +3 -3
- package/dist/boot/worker-boot-core.js +20 -40
- package/dist/boot-options/billing-option.d.ts +18 -9
- package/dist/boot-options/billing-option.js +44 -18
- package/dist/boot-options/file-storage-option.js +6 -6
- package/dist/boot-options/option-env-schema.js +11 -2
- package/dist/boot-options/resolve-boot-options.js +9 -3
- package/dist/boot-options/stack-boot-requirements.js +1 -1
- package/dist/build-prod-bundle.d.ts +16 -15
- package/dist/build-prod-bundle.js +106 -108
- package/dist/csp-hashes.js +1 -1
- package/dist/extra-routes-deps.d.ts +1 -1
- package/dist/fatal-error-handlers.js +7 -0
- package/dist/index.js +4 -4
- package/dist/pii-boot-gate.js +1 -1
- package/dist/require-env.d.ts +2 -0
- package/dist/require-env.js +21 -0
- package/dist/resolve-tailwind-cli.js +5 -7
- package/dist/run-bootstrap.d.ts +1 -1
- package/dist/run-bootstrap.js +1 -1
- package/dist/run-prod-app-auth.d.ts +17 -0
- package/dist/run-prod-app-auth.js +83 -0
- package/dist/run-prod-app-boot-context.d.ts +2 -2
- package/dist/run-prod-app-boot-context.js +28 -25
- package/dist/run-prod-app-config.d.ts +36 -0
- package/dist/run-prod-app-config.js +169 -0
- package/dist/run-prod-app-connections.d.ts +16 -0
- package/dist/run-prod-app-connections.js +55 -0
- package/dist/run-prod-app-entrypoint.d.ts +14 -0
- package/dist/run-prod-app-entrypoint.js +147 -0
- package/dist/run-prod-app-handle.d.ts +20 -0
- package/dist/run-prod-app-handle.js +104 -0
- package/dist/run-prod-app-request-context.d.ts +15 -0
- package/dist/run-prod-app-request-context.js +103 -0
- package/dist/run-prod-app-seeds.d.ts +13 -0
- package/dist/run-prod-app-seeds.js +86 -0
- package/dist/run-prod-app-static-files.js +43 -50
- package/dist/run-prod-app.d.ts +93 -103
- package/dist/run-prod-app.js +75 -772
- package/dist/run-worker-app.d.ts +1 -1
- package/dist/run-worker-app.js +5 -5
- package/dist/session-boot-gate.d.ts +1 -1
- package/dist/session-boot-gate.js +1 -1
- package/dist/session-wiring.d.ts +1 -1
- package/dist/session-wiring.js +1 -1
- package/dist/try-hono-first.js +1 -1
- package/package.json +16 -8
- 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
|
|
2
|
-
// runDevApp (kumiko-dev-server),
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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";
|
package/dist/pii-boot-gate.js
CHANGED
|
@@ -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
|
-
//
|
|
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,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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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)
|
package/dist/run-bootstrap.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type BootstrapPlan, type BootstrapReport } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
2
|
-
import {
|
|
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).
|
package/dist/run-bootstrap.js
CHANGED
|
@@ -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 "./
|
|
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
|
-
/**
|
|
43
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// (
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
223
|
-
//
|
|
224
|
-
// (channel-email),
|
|
225
|
-
//
|
|
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
|
|
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
|
|
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
|
+
}
|