@cosmicdrift/kumiko-server-runtime 0.387.0 → 0.389.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/dist/boot/boot-crypto.js +3 -3
- package/dist/boot/shared-boot-steps.d.ts +46 -0
- package/dist/boot/shared-boot-steps.js +160 -0
- package/dist/boot/wait-for-database.d.ts +5 -0
- package/dist/boot/wait-for-database.js +7 -6
- package/dist/boot/worker-boot-core.js +24 -38
- package/dist/boot-options/billing-option.d.ts +18 -9
- package/dist/boot-options/billing-option.js +44 -18
- package/dist/boot-options/demo-mode.d.ts +3 -3
- package/dist/boot-options/demo-mode.js +4 -1
- 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.d.ts +1 -0
- package/dist/boot-options/resolve-boot-options.js +11 -5
- package/dist/boot-options/stack-boot-requirements.d.ts +1 -1
- package/dist/boot-options/stack-boot-requirements.js +2 -14
- package/dist/build-prod-bundle.js +13 -13
- package/dist/bundled-lists-table.d.ts +2 -0
- package/dist/bundled-lists-table.js +56 -0
- package/dist/compose-auth-options.d.ts +16 -0
- package/dist/compose-auth-options.js +49 -0
- package/dist/compose-features.d.ts +5 -17
- package/dist/compose-features.js +6 -53
- package/dist/csp-hashes.js +1 -1
- package/dist/extra-routes-deps.d.ts +4 -0
- package/dist/extra-routes-deps.js +1 -0
- package/dist/fatal-error-handlers.js +7 -0
- package/dist/files-for-tenant.d.ts +3 -0
- package/dist/files-for-tenant.js +10 -0
- package/dist/renderer-web-fonts.js +3 -9
- package/dist/require-env.d.ts +2 -0
- package/dist/require-env.js +21 -0
- package/dist/resolve-installed-package.d.ts +10 -0
- package/dist/resolve-installed-package.js +32 -0
- package/dist/resolve-tailwind-cli.d.ts +3 -8
- package/dist/resolve-tailwind-cli.js +8 -25
- 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 -0
- package/dist/run-prod-app-boot-context.js +15 -7
- 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 +17 -0
- package/dist/run-prod-app-connections.js +59 -0
- package/dist/run-prod-app-entrypoint.d.ts +14 -0
- package/dist/run-prod-app-entrypoint.js +149 -0
- package/dist/run-prod-app-handle.d.ts +20 -0
- package/dist/run-prod-app-handle.js +105 -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 +88 -0
- package/dist/run-prod-app-static-files.js +9 -1
- package/dist/run-prod-app.d.ts +27 -19
- package/dist/run-prod-app.js +47 -727
- package/dist/run-worker-app.d.ts +12 -2
- package/dist/run-worker-app.js +2 -0
- package/package.json +27 -8
- package/src/changes.json +86 -0
|
@@ -1,34 +1,17 @@
|
|
|
1
1
|
// Resolves the locally installed @tailwindcss/cli binary to its absolute path.
|
|
2
2
|
// We avoid `bunx` because it still requests the registry manifest for a locally
|
|
3
3
|
// installed package and fails with `FailedToOpenSocket` when offline.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// branches can be tested under Vitest/Node without booting the server.
|
|
7
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { findInstalledPackageDir } from "./resolve-installed-package.js";
|
|
8
6
|
export function resolveTailwindCli(deps) {
|
|
9
7
|
if (deps.bun === undefined)
|
|
10
8
|
return undefined;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// bin: { tailwindcss: "./dist/index.mjs" } → absoluter Pfad
|
|
14
|
-
return resolve(pkgJsonPath, "..", "dist", "index.mjs");
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
9
|
+
const cliDir = findInstalledPackageDir("@tailwindcss/cli", deps.cwd);
|
|
10
|
+
return cliDir === undefined ? undefined : join(cliDir, "dist", "index.mjs");
|
|
19
11
|
}
|
|
20
|
-
/** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry
|
|
21
|
-
*
|
|
22
|
-
* aren't resolvable from that directory — e.g. Bun cache copies of
|
|
23
|
-
* `@cosmicdrift/kumiko-renderer/web` outside a hoisted node_modules tree. */
|
|
12
|
+
/** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry CSS file, so
|
|
13
|
+
* tailwindcss must be installed above it (not just next to a Bun cache copy of renderer-web). */
|
|
24
14
|
export function canResolveTailwindStylesheet(entryCss, deps) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
deps.bun.resolveSync("tailwindcss", dirname(entryCss));
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
15
|
+
return (deps.bun !== undefined &&
|
|
16
|
+
findInstalledPackageDir("tailwindcss", dirname(entryCss)) !== undefined);
|
|
34
17
|
}
|
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
|
+
}
|
|
@@ -16,6 +16,8 @@ export declare function addConfigAccessorFactory<T extends {
|
|
|
16
16
|
export declare function loggerFromExtraContext(extraContext: Record<string, unknown>): Logger | undefined;
|
|
17
17
|
/** Config key (`feature:config:name`) to value; wins over the key's env variable. */
|
|
18
18
|
export type ConfigOverridesInput = Readonly<Record<string, string | number | boolean>>;
|
|
19
|
+
/** Env bridge first, validated app overrides on top: the one merge every boot function shares. */
|
|
20
|
+
export declare function buildConfigOverrides(registry: Registry, envSource: Record<string, string | undefined>, appOverrides: ConfigOverridesInput | undefined): import("@cosmicdrift/kumiko-bundled-features/config").AppConfigOverrides;
|
|
19
21
|
type BootExtraContextOptions = {
|
|
20
22
|
readonly db: DbConnection;
|
|
21
23
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -120,12 +120,16 @@ function bootCryptoContext(crypto) {
|
|
|
120
120
|
...(crypto.configCipher && { configEncryption: crypto.configCipher }),
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
|
-
|
|
123
|
+
/** Env bridge first, validated app overrides on top: the one merge every boot function shares. */
|
|
124
|
+
export function buildConfigOverrides(registry, envSource, appOverrides) {
|
|
124
125
|
const envOverrides = buildEnvConfigOverrides(registry, envSource);
|
|
126
|
+
return appOverrides
|
|
127
|
+
? new Map([...envOverrides, ...validateAppOverrides(registry, appOverrides)])
|
|
128
|
+
: envOverrides;
|
|
129
|
+
}
|
|
130
|
+
function createBootConfigResolver(registry, envSource, crypto, appOverrides) {
|
|
125
131
|
return createConfigResolver({
|
|
126
|
-
appOverrides: appOverrides
|
|
127
|
-
? new Map([...envOverrides, ...validateAppOverrides(registry, appOverrides)])
|
|
128
|
-
: envOverrides,
|
|
132
|
+
appOverrides: buildConfigOverrides(registry, envSource, appOverrides),
|
|
129
133
|
...(crypto.configCipher && { cipher: crypto.configCipher }),
|
|
130
134
|
});
|
|
131
135
|
}
|
|
@@ -133,9 +137,13 @@ function createBootConfigResolver(registry, envSource, crypto, appOverrides) {
|
|
|
133
137
|
// layouts), so apps without auth still get a resolver for it; the ctx entry
|
|
134
138
|
// stays auth-only.
|
|
135
139
|
function resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
if (opts.hasAuth || hasDeliveryFeature) {
|
|
141
|
+
return createBootConfigResolver(opts.registry, opts.envSource, crypto, opts.appOverrides);
|
|
142
|
+
}
|
|
143
|
+
if (opts.appOverrides) {
|
|
144
|
+
throw new Error("[runProdApp] appOverrides needs `auth` or a delivery feature: without one no config resolver is built and the overrides would be ignored.");
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
139
147
|
}
|
|
140
148
|
function bootDeliveryContext(opts, parts) {
|
|
141
149
|
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, options.bundledLists);
|
|
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,17 @@
|
|
|
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 DatabaseWaitClock } from "./boot/wait-for-database.ts";
|
|
6
|
+
import type { RunProdAppOptions } from "./run-prod-app.ts";
|
|
7
|
+
import type { ProdBootConfig, RegisterBootAbortCleanup } from "./run-prod-app-config.ts";
|
|
8
|
+
export type ProdConnections = {
|
|
9
|
+
readonly db: DbConnection;
|
|
10
|
+
readonly closeDb: () => Promise<void>;
|
|
11
|
+
readonly outsideTransactionDb: DbConnection;
|
|
12
|
+
readonly closeOutsideTransactionDb: () => Promise<void>;
|
|
13
|
+
readonly redis: Redis;
|
|
14
|
+
readonly cacheSync: ReturnType<typeof createRedisCacheSyncBus>;
|
|
15
|
+
};
|
|
16
|
+
export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup, databaseWait?: DatabaseWaitClock): Promise<ProdConnections>;
|
|
17
|
+
export declare function assertProdSchemaCurrent(db: DbConnection, migrations: RunProdAppOptions["migrations"], log: Logger): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
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, databaseWait) {
|
|
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 = (databaseWait?.now ?? Date.now)() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
16
|
+
if (bootKms.kms) {
|
|
17
|
+
await waitForKmsHealthy(bootKms.kms, "runProdApp", databaseWaitDeadline, databaseWait);
|
|
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, {
|
|
26
|
+
...databaseWait,
|
|
27
|
+
processName: "runProdApp",
|
|
28
|
+
deadline: databaseWaitDeadline,
|
|
29
|
+
});
|
|
30
|
+
// One bus per process: the tier resolver, the dispatcher and the file-provider
|
|
31
|
+
// resolver all subscribe to it (invalidation across the API pods).
|
|
32
|
+
const cacheSync = createRedisCacheSyncBus({
|
|
33
|
+
redisUrl,
|
|
34
|
+
clientOptions: redisClientOptionsFromEnv(envSource),
|
|
35
|
+
});
|
|
36
|
+
return { ...pools, redis, cacheSync };
|
|
37
|
+
}
|
|
38
|
+
// Schema-drift gate (without drizzle, using kumiko/migrations). `kumiko
|
|
39
|
+
// schema apply` runs as a deploy step before container rollout. Boot only
|
|
40
|
+
// verifies that every kumiko/migrations/*.sql file is applied in
|
|
41
|
+
// _kumiko_migrations with an unchanged checksum and that every table in
|
|
42
|
+
// kumiko/migrations/.snapshot.json exists. Drift aborts boot rather than being auto-healed, avoiding
|
|
43
|
+
// races between container replicas. `migrations: false` opts out for custom
|
|
44
|
+
// schema setups.
|
|
45
|
+
export async function assertProdSchemaCurrent(db, migrations, log) {
|
|
46
|
+
if (migrations !== false) {
|
|
47
|
+
const migrationsDir = migrations?.dir ?? "./kumiko/migrations";
|
|
48
|
+
log.info(`checking schema drift (${migrationsDir})`);
|
|
49
|
+
try {
|
|
50
|
+
await assertKumikoSchemaCurrent(db, migrationsDir);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof SchemaDriftError) {
|
|
54
|
+
log.error(`BOOT ABORTED — ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -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>;
|