@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
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,46 @@
|
|
|
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 BundledListsOptions, 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
|
+
import { type DatabaseWaitClock } from "./wait-for-database.ts";
|
|
13
|
+
/** Returns `envSource` itself (it may be `process.env`) when there is nothing to layer under it. */
|
|
14
|
+
export declare function withEnvDefaults(envDefaults: Readonly<Record<string, string | undefined>>, envSource: Record<string, string | undefined>): Record<string, string | undefined>;
|
|
15
|
+
export declare function composeAppFeatures(appFeatures: readonly FeatureDefinition[], bootFeatures: readonly FeatureDefinition[], effectiveAuth: AuthOptionsCarrier | undefined, bundledLists?: BundledListsOptions): readonly FeatureDefinition[];
|
|
16
|
+
export declare function validateComposedFeatures(features: readonly FeatureDefinition[], bootOptions: ValidateBootOptions): void;
|
|
17
|
+
export declare function assertMfaSwitchTenantGate(registry: Registry, processName: string): void;
|
|
18
|
+
export declare function buildEffectiveFeaturesResolver(deps: {
|
|
19
|
+
readonly features: readonly FeatureDefinition[];
|
|
20
|
+
readonly featureToggles: FeatureTogglesWiring | undefined;
|
|
21
|
+
readonly db: DbConnection;
|
|
22
|
+
readonly registry: Registry;
|
|
23
|
+
readonly cacheSync: CacheSyncBus;
|
|
24
|
+
}): Promise<EffectiveFeaturesResolver | undefined>;
|
|
25
|
+
export declare function applyStandardBootSeeds(deps: {
|
|
26
|
+
readonly db: DbConnection;
|
|
27
|
+
readonly registry: Registry;
|
|
28
|
+
readonly admin: SeedAdminOptions | undefined;
|
|
29
|
+
readonly shortcutSeeds: readonly DbSeedFn[];
|
|
30
|
+
readonly cipher: EnvelopeCipher | undefined;
|
|
31
|
+
readonly processName: string;
|
|
32
|
+
readonly databaseWait?: DatabaseWaitClock;
|
|
33
|
+
}): Promise<void>;
|
|
34
|
+
type SharedAuthFlowFields = Pick<RunProdAppAuthOptions, "passwordReset" | "emailVerification" | "accountUnlock" | "signup" | "invite">;
|
|
35
|
+
type AuthFlowRoutes = Pick<AuthRoutesConfig, "passwordReset" | "emailVerification" | "accountUnlock" | "signup" | "invite">;
|
|
36
|
+
export type SharedAuthRouteSource = SharedAuthFlowFields & Pick<RunProdAppAuthOptions, "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding">;
|
|
37
|
+
export type SharedAuthRoutes = AuthFlowRoutes & Pick<AuthRoutesConfig, "membershipQuery" | "loginHandler" | "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding" | "mfaVerifyHandler" | "switchTenantMfaGateHandler" | "mfaPreauthEnableStartHandler" | "mfaPreauthConfirmHandler">;
|
|
38
|
+
export declare function buildSharedAuthRoutes(effectiveAuth: SharedAuthRouteSource, hasMfa: boolean): SharedAuthRoutes;
|
|
39
|
+
export declare function waitForKmsHealthy(kms: KmsAdapter, processName: string, deadline: number, clock?: DatabaseWaitClock): Promise<void>;
|
|
40
|
+
export declare function openDatabasePools(databaseUrl: string, envSource: Record<string, string | undefined>): {
|
|
41
|
+
readonly db: DbConnection;
|
|
42
|
+
readonly closeDb: () => Promise<void>;
|
|
43
|
+
readonly outsideTransactionDb: DbConnection;
|
|
44
|
+
readonly closeOutsideTransactionDb: () => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
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, bundledLists) {
|
|
19
|
+
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
20
|
+
return composeFeatures([...appFeatures, ...bootFeatures], {
|
|
21
|
+
includeBundled: !!effectiveAuth,
|
|
22
|
+
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
23
|
+
...(bundledLists && { bundledLists }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function validateComposedFeatures(features, bootOptions) {
|
|
27
|
+
validateBoot(features, bootOptions);
|
|
28
|
+
warnIfNonUtcServerTimeZone();
|
|
29
|
+
validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
|
|
30
|
+
}
|
|
31
|
+
export function assertMfaSwitchTenantGate(registry, processName) {
|
|
32
|
+
if (registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
|
|
33
|
+
throw new Error(`[${processName}] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
|
|
34
|
+
"createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
|
|
35
|
+
"POST /auth/switch-tenant would mint sessions without the second factor");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function buildEffectiveFeaturesResolver(deps) {
|
|
39
|
+
const { features, featureToggles, db, registry, cacheSync } = deps;
|
|
40
|
+
const tierResolverUsage = findTierResolverUsage(features);
|
|
41
|
+
const tierResolver = tierResolverUsage
|
|
42
|
+
? await requireTierResolverPlugin(tierResolverUsage).build({ db, registry, cacheSync })
|
|
43
|
+
: undefined;
|
|
44
|
+
if (!featureToggles)
|
|
45
|
+
return tierResolver;
|
|
46
|
+
const bound = await featureToggles.bind({ db, registry, cacheSync, tierResolver });
|
|
47
|
+
return bound.resolver;
|
|
48
|
+
}
|
|
49
|
+
export async function applyStandardBootSeeds(deps) {
|
|
50
|
+
const { db, registry, admin, shortcutSeeds, cipher, processName, databaseWait } = deps;
|
|
51
|
+
// Seeds are idempotent, so a transient connection error re-runs the whole sequence; any other
|
|
52
|
+
// error stays fatal. Later boot steps build on the seeds, so this cannot continue in the background.
|
|
53
|
+
await waitUntilDatabaseReachable(async () => {
|
|
54
|
+
if (admin) {
|
|
55
|
+
await seedAdminGuarded(db, admin);
|
|
56
|
+
}
|
|
57
|
+
for (const seed of shortcutSeeds) {
|
|
58
|
+
await seed(db);
|
|
59
|
+
}
|
|
60
|
+
// The helper indirection is what config-seed-boot.integration.ts pins — keep it as a single call.
|
|
61
|
+
await applyBootSeeds({
|
|
62
|
+
registry,
|
|
63
|
+
db,
|
|
64
|
+
...(cipher && { cipher }),
|
|
65
|
+
});
|
|
66
|
+
}, { ...databaseWait, processName, target: "database (boot seeds)" });
|
|
67
|
+
}
|
|
68
|
+
const MFA_ROUTE_HANDLERS = {
|
|
69
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
70
|
+
switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
71
|
+
mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
|
|
72
|
+
mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
|
|
73
|
+
};
|
|
74
|
+
function buildAuthFlowRoutes(effectiveAuth) {
|
|
75
|
+
return {
|
|
76
|
+
...(effectiveAuth.passwordReset && {
|
|
77
|
+
passwordReset: {
|
|
78
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
79
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
...(effectiveAuth.emailVerification && {
|
|
83
|
+
emailVerification: {
|
|
84
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
85
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
...(effectiveAuth.accountUnlock && {
|
|
89
|
+
accountUnlock: {
|
|
90
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
91
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
...(effectiveAuth.signup && {
|
|
95
|
+
signup: {
|
|
96
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
97
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
...(effectiveAuth.invite && {
|
|
101
|
+
invite: {
|
|
102
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
103
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
104
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
105
|
+
infoHandler: AuthQueries.inviteInfo,
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function buildSharedAuthRoutes(effectiveAuth, hasMfa) {
|
|
111
|
+
return {
|
|
112
|
+
membershipQuery: TenantQueries.memberships,
|
|
113
|
+
loginHandler: AuthHandlers.login,
|
|
114
|
+
loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
|
|
115
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
116
|
+
[AuthErrors.noMembership]: 403,
|
|
117
|
+
},
|
|
118
|
+
...(effectiveAuth.cookieDomain !== undefined && {
|
|
119
|
+
cookieDomain: effectiveAuth.cookieDomain,
|
|
120
|
+
}),
|
|
121
|
+
...(effectiveAuth.retiredCookieDomains !== undefined && {
|
|
122
|
+
retiredCookieDomains: effectiveAuth.retiredCookieDomains,
|
|
123
|
+
}),
|
|
124
|
+
...(effectiveAuth.allowedOrigins !== undefined && {
|
|
125
|
+
allowedOrigins: effectiveAuth.allowedOrigins,
|
|
126
|
+
}),
|
|
127
|
+
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
128
|
+
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
129
|
+
}),
|
|
130
|
+
...(effectiveAuth.postAuthLanding !== undefined && {
|
|
131
|
+
postAuthLanding: effectiveAuth.postAuthLanding,
|
|
132
|
+
}),
|
|
133
|
+
...(hasMfa && MFA_ROUTE_HANDLERS),
|
|
134
|
+
...buildAuthFlowRoutes(effectiveAuth),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export async function waitForKmsHealthy(kms, processName, deadline, clock) {
|
|
138
|
+
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
139
|
+
...clock,
|
|
140
|
+
processName,
|
|
141
|
+
target: "KMS",
|
|
142
|
+
deadline,
|
|
143
|
+
});
|
|
144
|
+
if (!kmsHealth.ok) {
|
|
145
|
+
throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function openDatabasePools(databaseUrl, envSource) {
|
|
149
|
+
const { db, close: closeDb } = createDbConnection({
|
|
150
|
+
url: databaseUrl,
|
|
151
|
+
...dbConnectionOptionsFromEnv(envSource),
|
|
152
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
153
|
+
});
|
|
154
|
+
const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
|
|
155
|
+
url: databaseUrl,
|
|
156
|
+
...outsideTransactionPoolOptionsFromEnv(envSource),
|
|
157
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
158
|
+
});
|
|
159
|
+
return { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb };
|
|
160
|
+
}
|
|
@@ -5,9 +5,14 @@ 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
|
};
|
|
15
|
+
/** Injectable clock and sink; tests drive the backoff without real waiting. */
|
|
16
|
+
export type DatabaseWaitClock = Pick<WaitForDatabaseOptions, "log" | "sleep" | "now">;
|
|
12
17
|
export declare function waitForDatabase(db: DbConnection, options: WaitForDatabaseOptions): Promise<void>;
|
|
13
18
|
export declare function waitUntilDatabaseReachable<T>(ping: () => Promise<T>, options: WaitForDatabaseOptions): Promise<T>;
|
|
@@ -7,9 +7,10 @@ const TRANSIENT_CODES = new Set([
|
|
|
7
7
|
"ETIMEDOUT",
|
|
8
8
|
"ENOTFOUND",
|
|
9
9
|
"EAI_AGAIN",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
10
|
+
"ERR_POSTGRES_CONNECTION_CLOSED",
|
|
11
|
+
"ERR_POSTGRES_CONNECTION_REFUSED",
|
|
12
|
+
"ERR_POSTGRES_CONNECTION_TIMEOUT",
|
|
13
|
+
"ERR_POSTGRES_CONNECTION_FAILED",
|
|
13
14
|
]);
|
|
14
15
|
const CONNECTION_EXCEPTION_CLASS = "08";
|
|
15
16
|
export function isTransientConnectionError(error) {
|
|
@@ -37,8 +38,8 @@ export async function waitForDatabase(db, options) {
|
|
|
37
38
|
await waitUntilDatabaseReachable(() => tableExists(db, "pg_catalog.pg_class"), options);
|
|
38
39
|
}
|
|
39
40
|
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;
|
|
41
|
+
const { processName, target = "database", log = logToConsole, sleep = sleepReal, now = Date.now, } = options;
|
|
42
|
+
const deadline = options.deadline ?? now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
42
43
|
let backoffMs = WAIT_FOR_DATABASE_INITIAL_BACKOFF_MS;
|
|
43
44
|
for (let attempt = 1;; attempt++) {
|
|
44
45
|
try {
|
|
@@ -52,7 +53,7 @@ export async function waitUntilDatabaseReachable(ping, options) {
|
|
|
52
53
|
throw error;
|
|
53
54
|
const waitMs = Math.min(backoffMs, remainingMs);
|
|
54
55
|
const code = extractPgError(error)?.code ?? "unknown";
|
|
55
|
-
log(`[${processName}]
|
|
56
|
+
log(`[${processName}] ${target} not reachable (code ${code}, attempt ${attempt}), retrying in ${waitMs}ms…`);
|
|
56
57
|
await sleep(waitMs);
|
|
57
58
|
backoffMs = Math.min(backoffMs * 2, WAIT_FOR_DATABASE_MAX_BACKOFF_MS);
|
|
58
59
|
}
|
|
@@ -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
|
|
@@ -71,6 +67,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
71
67
|
const features = composeFeatures(options.features, {
|
|
72
68
|
includeBundled,
|
|
73
69
|
...(profile.authOptions && { authOptions: profile.authOptions }),
|
|
70
|
+
...(options.bundledLists && { bundledLists: options.bundledLists }),
|
|
74
71
|
});
|
|
75
72
|
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
76
73
|
validateBoot(features, {
|
|
@@ -96,32 +93,22 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
96
93
|
return { kind: "dry-run", envSource };
|
|
97
94
|
}
|
|
98
95
|
// Before the connections, so an abort leaks nothing.
|
|
96
|
+
const databaseWaitDeadline = (options.databaseWait?.now ?? Date.now)() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
99
97
|
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
|
-
}
|
|
98
|
+
await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline, options.databaseWait);
|
|
107
99
|
}
|
|
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
|
-
});
|
|
100
|
+
const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource);
|
|
118
101
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
119
102
|
const cacheSync = createRedisCacheSyncBus({
|
|
120
103
|
redisUrl,
|
|
121
104
|
clientOptions: redisClientOptionsFromEnv(envSource),
|
|
122
105
|
});
|
|
123
106
|
try {
|
|
124
|
-
await waitForDatabase(db, {
|
|
107
|
+
await waitForDatabase(db, {
|
|
108
|
+
...options.databaseWait,
|
|
109
|
+
processName,
|
|
110
|
+
deadline: databaseWaitDeadline,
|
|
111
|
+
});
|
|
125
112
|
}
|
|
126
113
|
catch (err) {
|
|
127
114
|
await closeDb();
|
|
@@ -130,16 +117,13 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
130
117
|
await cacheSync.close();
|
|
131
118
|
throw err;
|
|
132
119
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
});
|
|
141
|
-
resolvedEffectiveFeatures = bound.resolver;
|
|
142
|
-
}
|
|
120
|
+
const resolvedEffectiveFeatures = await buildEffectiveFeaturesResolver({
|
|
121
|
+
features,
|
|
122
|
+
featureToggles: profile.featureToggles,
|
|
123
|
+
db,
|
|
124
|
+
registry,
|
|
125
|
+
cacheSync,
|
|
126
|
+
});
|
|
143
127
|
if (options.migrations !== false) {
|
|
144
128
|
try {
|
|
145
129
|
await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName);
|
|
@@ -172,6 +156,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
172
156
|
registry,
|
|
173
157
|
hasAuth: includeBundled,
|
|
174
158
|
redis,
|
|
159
|
+
...(options.appOverrides && { appOverrides: options.appOverrides }),
|
|
175
160
|
crypto: bootCrypto,
|
|
176
161
|
...(options.kms && { kms: options.kms }),
|
|
177
162
|
...(profile.deliverQueuedInline === true && { deliverQueuedInline: true }),
|
|
@@ -200,6 +185,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
200
185
|
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
201
186
|
}),
|
|
202
187
|
...(options.eventDispatcher && { eventDispatcher: options.eventDispatcher }),
|
|
188
|
+
instanceId: readEnv(INSTANCE_ID_ENV, envSource),
|
|
203
189
|
});
|
|
204
190
|
entrypoint.lifecycle.registerShutdownHook("workerCacheSync", async () => {
|
|
205
191
|
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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
export type DemoAccountRole = "admin" | "sysadmin";
|
|
3
3
|
/** A role alone, or a role with the label the banner shows next to the login. */
|
|
4
4
|
export type DemoAccountEntry = DemoAccountRole | {
|
|
@@ -19,7 +19,7 @@ export type DemoModeNotice = {
|
|
|
19
19
|
readonly accounts?: readonly DemoAccountEntry[];
|
|
20
20
|
};
|
|
21
21
|
/** Live-demo switch. `readOnly: true` answers every mutating request
|
|
22
|
-
* (POST/PUT/PATCH/DELETE) outside `/api/query` and the login/logout/MFA
|
|
22
|
+
* (POST/PUT/PATCH/DELETE) and every WebSocket upgrade outside `/api/query` and the login/logout/MFA
|
|
23
23
|
* routes with 403 `demo_read_only`. `GET /demo-mode` (anonymous) returns
|
|
24
24
|
* `{ readOnly, notice }` for the client banner.
|
|
25
25
|
*
|
|
@@ -32,7 +32,7 @@ export type DemoModeNotice = {
|
|
|
32
32
|
export type DemoModeOption = {
|
|
33
33
|
readonly readOnly: boolean;
|
|
34
34
|
readonly notice?: DemoModeNotice;
|
|
35
|
-
/** Mutating paths that stay open besides the default allowlist. */
|
|
35
|
+
/** Mutating paths (and WebSocket route paths) that stay open besides the default allowlist. */
|
|
36
36
|
readonly allowedWritePaths?: readonly string[];
|
|
37
37
|
};
|
|
38
38
|
export declare const DEMO_MODE_PATH = "/demo-mode";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isWebSocketUpgradeRequest, } from "@cosmicdrift/kumiko-framework/api";
|
|
1
2
|
export const DEMO_MODE_PATH = "/demo-mode";
|
|
2
3
|
export const DEMO_READ_ONLY_ERROR_CODE = "demo_read_only";
|
|
3
4
|
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
@@ -52,7 +53,9 @@ export function withDemoReadOnlyFetch(fetchHandler, demoMode) {
|
|
|
52
53
|
...(demoMode.allowedWritePaths ?? []),
|
|
53
54
|
]);
|
|
54
55
|
return async (req, arg) => {
|
|
55
|
-
|
|
56
|
+
// A socket carries writes after the upgrade (route `write` dep), outside this method gate.
|
|
57
|
+
const mutates = MUTATING_METHODS.has(req.method) || isWebSocketUpgradeRequest(req);
|
|
58
|
+
if (mutates && !allowed.has(new URL(req.url).pathname)) {
|
|
56
59
|
return Response.json({
|
|
57
60
|
isSuccess: false,
|
|
58
61
|
error: {
|
|
@@ -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 ?? []) {
|
|
@@ -24,6 +24,7 @@ export type BootOptionFields = {
|
|
|
24
24
|
/** With `runSingleInstance: false`: the worker process mounts the same mail / fileStorage
|
|
25
25
|
* features (its own boot options or by hand), which silences the boot warning. */
|
|
26
26
|
readonly workerMountsFeatures?: boolean;
|
|
27
|
+
readonly appOverrides?: object;
|
|
27
28
|
readonly auth?: {
|
|
28
29
|
readonly admin?: unknown;
|
|
29
30
|
readonly mail?: {
|