@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.
Files changed (38) hide show
  1. package/dist/boot/boot-crypto.js +3 -3
  2. package/dist/boot/shared-boot-steps.d.ts +43 -0
  3. package/dist/boot/shared-boot-steps.js +154 -0
  4. package/dist/boot/wait-for-database.d.ts +3 -0
  5. package/dist/boot/wait-for-database.js +3 -3
  6. package/dist/boot/worker-boot-core.js +18 -38
  7. package/dist/boot-options/billing-option.d.ts +18 -9
  8. package/dist/boot-options/billing-option.js +44 -18
  9. package/dist/boot-options/file-storage-option.js +6 -6
  10. package/dist/boot-options/option-env-schema.js +11 -2
  11. package/dist/boot-options/resolve-boot-options.js +9 -3
  12. package/dist/boot-options/stack-boot-requirements.js +1 -1
  13. package/dist/csp-hashes.js +1 -1
  14. package/dist/fatal-error-handlers.js +7 -0
  15. package/dist/require-env.d.ts +2 -0
  16. package/dist/require-env.js +21 -0
  17. package/dist/run-bootstrap.d.ts +1 -1
  18. package/dist/run-bootstrap.js +1 -1
  19. package/dist/run-prod-app-auth.d.ts +17 -0
  20. package/dist/run-prod-app-auth.js +83 -0
  21. package/dist/run-prod-app-boot-context.js +7 -3
  22. package/dist/run-prod-app-config.d.ts +36 -0
  23. package/dist/run-prod-app-config.js +169 -0
  24. package/dist/run-prod-app-connections.d.ts +16 -0
  25. package/dist/run-prod-app-connections.js +55 -0
  26. package/dist/run-prod-app-entrypoint.d.ts +14 -0
  27. package/dist/run-prod-app-entrypoint.js +147 -0
  28. package/dist/run-prod-app-handle.d.ts +20 -0
  29. package/dist/run-prod-app-handle.js +104 -0
  30. package/dist/run-prod-app-request-context.d.ts +15 -0
  31. package/dist/run-prod-app-request-context.js +103 -0
  32. package/dist/run-prod-app-seeds.d.ts +13 -0
  33. package/dist/run-prod-app-seeds.js +86 -0
  34. package/dist/run-prod-app.d.ts +20 -18
  35. package/dist/run-prod-app.js +46 -726
  36. package/dist/run-worker-app.d.ts +1 -1
  37. package/package.json +15 -7
  38. package/src/changes.json +25 -0
@@ -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}] database not reachable (code ${code}, attempt ${attempt}), retrying in ${waitMs}ms…`);
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, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
9
- import { createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
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 "../run-prod-app.js";
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 { waitForDatabase, waitUntilDatabaseReachable } from "./wait-for-database.js";
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
- const kms = options.kms;
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, close: closeDb } = createDbConnection({
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
- let resolvedEffectiveFeatures = await buildTierEffectiveFeatures(features, db, registry, cacheSync);
134
- if (profile.featureToggles) {
135
- const bound = await profile.featureToggles.bind({
136
- db,
137
- registry,
138
- cacheSync,
139
- tierResolver: resolvedEffectiveFeatures,
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 the webhook route that writes the
6
- * subscription and syncs the tenant's tier (`tier-engine`). Needs
7
- * `billing-foundation`, `tier-engine`, `config` and `secrets` in `features`.
8
- * `STRIPE_API_KEY` / `STRIPE_WEBHOOK_SECRET` from the env are only the
9
- * bridge fallback; the keys normally live in the secrets store.
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
- readonly stripe: {
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"`: Stripe retries, so a failed tier sync gets another attempt. */
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 feature: FeatureDefinition;
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 webhookPath = billing.stripe.webhookPath ?? SUBSCRIPTION_WEBHOOK_PATH;
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
- if (Object.keys(billing.stripe.priceToTier).length === 0) {
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(billing.stripe.priceToTier),
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 { feature, route: tierSync.createWebhookRoute({ path: webhookPath }) };
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
- if (seeds.demoTenant && seeds.demoTenant.id === undefined) {
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" ? S3_REQUIRED_KEYS : [],
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.stripe", [BILLING_FOUNDATION_FEATURE, TIER_ENGINE_FEATURE], "Mount createBillingFoundationFeature() and createTierEngineFeature(), or composeSaasStack from @cosmicdrift/kumiko-dev-server/compose-stacks.");
86
- rejectDoubleMount(mounted, "billing.stripe", SUBSCRIPTION_STRIPE_FEATURE);
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.feature);
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,
@@ -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([^>]*)>([\s\S]*?)<\/script\s*>/gi;
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
  };
@@ -0,0 +1,2 @@
1
+ export declare function requireEnv(name: string, src?: Record<string, string | undefined>, context?: string): string;
2
+ export declare function readEnv(name: string, src?: Record<string, string | undefined>): string | undefined;