@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
package/LICENSE
CHANGED
|
@@ -1,57 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
controlling, or under common control with the Licensor ("Affiliates"), or
|
|
23
|
-
contractors acting on their behalf. The Licensor remains free to use the
|
|
24
|
-
Licensed Work for any purpose, including for the operation of kumiko.rocks.
|
|
25
|
-
|
|
26
|
-
Change Date: 2030-05-05
|
|
27
|
-
Change License: Apache License, Version 2.0
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Terms
|
|
31
|
-
|
|
32
|
-
The Licensor hereby grants you the right to copy, modify, create derivative works,
|
|
33
|
-
redistribute, and make non-production use of the Licensed Work. The Licensor may
|
|
34
|
-
make an Additional Use Grant, above, permitting limited production use.
|
|
35
|
-
|
|
36
|
-
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
37
|
-
available distribution of the Licensed Work under this License, whichever comes
|
|
38
|
-
first, this License will convert to the Change License.
|
|
39
|
-
|
|
40
|
-
This Business Source License governs use of the Licensed Work in all cases, except
|
|
41
|
-
as to any use that is explicitly granted in the Additional Use Grant above or
|
|
42
|
-
under the Change License after the Change Date.
|
|
43
|
-
|
|
44
|
-
If your use of the Licensed Work does not comply with the requirements of this
|
|
45
|
-
License, you must cease use of the Licensed Work immediately.
|
|
46
|
-
|
|
47
|
-
All copies of the Licensed Work, and all derivative works thereof, must include
|
|
48
|
-
this License.
|
|
49
|
-
|
|
50
|
-
This License does not grant you any right, title, or interest in any trademark,
|
|
51
|
-
logo, or branding of the Licensor, except as required to comply with this License.
|
|
52
|
-
|
|
53
|
-
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
54
|
-
“AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
|
55
|
-
WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
56
|
-
TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
|
|
57
|
-
ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marc Frost
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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 })
|
|
@@ -31,15 +34,11 @@ function envMasterKeyProviderIfConfigured(envSource) {
|
|
|
31
34
|
if (!envHasMasterKek(envSource))
|
|
32
35
|
return undefined;
|
|
33
36
|
const provider = createEnvMasterKeyProvider({
|
|
34
|
-
// CURRENT_VERSION
|
|
35
|
-
// ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
|
|
37
|
+
// CURRENT_VERSION defaults to "1" in secretsEnvSchema; without it, the raw-env provider throws even when V1 is set.
|
|
36
38
|
env: {
|
|
37
39
|
...envSource,
|
|
38
40
|
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
|
|
39
41
|
},
|
|
40
42
|
});
|
|
41
|
-
// The provider defers its keyring check under KUMIKO_DRY_RUN_ENV; the boot
|
|
42
|
-
// must still reject a malformed keyring there.
|
|
43
|
-
provider.currentVersion();
|
|
44
43
|
return provider;
|
|
45
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
|
|
@@ -59,7 +55,7 @@ async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
|
|
|
59
55
|
}
|
|
60
56
|
export async function bootWorkerProcess(options, envSource, profile) {
|
|
61
57
|
const { processName } = profile;
|
|
62
|
-
// Polyfill before anything else
|
|
58
|
+
// Polyfill before anything else: without it every job fails with
|
|
63
59
|
// "Temporal is not defined" in a retry loop, with no boot-time signal.
|
|
64
60
|
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
65
61
|
await ensureTemporalPolyfill();
|
|
@@ -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);
|
|
@@ -155,7 +134,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
155
134
|
const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
|
|
156
135
|
const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
|
|
157
136
|
const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
|
|
158
|
-
// Same crypto wiring as the API
|
|
137
|
+
// Same crypto wiring as the API, otherwise this process writes
|
|
159
138
|
// rows the API can no longer read (different cipher, missing blind index).
|
|
160
139
|
const deps = { db, redis, registry };
|
|
161
140
|
const resolvedExtraContext = typeof options.extraContext === "function"
|
|
@@ -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) {
|