@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.
- package/dist/boot/boot-crypto.js +3 -3
- 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 +18 -38
- 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/csp-hashes.js +1 -1
- package/dist/fatal-error-handlers.js +7 -0
- package/dist/require-env.d.ts +2 -0
- package/dist/require-env.js +21 -0
- 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.js +7 -3
- 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.d.ts +20 -18
- package/dist/run-prod-app.js +46 -726
- package/dist/run-worker-app.d.ts +1 -1
- package/package.json +15 -7
- package/src/changes.json +25 -0
package/dist/boot/boot-crypto.js
CHANGED
|
@@ -15,6 +15,9 @@ export function envHasMasterKek(env) {
|
|
|
15
15
|
}
|
|
16
16
|
export function resolveBootCrypto(envSource, masterKeyOverride) {
|
|
17
17
|
const masterKeyProvider = masterKeyOverride ?? envMasterKeyProviderIfConfigured(envSource);
|
|
18
|
+
// Providers defer their keyring check under KUMIKO_DRY_RUN_ENV; an app-supplied
|
|
19
|
+
// provider must still be rejected at boot, not at the first key access.
|
|
20
|
+
masterKeyProvider?.currentVersion();
|
|
18
21
|
const dekCache = createDekCache();
|
|
19
22
|
const configCipher = masterKeyProvider
|
|
20
23
|
? createEnvelopeCipher(masterKeyProvider, { dekCache })
|
|
@@ -37,8 +40,5 @@ function envMasterKeyProviderIfConfigured(envSource) {
|
|
|
37
40
|
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
|
|
38
41
|
},
|
|
39
42
|
});
|
|
40
|
-
// The provider defers its keyring check under KUMIKO_DRY_RUN_ENV; the boot
|
|
41
|
-
// must still reject a malformed keyring there.
|
|
42
|
-
provider.currentVersion();
|
|
43
43
|
return provider;
|
|
44
44
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
2
|
+
import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
|
+
import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import { type EffectiveFeaturesResolver, type FeatureDefinition, type Registry, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import type { CacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
|
|
7
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import type { FeatureTogglesWiring } from "../boot-options/feature-toggles-option.ts";
|
|
9
|
+
import type { DbSeedFn } from "../boot-options/seed-shortcuts.ts";
|
|
10
|
+
import { type AuthOptionsCarrier } from "../compose-features.ts";
|
|
11
|
+
import type { RunProdAppAuthOptions } from "../run-prod-app.ts";
|
|
12
|
+
/** Returns `envSource` itself (it may be `process.env`) when there is nothing to layer under it. */
|
|
13
|
+
export declare function withEnvDefaults(envDefaults: Readonly<Record<string, string | undefined>>, envSource: Record<string, string | undefined>): Record<string, string | undefined>;
|
|
14
|
+
export declare function composeAppFeatures(appFeatures: readonly FeatureDefinition[], bootFeatures: readonly FeatureDefinition[], effectiveAuth: AuthOptionsCarrier | undefined): readonly FeatureDefinition[];
|
|
15
|
+
export declare function validateComposedFeatures(features: readonly FeatureDefinition[], bootOptions: ValidateBootOptions): void;
|
|
16
|
+
export declare function assertMfaSwitchTenantGate(registry: Registry, processName: string): void;
|
|
17
|
+
export declare function buildEffectiveFeaturesResolver(deps: {
|
|
18
|
+
readonly features: readonly FeatureDefinition[];
|
|
19
|
+
readonly featureToggles: FeatureTogglesWiring | undefined;
|
|
20
|
+
readonly db: DbConnection;
|
|
21
|
+
readonly registry: Registry;
|
|
22
|
+
readonly cacheSync: CacheSyncBus;
|
|
23
|
+
}): Promise<EffectiveFeaturesResolver | undefined>;
|
|
24
|
+
export declare function applyStandardBootSeeds(deps: {
|
|
25
|
+
readonly db: DbConnection;
|
|
26
|
+
readonly registry: Registry;
|
|
27
|
+
readonly admin: SeedAdminOptions | undefined;
|
|
28
|
+
readonly shortcutSeeds: readonly DbSeedFn[];
|
|
29
|
+
readonly cipher: EnvelopeCipher | undefined;
|
|
30
|
+
}): Promise<void>;
|
|
31
|
+
type SharedAuthFlowFields = Pick<RunProdAppAuthOptions, "passwordReset" | "emailVerification" | "accountUnlock" | "signup" | "invite">;
|
|
32
|
+
type AuthFlowRoutes = Pick<AuthRoutesConfig, "passwordReset" | "emailVerification" | "accountUnlock" | "signup" | "invite">;
|
|
33
|
+
export type SharedAuthRouteSource = SharedAuthFlowFields & Pick<RunProdAppAuthOptions, "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding">;
|
|
34
|
+
export type SharedAuthRoutes = AuthFlowRoutes & Pick<AuthRoutesConfig, "membershipQuery" | "loginHandler" | "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding" | "mfaVerifyHandler" | "switchTenantMfaGateHandler" | "mfaPreauthEnableStartHandler" | "mfaPreauthConfirmHandler">;
|
|
35
|
+
export declare function buildSharedAuthRoutes(effectiveAuth: SharedAuthRouteSource, hasMfa: boolean): SharedAuthRoutes;
|
|
36
|
+
export declare function waitForKmsHealthy(kms: KmsAdapter, processName: string, deadline: number): Promise<void>;
|
|
37
|
+
export declare function openDatabasePools(databaseUrl: string, envSource: Record<string, string | undefined>): {
|
|
38
|
+
readonly db: DbConnection;
|
|
39
|
+
readonly closeDb: () => Promise<void>;
|
|
40
|
+
readonly outsideTransactionDb: DbConnection;
|
|
41
|
+
readonly closeOutsideTransactionDb: () => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Boot steps that runProdApp, the dev server and the worker processes run
|
|
2
|
+
// identically. Each step keeps the caller in charge of ordering, logging sinks
|
|
3
|
+
// and cleanup policy: those differ between the processes.
|
|
4
|
+
import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
5
|
+
import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
6
|
+
import { AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
7
|
+
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
8
|
+
import { createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { collectWriteHandlerQns, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
11
|
+
import { buildComposeAuthOptions, composeFeatures, } from "../compose-features.js";
|
|
12
|
+
import { applyBootSeeds } from "./apply-boot-seeds.js";
|
|
13
|
+
import { waitUntilDatabaseReachable } from "./wait-for-database.js";
|
|
14
|
+
/** Returns `envSource` itself (it may be `process.env`) when there is nothing to layer under it. */
|
|
15
|
+
export function withEnvDefaults(envDefaults, envSource) {
|
|
16
|
+
return Object.keys(envDefaults).length > 0 ? { ...envDefaults, ...envSource } : envSource;
|
|
17
|
+
}
|
|
18
|
+
export function composeAppFeatures(appFeatures, bootFeatures, effectiveAuth) {
|
|
19
|
+
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
20
|
+
return composeFeatures([...appFeatures, ...bootFeatures], {
|
|
21
|
+
includeBundled: !!effectiveAuth,
|
|
22
|
+
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function validateComposedFeatures(features, bootOptions) {
|
|
26
|
+
validateBoot(features, bootOptions);
|
|
27
|
+
warnIfNonUtcServerTimeZone();
|
|
28
|
+
validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
|
|
29
|
+
}
|
|
30
|
+
export function assertMfaSwitchTenantGate(registry, processName) {
|
|
31
|
+
if (registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
|
|
32
|
+
throw new Error(`[${processName}] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
|
|
33
|
+
"createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
|
|
34
|
+
"POST /auth/switch-tenant would mint sessions without the second factor");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function buildEffectiveFeaturesResolver(deps) {
|
|
38
|
+
const { features, featureToggles, db, registry, cacheSync } = deps;
|
|
39
|
+
const tierResolverUsage = findTierResolverUsage(features);
|
|
40
|
+
const tierResolver = tierResolverUsage
|
|
41
|
+
? await requireTierResolverPlugin(tierResolverUsage).build({ db, registry, cacheSync })
|
|
42
|
+
: undefined;
|
|
43
|
+
if (!featureToggles)
|
|
44
|
+
return tierResolver;
|
|
45
|
+
const bound = await featureToggles.bind({ db, registry, cacheSync, tierResolver });
|
|
46
|
+
return bound.resolver;
|
|
47
|
+
}
|
|
48
|
+
export async function applyStandardBootSeeds(deps) {
|
|
49
|
+
const { db, registry, admin, shortcutSeeds, cipher } = deps;
|
|
50
|
+
if (admin) {
|
|
51
|
+
await seedAdminGuarded(db, admin);
|
|
52
|
+
}
|
|
53
|
+
for (const seed of shortcutSeeds) {
|
|
54
|
+
await seed(db);
|
|
55
|
+
}
|
|
56
|
+
// The helper indirection is what config-seed-boot.integration.ts pins — keep it as a single call.
|
|
57
|
+
await applyBootSeeds({
|
|
58
|
+
registry,
|
|
59
|
+
db,
|
|
60
|
+
...(cipher && { cipher }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const MFA_ROUTE_HANDLERS = {
|
|
64
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
65
|
+
switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
66
|
+
mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
|
|
67
|
+
mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
|
|
68
|
+
};
|
|
69
|
+
function buildAuthFlowRoutes(effectiveAuth) {
|
|
70
|
+
return {
|
|
71
|
+
...(effectiveAuth.passwordReset && {
|
|
72
|
+
passwordReset: {
|
|
73
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
74
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
...(effectiveAuth.emailVerification && {
|
|
78
|
+
emailVerification: {
|
|
79
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
80
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
...(effectiveAuth.accountUnlock && {
|
|
84
|
+
accountUnlock: {
|
|
85
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
86
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
...(effectiveAuth.signup && {
|
|
90
|
+
signup: {
|
|
91
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
92
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
...(effectiveAuth.invite && {
|
|
96
|
+
invite: {
|
|
97
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
98
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
99
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
100
|
+
infoHandler: AuthQueries.inviteInfo,
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function buildSharedAuthRoutes(effectiveAuth, hasMfa) {
|
|
106
|
+
return {
|
|
107
|
+
membershipQuery: TenantQueries.memberships,
|
|
108
|
+
loginHandler: AuthHandlers.login,
|
|
109
|
+
loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
|
|
110
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
111
|
+
[AuthErrors.noMembership]: 403,
|
|
112
|
+
},
|
|
113
|
+
...(effectiveAuth.cookieDomain !== undefined && {
|
|
114
|
+
cookieDomain: effectiveAuth.cookieDomain,
|
|
115
|
+
}),
|
|
116
|
+
...(effectiveAuth.retiredCookieDomains !== undefined && {
|
|
117
|
+
retiredCookieDomains: effectiveAuth.retiredCookieDomains,
|
|
118
|
+
}),
|
|
119
|
+
...(effectiveAuth.allowedOrigins !== undefined && {
|
|
120
|
+
allowedOrigins: effectiveAuth.allowedOrigins,
|
|
121
|
+
}),
|
|
122
|
+
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
123
|
+
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
124
|
+
}),
|
|
125
|
+
...(effectiveAuth.postAuthLanding !== undefined && {
|
|
126
|
+
postAuthLanding: effectiveAuth.postAuthLanding,
|
|
127
|
+
}),
|
|
128
|
+
...(hasMfa && MFA_ROUTE_HANDLERS),
|
|
129
|
+
...buildAuthFlowRoutes(effectiveAuth),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export async function waitForKmsHealthy(kms, processName, deadline) {
|
|
133
|
+
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
134
|
+
processName,
|
|
135
|
+
target: "KMS",
|
|
136
|
+
deadline,
|
|
137
|
+
});
|
|
138
|
+
if (!kmsHealth.ok) {
|
|
139
|
+
throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function openDatabasePools(databaseUrl, envSource) {
|
|
143
|
+
const { db, close: closeDb } = createDbConnection({
|
|
144
|
+
url: databaseUrl,
|
|
145
|
+
...dbConnectionOptionsFromEnv(envSource),
|
|
146
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
147
|
+
});
|
|
148
|
+
const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
|
|
149
|
+
url: databaseUrl,
|
|
150
|
+
...outsideTransactionPoolOptionsFromEnv(envSource),
|
|
151
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
152
|
+
});
|
|
153
|
+
return { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb };
|
|
154
|
+
}
|
|
@@ -5,9 +5,12 @@ export declare const WAIT_FOR_DATABASE_MAX_BACKOFF_MS = 15000;
|
|
|
5
5
|
export declare const WAIT_FOR_DATABASE_DEADLINE_MS = 90000;
|
|
6
6
|
export type WaitForDatabaseOptions = {
|
|
7
7
|
readonly processName: string;
|
|
8
|
+
/** Names the dependency in the retry log line; default "database". */
|
|
9
|
+
readonly target?: string;
|
|
8
10
|
readonly log?: (line: string) => void;
|
|
9
11
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
10
12
|
readonly now?: () => number;
|
|
13
|
+
readonly deadline?: number;
|
|
11
14
|
};
|
|
12
15
|
export declare function waitForDatabase(db: DbConnection, options: WaitForDatabaseOptions): Promise<void>;
|
|
13
16
|
export declare function waitUntilDatabaseReachable<T>(ping: () => Promise<T>, options: WaitForDatabaseOptions): Promise<T>;
|
|
@@ -37,8 +37,8 @@ export async function waitForDatabase(db, options) {
|
|
|
37
37
|
await waitUntilDatabaseReachable(() => tableExists(db, "pg_catalog.pg_class"), options);
|
|
38
38
|
}
|
|
39
39
|
export async function waitUntilDatabaseReachable(ping, options) {
|
|
40
|
-
const { processName, log = logToConsole, sleep = sleepReal, now = Date.now } = options;
|
|
41
|
-
const deadline = now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
40
|
+
const { processName, target = "database", log = logToConsole, sleep = sleepReal, now = Date.now, } = options;
|
|
41
|
+
const deadline = options.deadline ?? now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
42
42
|
let backoffMs = WAIT_FOR_DATABASE_INITIAL_BACKOFF_MS;
|
|
43
43
|
for (let attempt = 1;; attempt++) {
|
|
44
44
|
try {
|
|
@@ -52,7 +52,7 @@ export async function waitUntilDatabaseReachable(ping, options) {
|
|
|
52
52
|
throw error;
|
|
53
53
|
const waitMs = Math.min(backoffMs, remainingMs);
|
|
54
54
|
const code = extractPgError(error)?.code ?? "unknown";
|
|
55
|
-
log(`[${processName}]
|
|
55
|
+
log(`[${processName}] ${target} not reachable (code ${code}, attempt ${attempt}), retrying in ${waitMs}ms…`);
|
|
56
56
|
await sleep(waitMs);
|
|
57
57
|
backoffMs = Math.min(backoffMs * 2, WAIT_FOR_DATABASE_MAX_BACKOFF_MS);
|
|
58
58
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// share every step that decides ciphers, blind indexes and config encryption.
|
|
6
6
|
import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
|
|
7
7
|
import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
-
import { configureEntityFieldEncryption,
|
|
9
|
-
import { createRegistry,
|
|
8
|
+
import { configureEntityFieldEncryption, } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { createRegistry, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
11
11
|
import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
|
|
12
12
|
import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
@@ -16,11 +16,13 @@ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
16
16
|
import { Redis } from "ioredis";
|
|
17
17
|
import { composeFeatures } from "../compose-features.js";
|
|
18
18
|
import { assertPiiBootInvariants } from "../pii-boot-gate.js";
|
|
19
|
-
import { requireEnv } from "../
|
|
19
|
+
import { readEnv, requireEnv } from "../require-env.js";
|
|
20
20
|
import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "../run-prod-app-boot-context.js";
|
|
21
21
|
import { resolveBootCrypto } from "./boot-crypto.js";
|
|
22
22
|
import { jobRunLoggerCallbacks } from "./job-run-logger.js";
|
|
23
|
-
import {
|
|
23
|
+
import { INSTANCE_ID_ENV } from "./record-app-started-on-boot.js";
|
|
24
|
+
import { buildEffectiveFeaturesResolver, openDatabasePools, waitForKmsHealthy, } from "./shared-boot-steps.js";
|
|
25
|
+
import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./wait-for-database.js";
|
|
24
26
|
import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
|
|
25
27
|
function isSecretsContext(value) {
|
|
26
28
|
if (typeof value !== "object" || value === null)
|
|
@@ -36,12 +38,6 @@ export async function resolveWorkerEnvSource(options, processName) {
|
|
|
36
38
|
})
|
|
37
39
|
: rawEnvSource;
|
|
38
40
|
}
|
|
39
|
-
async function buildTierEffectiveFeatures(features, db, registry, cacheSync) {
|
|
40
|
-
const tierResolverUsage = findTierResolverUsage(features);
|
|
41
|
-
if (!tierResolverUsage)
|
|
42
|
-
return undefined;
|
|
43
|
-
return requireTierResolverPlugin(tierResolverUsage).build({ db, registry, cacheSync });
|
|
44
|
-
}
|
|
45
41
|
async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
|
|
46
42
|
const dir = migrationsDir ?? "./kumiko/migrations";
|
|
47
43
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint
|
|
@@ -96,32 +92,18 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
96
92
|
return { kind: "dry-run", envSource };
|
|
97
93
|
}
|
|
98
94
|
// Before the connections, so an abort leaks nothing.
|
|
95
|
+
const databaseWaitDeadline = Date.now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
99
96
|
if (options.kms) {
|
|
100
|
-
|
|
101
|
-
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
102
|
-
processName: processName,
|
|
103
|
-
});
|
|
104
|
-
if (!kmsHealth.ok) {
|
|
105
|
-
throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
|
|
106
|
-
}
|
|
97
|
+
await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline);
|
|
107
98
|
}
|
|
108
|
-
const { db,
|
|
109
|
-
url: databaseUrl,
|
|
110
|
-
...dbConnectionOptionsFromEnv(envSource),
|
|
111
|
-
...serverTimeoutOptionsFromEnv(envSource),
|
|
112
|
-
});
|
|
113
|
-
const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
|
|
114
|
-
url: databaseUrl,
|
|
115
|
-
...outsideTransactionPoolOptionsFromEnv(envSource),
|
|
116
|
-
...serverTimeoutOptionsFromEnv(envSource),
|
|
117
|
-
});
|
|
99
|
+
const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource);
|
|
118
100
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
119
101
|
const cacheSync = createRedisCacheSyncBus({
|
|
120
102
|
redisUrl,
|
|
121
103
|
clientOptions: redisClientOptionsFromEnv(envSource),
|
|
122
104
|
});
|
|
123
105
|
try {
|
|
124
|
-
await waitForDatabase(db, { processName });
|
|
106
|
+
await waitForDatabase(db, { processName, deadline: databaseWaitDeadline });
|
|
125
107
|
}
|
|
126
108
|
catch (err) {
|
|
127
109
|
await closeDb();
|
|
@@ -130,16 +112,13 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
130
112
|
await cacheSync.close();
|
|
131
113
|
throw err;
|
|
132
114
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
});
|
|
141
|
-
resolvedEffectiveFeatures = bound.resolver;
|
|
142
|
-
}
|
|
115
|
+
const resolvedEffectiveFeatures = await buildEffectiveFeaturesResolver({
|
|
116
|
+
features,
|
|
117
|
+
featureToggles: profile.featureToggles,
|
|
118
|
+
db,
|
|
119
|
+
registry,
|
|
120
|
+
cacheSync,
|
|
121
|
+
});
|
|
143
122
|
if (options.migrations !== false) {
|
|
144
123
|
try {
|
|
145
124
|
await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName);
|
|
@@ -200,6 +179,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
200
179
|
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
201
180
|
}),
|
|
202
181
|
...(options.eventDispatcher && { eventDispatcher: options.eventDispatcher }),
|
|
182
|
+
instanceId: readEnv(INSTANCE_ID_ENV, envSource),
|
|
203
183
|
});
|
|
204
184
|
entrypoint.lifecycle.registerShutdownHook("workerCacheSync", async () => {
|
|
205
185
|
await cacheSync.close();
|
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
import { type TierSyncResult } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
2
|
+
import { type SubscriptionDodoOptions } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
|
|
2
3
|
import { type SubscriptionStripeOptions } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
|
|
3
4
|
import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
|
|
4
5
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
-
/** Mounts `subscription-stripe` plus
|
|
6
|
-
* subscription and syncs the tenant's tier
|
|
7
|
-
* `billing-foundation`, `tier-engine`, `config` and
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
6
|
+
/** Mounts `subscription-stripe` and/or `subscription-dodo` plus one webhook
|
|
7
|
+
* route that writes the subscription and syncs the tenant's tier
|
|
8
|
+
* (`tier-engine`). Needs `billing-foundation`, `tier-engine`, `config` and
|
|
9
|
+
* `secrets` in `features`. The env keys (`STRIPE_API_KEY` /
|
|
10
|
+
* `STRIPE_WEBHOOK_SECRET`, `DODO_PAYMENTS_API_KEY` /
|
|
11
|
+
* `DODO_PAYMENTS_WEBHOOK_KEY`) are only the bridge fallback; the keys
|
|
12
|
+
* normally live in the secrets store.
|
|
10
13
|
*
|
|
11
14
|
* @example
|
|
12
15
|
* runProdApp({ ..., billing: {
|
|
13
16
|
* stripe: { priceToTier: { [env.STRIPE_PRICE_PRO]: "pro" } },
|
|
17
|
+
* dodo: { priceToTier: { [env.DODO_PRODUCT_PRO]: "pro" } },
|
|
14
18
|
* defaultTier: "free",
|
|
15
19
|
* } })
|
|
16
20
|
*/
|
|
17
21
|
export type BillingBootOption = {
|
|
18
|
-
|
|
22
|
+
/** At least one of `stripe` and `dodo` is required. */
|
|
23
|
+
readonly stripe?: {
|
|
19
24
|
readonly priceToTier: Readonly<Record<string, string>>;
|
|
20
|
-
/** Default `SUBSCRIPTION_WEBHOOK_PATH`; must contain `:providerName`. */
|
|
25
|
+
/** Default `SUBSCRIPTION_WEBHOOK_PATH`; must contain `:providerName`. Applies to every provider. */
|
|
21
26
|
readonly webhookPath?: string;
|
|
22
27
|
readonly oneOffPriceIds?: SubscriptionStripeOptions["oneOffPriceIds"];
|
|
23
28
|
readonly paymentInvoiceCreation?: boolean;
|
|
24
29
|
};
|
|
30
|
+
readonly dodo?: {
|
|
31
|
+
readonly priceToTier: Readonly<Record<string, string>>;
|
|
32
|
+
readonly oneOffPriceIds?: SubscriptionDodoOptions["oneOffPriceIds"];
|
|
33
|
+
};
|
|
25
34
|
/** Tier a tenant falls back to without an active subscription. */
|
|
26
35
|
readonly defaultTier: string;
|
|
27
36
|
/** Valid tier names beyond `defaultTier` and the `priceToTier` targets. */
|
|
28
37
|
readonly tierNames?: readonly string[];
|
|
29
|
-
/** Default `"fail-webhook"`:
|
|
38
|
+
/** Default `"fail-webhook"`: the provider retries, so a failed tier sync gets another attempt. */
|
|
30
39
|
readonly onTierSyncError?: "log" | "fail-webhook";
|
|
31
40
|
/** Runs after every webhook whose tier sync succeeded, also when the tier stayed the same (`changed: false`). */
|
|
32
41
|
readonly afterSync?: (result: TierSyncResult<string>) => Promise<void>;
|
|
33
42
|
};
|
|
34
43
|
export declare function resolveBillingOption(billing: BillingBootOption, envSource: Record<string, string | undefined>): {
|
|
35
|
-
readonly
|
|
44
|
+
readonly features: readonly FeatureDefinition[];
|
|
36
45
|
readonly route: ExtraRouteDefinition;
|
|
37
46
|
};
|
|
@@ -1,29 +1,55 @@
|
|
|
1
1
|
import { createSubscriptionTierSync, SUBSCRIPTION_WEBHOOK_PATH, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
2
|
+
import { createSubscriptionDodoFeature, } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
|
|
2
3
|
import { createSubscriptionStripeFeature, } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
|
|
3
4
|
const PROVIDER_NAME_PARAM = ":providerName";
|
|
5
|
+
function warnOnEmptyPriceMap(provider, priceToTier) {
|
|
6
|
+
if (Object.keys(priceToTier).length === 0) {
|
|
7
|
+
// biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
|
|
8
|
+
console.warn(`[runProdApp] billing.${provider}.priceToTier is empty: every subscription webhook is ignored until a price maps to a tier.`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function bridgeCredentials(envSource, apiKeyName, webhookSecretName) {
|
|
12
|
+
const apiKey = envSource[apiKeyName];
|
|
13
|
+
const webhookSecret = envSource[webhookSecretName];
|
|
14
|
+
return { ...(apiKey && { apiKey }), ...(webhookSecret && { webhookSecret }) };
|
|
15
|
+
}
|
|
16
|
+
function providerFeatures({ stripe, dodo }, envSource) {
|
|
17
|
+
const features = [];
|
|
18
|
+
if (stripe) {
|
|
19
|
+
warnOnEmptyPriceMap("stripe", stripe.priceToTier);
|
|
20
|
+
features.push(createSubscriptionStripeFeature({
|
|
21
|
+
priceToTier: stripe.priceToTier,
|
|
22
|
+
...(stripe.oneOffPriceIds && { oneOffPriceIds: stripe.oneOffPriceIds }),
|
|
23
|
+
...(stripe.paymentInvoiceCreation !== undefined && {
|
|
24
|
+
paymentInvoiceCreation: stripe.paymentInvoiceCreation,
|
|
25
|
+
}),
|
|
26
|
+
...bridgeCredentials(envSource, "STRIPE_API_KEY", "STRIPE_WEBHOOK_SECRET"),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
if (dodo) {
|
|
30
|
+
warnOnEmptyPriceMap("dodo", dodo.priceToTier);
|
|
31
|
+
features.push(createSubscriptionDodoFeature({
|
|
32
|
+
priceToTier: dodo.priceToTier,
|
|
33
|
+
...(dodo.oneOffPriceIds && { oneOffPriceIds: dodo.oneOffPriceIds }),
|
|
34
|
+
...bridgeCredentials(envSource, "DODO_PAYMENTS_API_KEY", "DODO_PAYMENTS_WEBHOOK_KEY"),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
return features;
|
|
38
|
+
}
|
|
4
39
|
export function resolveBillingOption(billing, envSource) {
|
|
5
|
-
const
|
|
40
|
+
const { stripe, dodo } = billing;
|
|
41
|
+
if (!stripe && !dodo) {
|
|
42
|
+
throw new Error("[runProdApp] billing needs at least one provider: set billing.stripe or billing.dodo.");
|
|
43
|
+
}
|
|
44
|
+
const webhookPath = stripe?.webhookPath ?? SUBSCRIPTION_WEBHOOK_PATH;
|
|
6
45
|
if (!webhookPath.includes(PROVIDER_NAME_PARAM)) {
|
|
7
46
|
throw new Error(`[runProdApp] billing.stripe.webhookPath "${webhookPath}" must contain ${PROVIDER_NAME_PARAM}.`);
|
|
8
47
|
}
|
|
9
|
-
|
|
10
|
-
// biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
|
|
11
|
-
console.warn("[runProdApp] billing.stripe.priceToTier is empty: every subscription webhook is ignored until a price maps to a tier.");
|
|
12
|
-
}
|
|
13
|
-
const apiKey = envSource["STRIPE_API_KEY"];
|
|
14
|
-
const webhookSecret = envSource["STRIPE_WEBHOOK_SECRET"];
|
|
15
|
-
const feature = createSubscriptionStripeFeature({
|
|
16
|
-
priceToTier: billing.stripe.priceToTier,
|
|
17
|
-
...(billing.stripe.oneOffPriceIds && { oneOffPriceIds: billing.stripe.oneOffPriceIds }),
|
|
18
|
-
...(billing.stripe.paymentInvoiceCreation !== undefined && {
|
|
19
|
-
paymentInvoiceCreation: billing.stripe.paymentInvoiceCreation,
|
|
20
|
-
}),
|
|
21
|
-
...(apiKey && { apiKey }),
|
|
22
|
-
...(webhookSecret && { webhookSecret }),
|
|
23
|
-
});
|
|
48
|
+
const features = providerFeatures(billing, envSource);
|
|
24
49
|
const tierNames = new Set([
|
|
25
50
|
billing.defaultTier,
|
|
26
|
-
...Object.values(
|
|
51
|
+
...Object.values(stripe?.priceToTier ?? {}),
|
|
52
|
+
...Object.values(dodo?.priceToTier ?? {}),
|
|
27
53
|
...(billing.tierNames ?? []),
|
|
28
54
|
]);
|
|
29
55
|
const tierSync = createSubscriptionTierSync({
|
|
@@ -32,5 +58,5 @@ export function resolveBillingOption(billing, envSource) {
|
|
|
32
58
|
onSyncError: billing.onTierSyncError ?? "fail-webhook",
|
|
33
59
|
...(billing.afterSync && { afterSync: billing.afterSync }),
|
|
34
60
|
});
|
|
35
|
-
return {
|
|
61
|
+
return { features, route: tierSync.createWebhookRoute({ path: webhookPath }) };
|
|
36
62
|
}
|
|
@@ -54,17 +54,17 @@ export function resolveFileStorageOption(fileStorage, deps) {
|
|
|
54
54
|
else {
|
|
55
55
|
provider = fileStorage.provider;
|
|
56
56
|
}
|
|
57
|
-
if (provider === "s3-env" && missingS3.length > 0) {
|
|
58
|
-
throw new Error(`[runProdApp] fileStorage "s3-env" is missing: ${missingS3.join(", ")}.`);
|
|
59
|
-
}
|
|
60
|
-
if (provider === "inmemory" && deps.isProduction) {
|
|
61
|
-
throw new Error('[runProdApp] fileStorage "inmemory" loses every file on restart — not allowed with NODE_ENV=production.');
|
|
62
|
-
}
|
|
63
57
|
const additional = fileStorage.providers ?? [];
|
|
64
58
|
const wanted = new Set([
|
|
65
59
|
provider,
|
|
66
60
|
...additional.filter((entry) => !isNamedS3FileProvider(entry)),
|
|
67
61
|
]);
|
|
62
|
+
if (wanted.has("s3-env") && missingS3.length > 0) {
|
|
63
|
+
throw new Error(`[runProdApp] fileStorage "s3-env" is missing: ${missingS3.join(", ")}.`);
|
|
64
|
+
}
|
|
65
|
+
if (wanted.has("inmemory") && deps.isProduction) {
|
|
66
|
+
throw new Error('[runProdApp] fileStorage "inmemory" loses every file on restart — not allowed with NODE_ENV=production.');
|
|
67
|
+
}
|
|
68
68
|
const named = additional.filter(isNamedS3FileProvider);
|
|
69
69
|
assertNamedProviderNames(named);
|
|
70
70
|
for (const entry of named) {
|
|
@@ -31,10 +31,17 @@ function pickShape(shape, keys) {
|
|
|
31
31
|
}
|
|
32
32
|
return picked;
|
|
33
33
|
}
|
|
34
|
+
function sysadminTenantNeedsEnvId(seeds) {
|
|
35
|
+
const { sysadminFromEnv } = seeds;
|
|
36
|
+
return (typeof sysadminFromEnv === "object" &&
|
|
37
|
+
sysadminFromEnv.tenant !== undefined &&
|
|
38
|
+
sysadminFromEnv.tenant.id === undefined);
|
|
39
|
+
}
|
|
34
40
|
function seedEnvBlock(seeds) {
|
|
35
41
|
const keys = [];
|
|
36
42
|
const required = [];
|
|
37
|
-
|
|
43
|
+
const demoTenantNeedsEnvId = seeds.demoTenant !== undefined && seeds.demoTenant.id === undefined;
|
|
44
|
+
if (demoTenantNeedsEnvId || sysadminTenantNeedsEnvId(seeds)) {
|
|
38
45
|
keys.push("DEMO_TENANT_ID");
|
|
39
46
|
required.push("DEMO_TENANT_ID");
|
|
40
47
|
}
|
|
@@ -61,7 +68,9 @@ function fileStorageEnvBlocks(fileStorage) {
|
|
|
61
68
|
blocks.push({
|
|
62
69
|
source: "runProdApp.fileStorage",
|
|
63
70
|
shape: s3EnvSchema.shape,
|
|
64
|
-
required: fileStorage.provider === "s3-env"
|
|
71
|
+
required: fileStorage.provider === "s3-env" || fileStorage.providers?.includes("s3-env")
|
|
72
|
+
? S3_REQUIRED_KEYS
|
|
73
|
+
: [],
|
|
65
74
|
});
|
|
66
75
|
}
|
|
67
76
|
for (const provider of fileStorage?.providers ?? []) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BILLING_FOUNDATION_FEATURE } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
2
2
|
import { DELIVERY_FEATURE } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
3
3
|
import { FEATURE_TOGGLES_FEATURE } from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
|
|
4
|
+
import { SUBSCRIPTION_DODO_FEATURE } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
|
|
4
5
|
import { SUBSCRIPTION_STRIPE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
|
|
5
6
|
import { TIER_ENGINE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
|
|
6
7
|
import { resolveBillingOption } from "./billing-option.js";
|
|
@@ -82,10 +83,15 @@ export async function resolveBootOptionWiring(options, envSource) {
|
|
|
82
83
|
envDefaults = { ...envDefaults, ...fileStorage.envDefaults };
|
|
83
84
|
}
|
|
84
85
|
if (options.billing) {
|
|
85
|
-
requireFeatures(mounted, "billing
|
|
86
|
-
|
|
86
|
+
requireFeatures(mounted, "billing", [BILLING_FOUNDATION_FEATURE, TIER_ENGINE_FEATURE], "Mount createBillingFoundationFeature() and createTierEngineFeature(), or composeSaasStack from @cosmicdrift/kumiko-dev-server/compose-stacks.");
|
|
87
|
+
if (options.billing.stripe) {
|
|
88
|
+
rejectDoubleMount(mounted, "billing.stripe", SUBSCRIPTION_STRIPE_FEATURE);
|
|
89
|
+
}
|
|
90
|
+
if (options.billing.dodo) {
|
|
91
|
+
rejectDoubleMount(mounted, "billing.dodo", SUBSCRIPTION_DODO_FEATURE);
|
|
92
|
+
}
|
|
87
93
|
const billing = resolveBillingOption(options.billing, envSource);
|
|
88
|
-
features.push(billing.
|
|
94
|
+
features.push(...billing.features);
|
|
89
95
|
extraRoutes.push(billing.route);
|
|
90
96
|
}
|
|
91
97
|
const featureToggles = wireFeatureToggles(options, mounted);
|
|
@@ -23,7 +23,7 @@ const OPTION_CHECKS = {
|
|
|
23
23
|
},
|
|
24
24
|
billing: {
|
|
25
25
|
isSatisfied: (options, mounted) => options.billing !== undefined || mountsExtension(mounted, SUBSCRIPTION_PROVIDER_EXTENSION),
|
|
26
|
-
hint: "set billing: { stripe: { priceToTier }, defaultTier }",
|
|
26
|
+
hint: "set billing: { stripe: { priceToTier }, defaultTier } (or dodo)",
|
|
27
27
|
},
|
|
28
28
|
featureToggles: {
|
|
29
29
|
isSatisfied: (options) => options.featureToggles === true,
|
package/dist/csp-hashes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
3
|
import { isScriptHash } from "./security-headers.js";
|
|
4
4
|
export const CSP_HASHES_FILE = "csp-hashes.json";
|
|
5
|
-
const INLINE_SCRIPT_PATTERN = /<script\b([
|
|
5
|
+
const INLINE_SCRIPT_PATTERN = /<script\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/script\s*>/gi;
|
|
6
6
|
const SRC_ATTRIBUTE_PATTERN = /\ssrc\s*=/i;
|
|
7
7
|
const TYPE_ATTRIBUTE_PATTERN = /\stype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
|
|
8
8
|
// Data blocks (application/json, ld+json, templates) never execute, so
|
|
@@ -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
|
};
|