@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.
Files changed (61) hide show
  1. package/dist/boot/boot-crypto.js +3 -3
  2. package/dist/boot/shared-boot-steps.d.ts +46 -0
  3. package/dist/boot/shared-boot-steps.js +160 -0
  4. package/dist/boot/wait-for-database.d.ts +5 -0
  5. package/dist/boot/wait-for-database.js +7 -6
  6. package/dist/boot/worker-boot-core.js +24 -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/demo-mode.d.ts +3 -3
  10. package/dist/boot-options/demo-mode.js +4 -1
  11. package/dist/boot-options/file-storage-option.js +6 -6
  12. package/dist/boot-options/option-env-schema.js +11 -2
  13. package/dist/boot-options/resolve-boot-options.d.ts +1 -0
  14. package/dist/boot-options/resolve-boot-options.js +11 -5
  15. package/dist/boot-options/stack-boot-requirements.d.ts +1 -1
  16. package/dist/boot-options/stack-boot-requirements.js +2 -14
  17. package/dist/build-prod-bundle.js +13 -13
  18. package/dist/bundled-lists-table.d.ts +2 -0
  19. package/dist/bundled-lists-table.js +56 -0
  20. package/dist/compose-auth-options.d.ts +16 -0
  21. package/dist/compose-auth-options.js +49 -0
  22. package/dist/compose-features.d.ts +5 -17
  23. package/dist/compose-features.js +6 -53
  24. package/dist/csp-hashes.js +1 -1
  25. package/dist/extra-routes-deps.d.ts +4 -0
  26. package/dist/extra-routes-deps.js +1 -0
  27. package/dist/fatal-error-handlers.js +7 -0
  28. package/dist/files-for-tenant.d.ts +3 -0
  29. package/dist/files-for-tenant.js +10 -0
  30. package/dist/renderer-web-fonts.js +3 -9
  31. package/dist/require-env.d.ts +2 -0
  32. package/dist/require-env.js +21 -0
  33. package/dist/resolve-installed-package.d.ts +10 -0
  34. package/dist/resolve-installed-package.js +32 -0
  35. package/dist/resolve-tailwind-cli.d.ts +3 -8
  36. package/dist/resolve-tailwind-cli.js +8 -25
  37. package/dist/run-bootstrap.d.ts +1 -1
  38. package/dist/run-bootstrap.js +1 -1
  39. package/dist/run-prod-app-auth.d.ts +17 -0
  40. package/dist/run-prod-app-auth.js +83 -0
  41. package/dist/run-prod-app-boot-context.d.ts +2 -0
  42. package/dist/run-prod-app-boot-context.js +15 -7
  43. package/dist/run-prod-app-config.d.ts +36 -0
  44. package/dist/run-prod-app-config.js +169 -0
  45. package/dist/run-prod-app-connections.d.ts +17 -0
  46. package/dist/run-prod-app-connections.js +59 -0
  47. package/dist/run-prod-app-entrypoint.d.ts +14 -0
  48. package/dist/run-prod-app-entrypoint.js +149 -0
  49. package/dist/run-prod-app-handle.d.ts +20 -0
  50. package/dist/run-prod-app-handle.js +105 -0
  51. package/dist/run-prod-app-request-context.d.ts +15 -0
  52. package/dist/run-prod-app-request-context.js +103 -0
  53. package/dist/run-prod-app-seeds.d.ts +13 -0
  54. package/dist/run-prod-app-seeds.js +88 -0
  55. package/dist/run-prod-app-static-files.js +9 -1
  56. package/dist/run-prod-app.d.ts +27 -19
  57. package/dist/run-prod-app.js +47 -727
  58. package/dist/run-worker-app.d.ts +12 -2
  59. package/dist/run-worker-app.js +2 -0
  60. package/package.json +27 -8
  61. package/src/changes.json +86 -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,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
- "CONNECTION_CLOSED",
11
- "CONNECT_TIMEOUT",
12
- "CONNECTION_DESTROYED",
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}] database not reachable (code ${code}, attempt ${attempt}), retrying in ${waitMs}ms…`);
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, 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
@@ -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
- 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
- }
98
+ await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline, options.databaseWait);
107
99
  }
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
- });
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, { processName });
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
- 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
- }
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 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
  }
@@ -1,4 +1,4 @@
1
- import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
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
- if (MUTATING_METHODS.has(req.method) && !allowed.has(new URL(req.url).pathname)) {
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
- 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 ?? []) {
@@ -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?: {