@cosmicdrift/kumiko-server-runtime 0.374.0 → 0.377.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 (35) hide show
  1. package/dist/boot/env-gate.d.ts +23 -0
  2. package/dist/boot/env-gate.js +136 -0
  3. package/dist/boot/worker-boot-core.d.ts +3 -0
  4. package/dist/boot/worker-boot-core.js +10 -2
  5. package/dist/boot-options/demo-mode.d.ts +7 -1
  6. package/dist/boot-options/demo-mode.js +5 -2
  7. package/dist/boot-options/feature-toggles-option.d.ts +19 -0
  8. package/dist/boot-options/feature-toggles-option.js +25 -0
  9. package/dist/boot-options/file-storage-option.d.ts +11 -2
  10. package/dist/boot-options/file-storage-option.js +48 -9
  11. package/dist/boot-options/mail-option.d.ts +8 -3
  12. package/dist/boot-options/mail-option.js +21 -6
  13. package/dist/boot-options/option-env-schema.js +30 -9
  14. package/dist/boot-options/resolve-boot-options.d.ts +9 -1
  15. package/dist/boot-options/resolve-boot-options.js +37 -4
  16. package/dist/boot-options/stack-boot-requirements.d.ts +5 -2
  17. package/dist/boot-options/stack-boot-requirements.js +23 -8
  18. package/dist/build-prod-bundle.d.ts +3 -3
  19. package/dist/build-prod-bundle.js +5 -5
  20. package/dist/extra-routes-deps.d.ts +5 -0
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.js +3 -0
  23. package/dist/renderer-web-fonts.js +1 -1
  24. package/dist/request-bound-system-query.d.ts +1 -1
  25. package/dist/resolve-tailwind-cli.d.ts +1 -1
  26. package/dist/resolve-tailwind-cli.js +1 -1
  27. package/dist/run-prod-app-boot-context.d.ts +4 -0
  28. package/dist/run-prod-app-boot-context.js +12 -5
  29. package/dist/run-prod-app-static-files.js +1 -1
  30. package/dist/run-prod-app.d.ts +22 -12
  31. package/dist/run-prod-app.js +45 -144
  32. package/dist/run-worker-app.d.ts +23 -4
  33. package/dist/run-worker-app.js +51 -5
  34. package/package.json +7 -8
  35. package/src/changes.json +51 -0
@@ -44,17 +44,14 @@ import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-ema
44
44
  import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
45
45
  import { AUTH_MFA_FEATURE, AuthMfaHandlers, bindMfaTokenSecretsFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
46
46
  import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
47
- import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-features/secrets";
48
47
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
49
48
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
50
49
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
51
50
  import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
52
- import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
51
+ import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
53
52
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
54
53
  import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
55
54
  import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
56
- import { inspectEnv, KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
57
- import { DRY_RUN_ENV_VAR, parseDryRunEnvMode, renderDryRun, renderEnvInspection, } from "@cosmicdrift/kumiko-framework/env/dry-run";
58
55
  import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
59
56
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
60
57
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
@@ -66,11 +63,11 @@ import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
66
63
  import { Redis } from "ioredis";
67
64
  import { applyBootSeeds } from "./boot/apply-boot-seeds.js";
68
65
  import { resolveBootCrypto } from "./boot/boot-crypto.js";
66
+ import { runEnvGate } from "./boot/env-gate.js";
69
67
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
70
68
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
71
69
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
72
70
  import { waitForDatabase, waitUntilDatabaseReachable } from "./boot/wait-for-database.js";
73
- import { withBootOptionEnvBlocks } from "./boot-options/option-env-schema.js";
74
71
  import { installShutdownSignals } from "./boot-options/process-signals.js";
75
72
  import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
76
73
  import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
@@ -88,21 +85,11 @@ import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.
88
85
  import { assertSessionBootInvariants } from "./session-boot-gate.js";
89
86
  import { shouldWireProdSessions } from "./session-wiring.js";
90
87
  import { stripNoRouteMatchHeader } from "./try-hono-first.js";
88
+ export { parseRunMode } from "./boot/env-gate.js";
91
89
  export { buildBunServeOptions } from "./bun-serve-options.js";
92
90
  export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
93
91
  export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
94
92
  export { staticCachePolicy } from "./run-prod-app-static-files.js";
95
- // runProdApp reads these itself, so the boot dry run lists them even when
96
- // the app schema leaves them out (JWT_SECRET usually comes from auth-email-password).
97
- const RUNTIME_REQUIRED_ENV_KEYS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET"];
98
- function inspectUndeclaredRuntimeEnv(declaredShape, env) {
99
- return RUNTIME_REQUIRED_ENV_KEYS.filter((name) => !(name in declaredShape)).map((name) => ({
100
- name,
101
- klass: "required",
102
- source: "server-runtime",
103
- status: env[name] ? "set" : "missing",
104
- }));
105
- }
106
93
  // Strict env-var read. Throws with a clear hint when missing — better
107
94
  // than discovering a Postgres-connection-refused 30s into the boot.
108
95
  // `src` defaults to process.env but is threaded from the caller's envSource
@@ -124,25 +111,6 @@ function readEnv(name, src = process.env) {
124
111
  const value = src[name];
125
112
  return value === undefined || value === "" ? undefined : value;
126
113
  }
127
- export function parseRunMode(raw) {
128
- const mode = parseDryRunEnvMode(raw);
129
- if (mode !== undefined)
130
- return mode;
131
- if (raw) {
132
- // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
133
- console.warn(`[runProdApp] ${DRY_RUN_ENV_VAR}="${raw}" unrecognized ` +
134
- `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`);
135
- }
136
- return null;
137
- }
138
- function isRenderMode(mode) {
139
- return mode !== null && mode !== "boot";
140
- }
141
- function defaultBootErrorReporter(err) {
142
- // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
143
- console.error(err.format());
144
- process.exit(1);
145
- }
146
114
  // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
147
115
  // was passed (= test-mode). The handle is intentionally inert — listen()
148
116
  // and stop() are no-ops; tests inspect the dry-run console output and
@@ -176,19 +144,6 @@ function warnLegacyJwtSecretOnce() {
176
144
  "that is no longer present in any in-flight token (see resolveAuthMail / " +
177
145
  "loadJwtSecretOrKeyring).");
178
146
  }
179
- // JS callers and apps built against older typings can still omit it.
180
- function requireEnvSchema(envSchema) {
181
- if (envSchema === undefined) {
182
- throw new KumikoBootError([
183
- {
184
- name: "envSchema",
185
- kind: "missing",
186
- message: "runProdApp needs envSchema. Pass composeEnvSchema({ core: frameworkCoreEnvSchema, features, extend }): composeEnvSchema from @cosmicdrift/kumiko-framework/env, frameworkCoreEnvSchema from @cosmicdrift/kumiko-dev-server/env-schema, features the same list as runProdApp's.",
187
- },
188
- ]);
189
- }
190
- return envSchema;
191
- }
192
147
  export async function runProdApp(options) {
193
148
  assertSeedModeNotInProduction();
194
149
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
@@ -208,88 +163,10 @@ export async function runProdApp(options) {
208
163
  // at parse-time before the polyfill loads. Plain strings + .regex /
209
164
  // .min / .email / .url cover every env-var shape we've actually
210
165
  // needed in 9.1's audit (37 references, 25 distinct vars).
211
- const rawEnvSource = options.envSource ?? process.env;
212
- const runMode = parseRunMode(rawEnvSource[DRY_RUN_ENV_VAR]);
213
- const composedEnvSchema = withBootOptionEnvBlocks(requireEnvSchema(options.envSchema), options);
214
- const effectiveEnvSchema = options.masterKey
215
- ? withOptionalEnvKeys(composedEnvSchema.schema, SECRETS_MASTER_KEK_ENV_KEYS)
216
- : composedEnvSchema.schema;
217
- const reportBootError = (err) => {
218
- const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
219
- reporter(err);
220
- };
221
- const validateEnv = (source) => {
222
- try {
223
- parseEnv(effectiveEnvSchema, source, {
224
- sources: composedEnvSchema.sources,
225
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
226
- });
227
- }
228
- catch (err) {
229
- if (err instanceof KumikoBootError)
230
- reportBootError(err);
231
- throw err;
232
- }
233
- };
234
- if (isRenderMode(runMode)) {
235
- // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
236
- console.log(renderDryRun(composedEnvSchema, runMode, {
237
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
238
- sources: composedEnvSchema.sources,
239
- }));
240
- // Tests inject envSource and want a return-value, not exit. Detecting
241
- // "this is a test" via envSource is brittle; instead exit when running
242
- // against the real process.env (the deploy-flow), return otherwise.
243
- if (options.envSource === undefined) {
244
- process.exit(0);
245
- }
166
+ const envGate = await runEnvGate(options, "runProdApp");
167
+ if (envGate.kind === "stopped")
246
168
  return makeDryRunHandle();
247
- }
248
- if (runMode === "boot") {
249
- const inspection = [
250
- ...inspectEnv(effectiveEnvSchema, rawEnvSource, { sources: composedEnvSchema.sources }),
251
- ...inspectUndeclaredRuntimeEnv(effectiveEnvSchema.shape, rawEnvSource),
252
- ];
253
- // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
254
- console.log(renderEnvInspection(inspection));
255
- if (inspection.some((row) => row.status === "invalid")) {
256
- // Missing rows go into the same report: the run fails anyway, so the
257
- // operator sees every variable to fix in one pass.
258
- reportBootError(new KumikoBootError(inspection.flatMap((row) => row.status === "invalid" || row.status === "missing"
259
- ? [
260
- {
261
- name: row.name,
262
- kind: row.status,
263
- message: row.message ?? (row.status === "missing" ? "missing" : "invalid value"),
264
- source: row.source,
265
- },
266
- ]
267
- : [])));
268
- return makeDryRunHandle();
269
- }
270
- // A build pipeline has no deploy secrets: the inventory is the result,
271
- // and the wiring check below needs every value, so it stops here.
272
- if (inspection.some((row) => row.status === "missing")) {
273
- if (options.envSource === undefined) {
274
- process.exit(0);
275
- }
276
- return makeDryRunHandle();
277
- }
278
- }
279
- // boot-mode with a complete env AND normal boot run the full validation,
280
- // including object-level refinements the per-field check skips.
281
- validateEnv(rawEnvSource);
282
- // Slots the schema marks `kms` are decrypted once here so every consumer
283
- // below (boot probe, master-key keyring, config) sees the same resolved env.
284
- const decryptedEnvSource = await resolvePlatformKeks(rawEnvSource, {
285
- slots: kmsSlotsOf(composedEnvSchema.schema),
286
- logPrefix: "[runProdApp]",
287
- });
288
- // The first parse relaxes ciphertext-only slots, so their decrypted
289
- // plaintext never met the slot's own validators (.regex/.min/.refine).
290
- if (kmsSlotsOf(composedEnvSchema.schema).some((slot) => rawEnvSource[slot] === undefined && decryptedEnvSource[slot] !== undefined)) {
291
- validateEnv(decryptedEnvSource);
292
- }
169
+ const { runMode, decryptedEnvSource } = envGate;
293
170
  // Stacks whose boot options are missing and the declarative boot options
294
171
  // (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
295
172
  // the env so an explicitly set variable wins.
@@ -432,9 +309,8 @@ export async function runProdApp(options) {
432
309
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
433
310
  await waitForDatabase(db, { processName: "runProdApp" });
434
311
  // Sprint-8a Tier-Composition auto-wire: scan features for a
435
- // tenantTierResolver-extension. If found AND user didn't supply own
436
- // effectiveFeatures, build the resolver here (db + registry are
437
- // available) before the dispatcher is constructed. App-Author sees
312
+ // tenantTierResolver-extension. If found, build the resolver here (db +
313
+ // registry are available) before the dispatcher is constructed. App-Author sees
438
314
  // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
439
315
  // One bus per process: the tier resolver, the dispatcher and the file-provider
440
316
  // resolver all subscribe to it (invalidation across the API pods).
@@ -442,16 +318,23 @@ export async function runProdApp(options) {
442
318
  redisUrl,
443
319
  clientOptions: redisClientOptionsFromEnv(envSource),
444
320
  });
445
- let resolvedEffectiveFeatures = options.effectiveFeatures;
446
- if (resolvedEffectiveFeatures === undefined) {
447
- const tierResolverUsage = findTierResolverUsage(features);
448
- if (tierResolverUsage) {
449
- resolvedEffectiveFeatures = await requireTierResolverPlugin(tierResolverUsage).build({
450
- db,
451
- registry,
452
- cacheSync,
453
- });
454
- }
321
+ let resolvedEffectiveFeatures;
322
+ const tierResolverUsage = findTierResolverUsage(features);
323
+ if (tierResolverUsage) {
324
+ resolvedEffectiveFeatures = await requireTierResolverPlugin(tierResolverUsage).build({
325
+ db,
326
+ registry,
327
+ cacheSync,
328
+ });
329
+ }
330
+ if (bootOptionWiring.featureToggles) {
331
+ const bound = await bootOptionWiring.featureToggles.bind({
332
+ db,
333
+ registry,
334
+ cacheSync,
335
+ tierResolver: resolvedEffectiveFeatures,
336
+ });
337
+ resolvedEffectiveFeatures = bound.resolver;
455
338
  }
456
339
  // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
457
340
  // apply` läuft als Deploy-Step VOR dem Container-Rollout. Boot prüft nur:
@@ -502,7 +385,15 @@ export async function runProdApp(options) {
502
385
  // wins), which is exactly how fw#2630's cross-replica fanout fix shipped
503
386
  // without ever taking effect in production.
504
387
  const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
505
- const deps = { db, redis, registry, sseBroker, env: envSource };
388
+ const deps = {
389
+ db,
390
+ redis,
391
+ registry,
392
+ sseBroker,
393
+ env: envSource,
394
+ ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
395
+ ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
396
+ };
506
397
  const resolvedExtraContext = typeof options.extraContext === "function"
507
398
  ? options.extraContext(deps)
508
399
  : (options.extraContext ?? {});
@@ -534,6 +425,7 @@ export async function runProdApp(options) {
534
425
  ...(bootKms.kms && { kms: bootKms.kms }),
535
426
  ...(bootLogger && { log: bootLogger }),
536
427
  ...(unsubscribeLinks && { unsubscribeLinks }),
428
+ ...(options.appOverrides && { appOverrides: options.appOverrides }),
537
429
  });
538
430
  const extraContext = {
539
431
  ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
@@ -776,6 +668,8 @@ export async function runProdApp(options) {
776
668
  db,
777
669
  redis,
778
670
  registry,
671
+ ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
672
+ ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
779
673
  metricsFor: createMetricsForFactory(entrypoint.observability.meter),
780
674
  dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
781
675
  dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
@@ -820,7 +714,14 @@ export async function runProdApp(options) {
820
714
  if (seedDispatcher && seedContext) {
821
715
  const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
822
716
  for (const seed of customSeeds) {
823
- await seed({ db, registry, dispatcher: seedDispatcher, createFileRef });
717
+ await seed({
718
+ db,
719
+ registry,
720
+ dispatcher: seedDispatcher,
721
+ createFileRef,
722
+ ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
723
+ ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
724
+ });
824
725
  }
825
726
  }
826
727
  // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
@@ -1,13 +1,18 @@
1
1
  import type { ServerOptions } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
- import type { EffectiveFeaturesResolver, FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
6
+ import type { ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
6
7
  import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
7
8
  import { type ObservabilityOptions, type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
8
9
  import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
9
10
  import type { Redis } from "ioredis";
10
11
  import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.ts";
12
+ import type { BillingBootOption } from "./boot-options/billing-option.ts";
13
+ import type { FileStorageBootOption } from "./boot-options/file-storage-option.ts";
14
+ import type { KmsMode } from "./boot-options/kms-mode.ts";
15
+ import type { MailBootOption } from "./boot-options/mail-option.ts";
11
16
  import { type SystemWireDeps } from "./extra-routes-deps.ts";
12
17
  export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
13
18
  export type WorkerDeps = {
@@ -24,7 +29,7 @@ export type WorkerWireDeps = SystemWireDeps & {
24
29
  readonly lifecycle: WorkerEntrypoint["lifecycle"];
25
30
  /** The worker's ctx.secrets — what `createInboundMailSupervisor` needs as
26
31
  * `providerCtx.secrets` (the IMAP provider reads credentials through it,
27
- * with `_userId` set to a system identity for the audit trail). Undefined
32
+ * with `auditUserId` set to a system identity for the audit trail). Undefined
28
33
  * when the secrets feature is not mounted or no master key is available. */
29
34
  readonly secrets?: SecretsContext;
30
35
  };
@@ -79,8 +84,22 @@ export type RunWorkerAppOptions = {
79
84
  * `entrypoint.start()` — the hook itself is responsible for starting
80
85
  * its component and registering a shutdown hook on `lifecycle`. */
81
86
  readonly wireComponents?: (deps: WorkerWireDeps) => Promise<void> | void;
82
- /** Feature-toggle resolver (see RunProdAppOptions["effectiveFeatures"]). */
83
- readonly effectiveFeatures?: EffectiveFeaturesResolver;
87
+ /** Same as RunProdAppOptions["mail"]: mounts channel-email so the worker delivers what the API queues. */
88
+ readonly mail?: MailBootOption;
89
+ /** Same as RunProdAppOptions["fileStorage"]: mounts the file providers the worker's file jobs use. */
90
+ readonly fileStorage?: FileStorageBootOption;
91
+ /** Same as RunProdAppOptions["kmsMode"]; cannot be combined with `kms` / `blindIndexKey` / `allowPlaintextPii`. */
92
+ readonly kmsMode?: KmsMode;
93
+ /** Same as RunProdAppOptions["billing"]: mounts subscription-stripe so the registry matches the API. The webhook route belongs to the API process. */
94
+ readonly billing?: BillingBootOption;
95
+ /** Same as RunProdAppOptions["featureToggles"]: mounts feature-toggles and layers the operator switches over the tier resolver. */
96
+ readonly featureToggles?: boolean;
97
+ /** Composed env schema (see RunProdAppOptions["envSchema"]). With it the worker validates its env,
98
+ * derives the KMS slots from it (`kmsSlots` is ignored) and understands every `KUMIKO_DRY_RUN_ENV`
99
+ * mode. Without it only `KUMIKO_DRY_RUN_ENV=boot` is available. */
100
+ readonly envSchema?: ComposedEnvSchema;
101
+ readonly pulumiPrefix?: string;
102
+ readonly bootErrorReporter?: (err: KumikoBootError) => void;
84
103
  /** Override `process.env` for env-validation (see
85
104
  * RunProdAppOptions["envSource"]). */
86
105
  readonly envSource?: Record<string, string | undefined>;
@@ -16,10 +16,13 @@
16
16
  // DATABASE_URL, REDIS_URL, JWT_SECRET — PORT is not needed.
17
17
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
18
18
  import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
19
+ import { runEnvGate } from "./boot/env-gate.js";
19
20
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
20
21
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
21
- import { bootWorkerProcess, resolveWorkerEnvSource } from "./boot/worker-boot-core.js";
22
+ import { bootWorkerProcess, resolveWorkerEnvSource, } from "./boot/worker-boot-core.js";
22
23
  import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
24
+ import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
25
+ import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
23
26
  import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
24
27
  function makeBootModeHandle() {
25
28
  return {
@@ -29,14 +32,53 @@ function makeBootModeHandle() {
29
32
  stop: async () => { },
30
33
  };
31
34
  }
35
+ // undefined: a dry-run mode already produced its output and the worker must not boot.
36
+ async function resolveGatedEnvSource(options) {
37
+ if (!options.envSchema)
38
+ return resolveWorkerEnvSource(options, "runWorkerApp");
39
+ const envGate = await runEnvGate({ ...options, envSchema: options.envSchema }, "runWorkerApp");
40
+ return envGate.kind === "stopped" ? undefined : envGate.decryptedEnvSource;
41
+ }
42
+ // The API enforces the same requirement; a worker that skipped it would keep running
43
+ // jobs and event handlers of features an operator switched off.
44
+ function assertWorkerStackRequirements(options) {
45
+ assertStackBootRequirements({ features: options.features, ...(options.featureToggles && { featureToggles: true }) }, ["featureToggles"]);
46
+ }
32
47
  export async function runWorkerApp(options) {
33
48
  assertSeedModeNotInProduction();
34
- const envSource = await resolveWorkerEnvSource(options, "runWorkerApp");
49
+ const resolvedEnvSource = await resolveGatedEnvSource(options);
50
+ if (resolvedEnvSource === undefined)
51
+ return makeBootModeHandle();
35
52
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
36
53
  console.log("[runWorkerApp] booting Kumiko worker…");
37
- const boot = await bootWorkerProcess(options, envSource, { processName: "runWorkerApp" });
38
- if (boot.kind === "dry-run")
54
+ assertWorkerStackRequirements(options);
55
+ const wiring = await resolveBootOptionWiring(options, resolvedEnvSource);
56
+ const envSource = Object.keys(wiring.envDefaults).length > 0
57
+ ? { ...wiring.envDefaults, ...resolvedEnvSource }
58
+ : resolvedEnvSource;
59
+ let boot;
60
+ try {
61
+ boot = await bootWorkerProcess({
62
+ ...options,
63
+ features: [...options.features, ...wiring.features],
64
+ ...(wiring.kms.kms && { kms: wiring.kms.kms }),
65
+ ...(wiring.kms.blindIndexKey !== undefined && { blindIndexKey: wiring.kms.blindIndexKey }),
66
+ ...(wiring.kms.allowPlaintextPii !== undefined && {
67
+ allowPlaintextPii: wiring.kms.allowPlaintextPii,
68
+ }),
69
+ }, envSource, {
70
+ processName: "runWorkerApp",
71
+ ...(wiring.featureToggles && { featureToggles: wiring.featureToggles }),
72
+ });
73
+ }
74
+ catch (error) {
75
+ await wiring.kms.close().catch(() => { });
76
+ throw error;
77
+ }
78
+ if (boot.kind === "dry-run") {
79
+ await wiring.kms.close();
39
80
  return makeBootModeHandle();
81
+ }
40
82
  const { db, redis, registry, entrypoint } = boot;
41
83
  // Metrics first: a port clash must fail the boot before start() begins
42
84
  // consuming jobs/events. boot.close() runs the "metrics-server" shutdown
@@ -52,11 +94,15 @@ export async function runWorkerApp(options) {
52
94
  // The boot failure is the actionable error; a close failure on a
53
95
  // half-started worker must not replace it.
54
96
  await boot.close().catch(() => { });
97
+ await wiring.kms.close().catch(() => { });
55
98
  throw error;
56
99
  }
57
100
  const handle = {
58
101
  entrypoint,
59
- stop: boot.close,
102
+ stop: async () => {
103
+ await boot.close();
104
+ await wiring.kms.close();
105
+ },
60
106
  ...(metricsServer && { metricsServer }),
61
107
  };
62
108
  await recordAppStartedOnBoot({ db, envSource });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.374.0",
3
+ "version": "0.377.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -88,19 +88,18 @@
88
88
  }
89
89
  },
90
90
  "dependencies": {
91
- "@cosmicdrift/kumiko-bundled-features": "0.374.0",
92
- "@cosmicdrift/kumiko-framework": "0.374.0",
93
- "@cosmicdrift/kumiko-headless": "0.374.0",
94
- "@cosmicdrift/kumiko-renderer-mail-html": "0.374.0",
95
- "@cosmicdrift/kumiko-renderer-web": "0.374.0",
96
- "@cosmicdrift/kumiko-types": "0.374.0",
91
+ "@cosmicdrift/kumiko-bundled-features": "0.377.0",
92
+ "@cosmicdrift/kumiko-framework": "0.377.0",
93
+ "@cosmicdrift/kumiko-renderer": "0.377.0",
94
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.377.0",
95
+ "@cosmicdrift/kumiko-types": "0.377.0",
97
96
  "bun-types": "^1.3.13",
98
97
  "ioredis": "^5.10.1",
99
98
  "temporal-polyfill": "^0.3.2",
100
99
  "zod": "^4.4.3"
101
100
  },
102
101
  "devDependencies": {
103
- "@cosmicdrift/kumiko-testing": "0.374.0"
102
+ "@cosmicdrift/kumiko-testing": "0.377.0"
104
103
  },
105
104
  "publishConfig": {
106
105
  "registry": "https://registry.npmjs.org",
package/src/changes.json CHANGED
@@ -1,4 +1,55 @@
1
1
  [
2
+ {
3
+ "version": "0.376.0",
4
+ "type": "breaking",
5
+ "title": "effectiveFeatures is gone from runProdApp, runDevApp and runWorkerApp",
6
+ "detail": "The effectiveFeatures option of the boot functions is removed. Global operator switches come from the featureToggles option, tier gating from the tier-engine feature, which the boot functions wire themselves.",
7
+ "migration": "Replace a hand-built effectiveFeatures resolver with featureToggles true (global switches) and mount createTierEngineFeature for tier gating. A custom resolver that did neither has no replacement on the boot functions; wire it through createKumikoServer or setupTestStack in tests. The boot functions now abort when the option is still passed, because an ignored option would drop an operator kill-switch silently; the same goes for a feature-toggles feature in features without the featureToggles option, or with it set (the option mounts the feature)."
8
+ },
9
+ {
10
+ "version": "0.376.0",
11
+ "type": "breaking",
12
+ "title": "The default mail resolver only reaches members of the notifying tenant",
13
+ "detail": "The default resolveEmail of the mail boot option returns an address for a notification from a tenant other than the system tenant only if the user is a member of that tenant or holds the SystemAdmin role in the stored user row. Everyone else resolves to null and the mail is skipped. The new option mail.recipientScope takes \"membership\" (default) or \"any\", which restores the old behaviour of reaching every user. EmailChannelOptions resolveEmail now receives the tenant database as TenantDb instead of unknown.",
14
+ "migration": "Apps that notify users outside the notifying tenant on purpose, for example merchants reached from a SystemAdmin context, set mail recipientScope to \"any\" or keep their own resolveEmail. Apps whose notifications go to members of the active tenant need no change. Known consumers on the default: offlot-app (main.ts) reaches merchants and SystemAdmins across tenants and needs recipientScope any; publicstatus (main.ts), show-pony, solon (main.ts) and kumiko-studio (main.ts) take the default, which fits notifications to members of the active tenant. money-horse and phronexsis keep their own resolveEmail. Per-app resolvers that only copied the membership check can be deleted. A custom resolveEmail that cast ctx.db can drop the cast."
15
+ },
16
+ {
17
+ "version": "0.376.0",
18
+ "type": "improvement",
19
+ "title": "runDevApp and runWorkerApp take the declarative boot options",
20
+ "detail": "mail, fileStorage, kmsMode, billing, seeds, demoMode, envSchema and featureToggles work in runDevApp, and mail, fileStorage, kmsMode, billing, featureToggles and envSchema in runWorkerApp, through the same resolve functions as runProdApp. The worker honours KUMIKO_DRY_RUN_ENV with an env schema. A stack that sets runSingleInstance false only warns when no worker mounts the features.",
21
+ "migration": "RunDevAppOptions.seeds is now `readonly SeedFn[] | RunDevAppSeedsConfig`. Code that spreads `seeds` from an object typed as RunDevAppOptions no longer type-checks; declare that object with `satisfies RunDevAppOptions` so the array type survives, or build the seed list separately. An app that lists createFeatureTogglesFeature() in the features passed to runDevApp or runProdApp removes it there and sets `featureToggles: true`."
22
+ },
23
+ {
24
+ "version": "0.376.0",
25
+ "type": "improvement",
26
+ "title": "featureToggles, appOverrides and secrets in boot deps",
27
+ "detail": "featureToggles true mounts feature-toggles and layers the global switches over the tier resolver in runProdApp, runDevApp and runWorkerApp. appOverrides sets tenant-model, branding and seo defaults. masterKeyProvider and configEncryption reach extraContext factories, system wiring and seed functions, so an app no longer resolves its KEK a second time."
28
+ },
29
+ {
30
+ "version": "0.376.0",
31
+ "type": "improvement",
32
+ "title": "Boot errors for removed and half-wired feature toggles",
33
+ "detail": "runProdApp, runDevApp and runWorkerApp abort when the removed effectiveFeatures option is still passed, and when feature-toggles sits in features without the featureToggles option. runWorkerApp now enforces a stack that expects featureToggles, so a worker cannot keep running jobs and event handlers of features an operator switched off."
34
+ },
35
+ {
36
+ "version": "0.376.0",
37
+ "type": "improvement",
38
+ "title": "Named S3 provider names are validated at boot",
39
+ "detail": "fileStorage.providers refuses the names s3-env, s3 and inmemory, which the bundled providers register, and a name listed twice. A tenant admin can still select any mounted provider through the file-foundation provider config, so name a provider only if every tenant may use its bucket."
40
+ },
41
+ {
42
+ "version": "0.376.0",
43
+ "type": "improvement",
44
+ "title": "Named S3 providers and a corrected SMTP_HOST description",
45
+ "detail": "fileStorage.providers takes entries with name, envPrefix and lazy, each validated through createS3EnvSchema with its own prefix. The SMTP_HOST description in the env schema of mail smtp-env now says the variable is required."
46
+ },
47
+ {
48
+ "version": "0.376.0",
49
+ "type": "improvement",
50
+ "title": "Demo-mode accounts can carry a label",
51
+ "detail": "demoMode.notice.accounts entries may be { role, label? } next to a bare role; the GET /demo-mode payload then lists { role, label?, email, password } so the banner can name each login. Bare roles behave as before."
52
+ },
2
53
  {
3
54
  "version": "0.372.0",
4
55
  "type": "fix",