@cosmicdrift/kumiko-server-runtime 0.369.0 → 0.370.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 (42) hide show
  1. package/dist/boot/boot-crypto.js +17 -11
  2. package/dist/boot/worker-boot-core.d.ts +1 -1
  3. package/dist/boot-options/billing-option.d.ts +34 -0
  4. package/dist/boot-options/billing-option.js +35 -0
  5. package/dist/boot-options/demo-mode.d.ts +51 -0
  6. package/dist/boot-options/demo-mode.js +64 -0
  7. package/dist/boot-options/file-storage-option.d.ts +30 -0
  8. package/dist/boot-options/file-storage-option.js +52 -0
  9. package/dist/boot-options/kms-mode.d.ts +27 -0
  10. package/dist/boot-options/kms-mode.js +40 -0
  11. package/dist/boot-options/mail-option.d.ts +30 -0
  12. package/dist/boot-options/mail-option.js +44 -0
  13. package/dist/boot-options/option-env-schema.d.ts +7 -0
  14. package/dist/boot-options/option-env-schema.js +113 -0
  15. package/dist/boot-options/process-signals.d.ts +23 -0
  16. package/dist/boot-options/process-signals.js +58 -0
  17. package/dist/boot-options/resolve-boot-options.d.ts +44 -0
  18. package/dist/boot-options/resolve-boot-options.js +88 -0
  19. package/dist/boot-options/seed-shortcuts.d.ts +49 -0
  20. package/dist/boot-options/seed-shortcuts.js +92 -0
  21. package/dist/boot-options/stack-boot-requirements.d.ts +6 -0
  22. package/dist/boot-options/stack-boot-requirements.js +63 -0
  23. package/dist/build-prod-bundle.d.ts +4 -0
  24. package/dist/build-prod-bundle.js +41 -2
  25. package/dist/csp-hashes.d.ts +15 -0
  26. package/dist/csp-hashes.js +46 -0
  27. package/dist/index.d.ts +18 -9
  28. package/dist/index.js +1 -0
  29. package/dist/rate-limit-defaults.d.ts +19 -0
  30. package/dist/rate-limit-defaults.js +13 -0
  31. package/dist/run-bootstrap.d.ts +2 -2
  32. package/dist/run-prod-app-boot-context.d.ts +5 -3
  33. package/dist/run-prod-app-boot-context.js +4 -2
  34. package/dist/run-prod-app-static-files.d.ts +6 -2
  35. package/dist/run-prod-app-static-files.js +22 -1
  36. package/dist/run-prod-app.d.ts +76 -29
  37. package/dist/run-prod-app.js +161 -96
  38. package/dist/run-worker-app.d.ts +2 -2
  39. package/dist/security-headers.d.ts +16 -5
  40. package/dist/security-headers.js +56 -5
  41. package/package.json +18 -9
  42. package/src/changes.json +43 -0
@@ -16,7 +16,7 @@
16
16
  // (gleiche Logik wie runDevApp).
17
17
  //
18
18
  // App-Author schreibt:
19
- // await runProdApp({ features, auth, anonymousAccess, seeds });
19
+ // await runProdApp({ features, envSchema, auth, anonymousAccess, seeds });
20
20
  //
21
21
  // Container/Coolify setzt:
22
22
  // DATABASE_URL=postgresql://...
@@ -42,7 +42,7 @@
42
42
  import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
43
43
  import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
44
44
  import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
45
- import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
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
47
  import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-features/secrets";
48
48
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
@@ -53,8 +53,8 @@ import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, }
53
53
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
54
54
  import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
55
55
  import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
56
- import { KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
57
- import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
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
58
  import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
59
59
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
60
60
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
@@ -70,14 +70,19 @@ import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
70
70
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
71
71
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
72
72
  import { waitForDatabase, waitUntilDatabaseReachable } from "./boot/wait-for-database.js";
73
+ import { withBootOptionEnvBlocks } from "./boot-options/option-env-schema.js";
74
+ import { installShutdownSignals } from "./boot-options/process-signals.js";
75
+ import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
76
+ import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
73
77
  import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
74
78
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
75
79
  import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
76
80
  import { assertPiiBootInvariants } from "./pii-boot-gate.js";
81
+ import { resolveRateLimit } from "./rate-limit-defaults.js";
77
82
  import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
78
83
  import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
79
84
  import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
80
- import { buildStaticFallback } from "./run-prod-app-static-files.js";
85
+ import { buildStaticFallback, resolveCspScriptHashes } from "./run-prod-app-static-files.js";
81
86
  import { withSecurityHeaders } from "./security-headers.js";
82
87
  import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
83
88
  import { assertSessionBootInvariants } from "./session-boot-gate.js";
@@ -87,6 +92,17 @@ export { buildBunServeOptions } from "./bun-serve-options.js";
87
92
  export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
88
93
  export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
89
94
  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
+ }
90
106
  // Strict env-var read. Throws with a clear hint when missing — better
91
107
  // than discovering a Postgres-connection-refused 30s into the boot.
92
108
  // `src` defaults to process.env but is threaded from the caller's envSource
@@ -109,16 +125,14 @@ function readEnv(name, src = process.env) {
109
125
  return value === undefined || value === "" ? undefined : value;
110
126
  }
111
127
  function parseRunMode(raw) {
112
- if (!raw)
113
- return null;
114
- const v = raw.toLowerCase();
115
- if (v === "1" || v === "true" || v === "human")
116
- return "human";
117
- if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot")
118
- return v;
119
- // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
120
- console.warn(`[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
121
- `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`);
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
+ }
122
136
  return null;
123
137
  }
124
138
  function isRenderMode(mode) {
@@ -162,6 +176,19 @@ function warnLegacyJwtSecretOnce() {
162
176
  "that is no longer present in any in-flight token (see resolveAuthMail / " +
163
177
  "loadJwtSecretOrKeyring).");
164
178
  }
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
+ }
165
192
  export async function runProdApp(options) {
166
193
  assertSeedModeNotInProduction();
167
194
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
@@ -171,9 +198,9 @@ export async function runProdApp(options) {
171
198
  // with ALL problems aggregated (not first-fail), before we waste
172
199
  // seconds on a Postgres connection that was never configured.
173
200
  //
174
- // Both code paths are no-ops when no envSchema is passed — Sprint-9
175
- // migration is per-feature additive; pre-migration apps keep the
176
- // legacy `requireEnv("DATABASE_URL")` checks below.
201
+ // The dry-run exits sit before every other env read (KEK decrypt,
202
+ // JWT secret, MFA token secrets), so a build pipeline without deploy
203
+ // secrets can run them.
177
204
  //
178
205
  // Ordering invariant: this step runs BEFORE the Temporal polyfill,
179
206
  // so env-schemas MUST use only Temporal-free Zod types. Don't author
@@ -182,63 +209,96 @@ export async function runProdApp(options) {
182
209
  // .min / .email / .url cover every env-var shape we've actually
183
210
  // needed in 9.1's audit (37 references, 25 distinct vars).
184
211
  const rawEnvSource = options.envSource ?? process.env;
185
- const runMode = parseRunMode(rawEnvSource["KUMIKO_DRY_RUN_ENV"]);
186
- const composedEnvSchema = options.envSchema;
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
+ };
187
221
  const validateEnv = (source) => {
188
- // skip: app declares no env schema, nothing to validate
189
- if (!composedEnvSchema)
190
- return;
191
222
  try {
192
- const schema = options.masterKey
193
- ? withOptionalEnvKeys(composedEnvSchema.schema, SECRETS_MASTER_KEK_ENV_KEYS)
194
- : composedEnvSchema.schema;
195
- parseEnv(schema, source, {
223
+ parseEnv(effectiveEnvSchema, source, {
196
224
  sources: composedEnvSchema.sources,
197
225
  ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
198
226
  });
199
227
  }
200
228
  catch (err) {
201
- if (err instanceof KumikoBootError) {
202
- const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
203
- reporter(err);
204
- }
229
+ if (err instanceof KumikoBootError)
230
+ reportBootError(err);
205
231
  throw err;
206
232
  }
207
233
  };
208
- if (options.envSchema) {
209
- if (isRenderMode(runMode)) {
210
- // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
211
- console.log(renderDryRun(options.envSchema, runMode, {
212
- ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
213
- sources: options.envSchema.sources,
214
- }));
215
- // Tests inject envSource and want a return-value, not exit. Detecting
216
- // "this is a test" via envSource is brittle; instead exit when running
217
- // against the real process.env (the deploy-flow), return otherwise.
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
+ }
246
+ 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")) {
218
273
  if (options.envSource === undefined) {
219
274
  process.exit(0);
220
275
  }
221
276
  return makeDryRunHandle();
222
277
  }
223
- // boot-mode AND normal-boot both run env-validation. boot-mode wants
224
- // a real env-check (all required vars present + schema-valid) before
225
- // it asserts feature-wiring works.
226
- validateEnv(rawEnvSource);
227
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);
228
282
  // Slots the schema marks `kms` are decrypted once here so every consumer
229
283
  // below (boot probe, master-key keyring, config) sees the same resolved env.
230
- const envSource = options.envSchema
231
- ? await resolvePlatformKeks(rawEnvSource, {
232
- slots: kmsSlotsOf(options.envSchema.schema),
233
- logPrefix: "[runProdApp]",
234
- })
235
- : rawEnvSource;
284
+ const decryptedEnvSource = await resolvePlatformKeks(rawEnvSource, {
285
+ slots: kmsSlotsOf(composedEnvSchema.schema),
286
+ logPrefix: "[runProdApp]",
287
+ });
236
288
  // The first parse relaxes ciphertext-only slots, so their decrypted
237
289
  // plaintext never met the slot's own validators (.regex/.min/.refine).
238
- if (options.envSchema &&
239
- kmsSlotsOf(options.envSchema.schema).some((slot) => rawEnvSource[slot] === undefined && envSource[slot] !== undefined)) {
240
- validateEnv(envSource);
290
+ if (kmsSlotsOf(composedEnvSchema.schema).some((slot) => rawEnvSource[slot] === undefined && decryptedEnvSource[slot] !== undefined)) {
291
+ validateEnv(decryptedEnvSource);
241
292
  }
293
+ // Stacks whose boot options are missing and the declarative boot options
294
+ // (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
295
+ // the env so an explicitly set variable wins.
296
+ assertStackBootRequirements(options);
297
+ const bootOptionWiring = await resolveBootOptionWiring(options, decryptedEnvSource);
298
+ const envSource = Object.keys(bootOptionWiring.envDefaults).length > 0
299
+ ? { ...bootOptionWiring.envDefaults, ...decryptedEnvSource }
300
+ : decryptedEnvSource;
301
+ const bootKms = bootOptionWiring.kms;
242
302
  // 1. Polyfill before anything else — feature code references Temporal.
243
303
  const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
244
304
  await ensureTemporalPolyfill();
@@ -275,8 +335,9 @@ export async function runProdApp(options) {
275
335
  // rate limiters collapse into one shared bucket behind a reverse proxy.
276
336
  // biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
277
337
  console.warn(`[runProdApp] trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
278
- "Behind a reverse proxy all clients share one rate-limit bucket.");
338
+ "Behind a reverse proxy all clients share one rate-limit bucket, so the default L1/L2 limits throttle the whole site at once.");
279
339
  }
340
+ const resolvedRateLimit = resolveRateLimit(options.rateLimit);
280
341
  const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
281
342
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
282
343
  console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
@@ -284,7 +345,9 @@ export async function runProdApp(options) {
284
345
  // die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
285
346
  // unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
286
347
  const effectiveAuth = options.auth
287
- ? resolveAuthMail(options.auth, jwtSecret, envSource)
348
+ ? resolveAuthMail(options.auth, jwtSecret, envSource, {
349
+ mailConfigured: bootOptionWiring.mailConfigured,
350
+ })
288
351
  : undefined;
289
352
  const authPublicOrigin = effectiveAuth
290
353
  ? resolveAuthPublicOrigin(effectiveAuth, envSource, (message) => {
@@ -305,7 +368,7 @@ export async function runProdApp(options) {
305
368
  // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
306
369
  // can validate wiring and exit without opening a Postgres/Redis socket.
307
370
  const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
308
- const features = composeFeatures(options.features, {
371
+ const features = composeFeatures([...options.features, ...bootOptionWiring.features], {
309
372
  includeBundled: !!effectiveAuth,
310
373
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
311
374
  });
@@ -318,9 +381,9 @@ export async function runProdApp(options) {
318
381
  warnIfNonUtcServerTimeZone();
319
382
  validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
320
383
  assertPiiBootInvariants(features, {
321
- kms: options.kms,
322
- blindIndexKey: options.blindIndexKey,
323
- allowPlaintextPii: options.allowPlaintextPii,
384
+ kms: bootKms.kms,
385
+ blindIndexKey: bootKms.blindIndexKey,
386
+ allowPlaintextPii: bootKms.allowPlaintextPii,
324
387
  mode: "prod",
325
388
  });
326
389
  const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
@@ -336,6 +399,7 @@ export async function runProdApp(options) {
336
399
  if (runMode === "boot") {
337
400
  // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
338
401
  console.log(`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`);
402
+ await bootKms.close();
339
403
  if (options.envSource === undefined) {
340
404
  process.exit(0);
341
405
  }
@@ -348,8 +412,8 @@ export async function runProdApp(options) {
348
412
  // KMS health gate: an app configured for crypto-shredding must not boot
349
413
  // against an unreachable key store — a silent start would answer every
350
414
  // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
351
- if (options.kms) {
352
- const kms = options.kms;
415
+ if (bootKms.kms) {
416
+ const kms = bootKms.kms;
353
417
  const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
354
418
  processName: "runProdApp",
355
419
  });
@@ -451,12 +515,12 @@ export async function runProdApp(options) {
451
515
  // No adapter only gets this far when assertPiiBootInvariants above let it
452
516
  // through: no PII carriers, or an explicit allowPlaintextPii opt-out. The
453
517
  // engine then stays off and those fields are stored in plaintext.
454
- configurePiiSubjectKms(options.kms);
518
+ configurePiiSubjectKms(bootKms.kms);
455
519
  // Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
456
520
  // Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
457
521
  // jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
458
522
  // silent-broken-auth.
459
- configureBlindIndexKey(options.blindIndexKey);
523
+ configureBlindIndexKey(bootKms.blindIndexKey);
460
524
  const bootLogger = loggerFromExtraContext(resolvedExtraContext);
461
525
  const autoExtraContext = buildBootExtraContext({
462
526
  db,
@@ -467,7 +531,7 @@ export async function runProdApp(options) {
467
531
  sseBroker,
468
532
  redis,
469
533
  crypto: bootCrypto,
470
- ...(options.kms && { kms: options.kms }),
534
+ ...(bootKms.kms && { kms: bootKms.kms }),
471
535
  ...(bootLogger && { log: bootLogger }),
472
536
  ...(unsubscribeLinks && { unsubscribeLinks }),
473
537
  });
@@ -493,6 +557,8 @@ export async function runProdApp(options) {
493
557
  // über die auth-routes.
494
558
  // Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
495
559
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
560
+ if (mfaFeature)
561
+ bindMfaTokenSecretsFromFeature(mfaFeature)?.(jwtSecret, envSource);
496
562
  if (mfaFeature && registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
497
563
  throw new Error(`[runProdApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
498
564
  "createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
@@ -549,11 +615,15 @@ export async function runProdApp(options) {
549
615
  ...(options.observability && { observability: options.observability }),
550
616
  ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
551
617
  ...(options.metrics && { metrics: options.metrics }),
552
- ...(options.rateLimit && { rateLimit: options.rateLimit }),
618
+ ...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
553
619
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
554
620
  ...(options.errorDocs && { errorDocs: options.errorDocs }),
555
- ...((options.extraRoutes || options.unsubscribe) && {
556
- extraRoutes: [...(options.extraRoutes ?? []), ...unsubscribeRoutes],
621
+ ...((options.extraRoutes || options.unsubscribe || bootOptionWiring.extraRoutes.length > 0) && {
622
+ extraRoutes: [
623
+ ...(options.extraRoutes ?? []),
624
+ ...unsubscribeRoutes,
625
+ ...bootOptionWiring.extraRoutes,
626
+ ],
557
627
  }),
558
628
  ...(effectiveAuth && {
559
629
  auth: {
@@ -718,12 +788,16 @@ export async function runProdApp(options) {
718
788
  if (effectiveAuth?.admin) {
719
789
  await seedAdminGuarded(db, effectiveAuth.admin);
720
790
  }
791
+ for (const seed of bootOptionWiring.shortcutSeeds) {
792
+ await seed(db);
793
+ }
721
794
  await applyBootSeeds({
722
795
  registry,
723
796
  db,
724
797
  ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
725
798
  });
726
- const hasSeeds = (options.seeds?.length ?? 0) > 0 || options.seedsDir !== undefined;
799
+ const customSeeds = bootOptionWiring.customSeeds;
800
+ const hasSeeds = customSeeds.length > 0 || options.seedsDir !== undefined;
727
801
  const seedContext = hasSeeds
728
802
  ? withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync)
729
803
  : undefined;
@@ -735,7 +809,7 @@ export async function runProdApp(options) {
735
809
  : undefined;
736
810
  if (seedDispatcher && seedContext) {
737
811
  const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
738
- for (const seed of options.seeds ?? []) {
812
+ for (const seed of customSeeds) {
739
813
  await seed({ db, registry, dispatcher: seedDispatcher, createFileRef });
740
814
  }
741
815
  }
@@ -767,7 +841,8 @@ export async function runProdApp(options) {
767
841
  // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
768
842
  // owns the rest. Tests use this directly; listen() wraps it in
769
843
  // Bun.serve.
770
- const fetchHandler = withSecurityHeaders(options.staticDir
844
+ const cspScriptHashes = await resolveCspScriptHashes(options.staticDir, options.securityHeaders);
845
+ const fetchHandler = withSecurityHeaders(bootOptionWiring.wrapFetch(options.staticDir
771
846
  ? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
772
847
  ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
773
848
  : undefined, entrypoint.dispatcher, trustedProxyHops ?? 0)
@@ -776,12 +851,13 @@ export async function runProdApp(options) {
776
851
  // (and with it tryHonoFirst) entirely. Must strip the router-miss
777
852
  // marker here too, same reason as the /api/* passthrough in
778
853
  // run-prod-app-static-files.ts (see try-hono-first.ts).
779
- async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)), options.securityHeaders);
854
+ async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress))), options.securityHeaders, cspScriptHashes);
780
855
  // 11. Mark lifecycle ready — health/ready flips to 200 after this.
781
856
  entrypoint.lifecycle.markReady();
782
857
  // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
783
- const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders);
858
+ const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders, cspScriptHashes);
784
859
  const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
860
+ let uninstallShutdownSignals;
785
861
  const handle = {
786
862
  entrypoint,
787
863
  fetch: fetchHandler,
@@ -803,29 +879,15 @@ export async function runProdApp(options) {
803
879
  // SIGTERM/SIGINT — graceful shutdown. Only registered when we
804
880
  // actually own a Bun-server, otherwise the test process picks up
805
881
  // signals it shouldn't respond to.
806
- let shuttingDown = false;
807
- const shutdown = async (signal) => {
808
- // skip: shutdown already in progress, avoid double-drain
809
- if (shuttingDown)
810
- return;
811
- shuttingDown = true;
812
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
813
- console.log(`[runProdApp] ${signal} received — draining…`);
814
- try {
815
- await handle.stop();
816
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
817
- console.log("[runProdApp] graceful shutdown complete.");
818
- }
819
- catch (e) {
820
- // biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
821
- console.error("[runProdApp] error during shutdown:", e);
822
- }
823
- finally {
824
- process.exit(0);
825
- }
826
- };
827
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
828
- process.on("SIGINT", () => void shutdown("SIGINT"));
882
+ if (options.signals !== false) {
883
+ uninstallShutdownSignals?.();
884
+ uninstallShutdownSignals = installShutdownSignals({
885
+ stop: () => handle.stop(),
886
+ ...(options.signals?.drainTimeoutMs !== undefined && {
887
+ drainTimeoutMs: options.signals.drainTimeoutMs,
888
+ }),
889
+ });
890
+ }
829
891
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
830
892
  console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
831
893
  },
@@ -836,6 +898,9 @@ export async function runProdApp(options) {
836
898
  await closeDb();
837
899
  await closeOutsideTransactionDb();
838
900
  redis.disconnect();
901
+ await bootKms.close();
902
+ uninstallShutdownSignals?.();
903
+ uninstallShutdownSignals = undefined;
839
904
  },
840
905
  };
841
906
  // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
@@ -7,8 +7,8 @@ import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
7
7
  import { type ObservabilityOptions, type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
8
8
  import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
9
9
  import type { Redis } from "ioredis";
10
- import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.js";
11
- import { type SystemWireDeps } from "./extra-routes-deps.js";
10
+ import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.ts";
11
+ import { type SystemWireDeps } from "./extra-routes-deps.ts";
12
12
  export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
13
13
  export type WorkerDeps = {
14
14
  readonly db: DbConnection;
@@ -11,10 +11,21 @@ export type SecurityHeadersOption = false | {
11
11
  /** `Referrer-Policy` value, or `false` to omit.
12
12
  * Default: `strict-origin-when-cross-origin`. */
13
13
  readonly referrerPolicy?: string | false;
14
- /** `Content-Security-Policy` default for ALL responses. No built-in
15
- * default — a wrong CSP breaks app assets, so it stays opt-in.
14
+ /** `Content-Security-Policy` for ALL responses.
15
+ * - omitted: the built-in default (see `DEFAULT_CSP_DIRECTIVES`) with the
16
+ * inline-script hashes from `dist/csp-hashes.json`.
17
+ * - `false`: no CSP header.
18
+ * - string: replaces the default entirely (no hashes are added).
19
+ * - `{ extend }`: adds sources to the default directives, or new directives.
16
20
  * A per-host CSP from `hostDispatch` wins over this value. */
17
- readonly csp?: string;
21
+ readonly csp?: CspOption;
18
22
  };
19
- export declare function resolveSecurityHeaders(option: SecurityHeadersOption | undefined): ReadonlyArray<readonly [string, string]>;
20
- export declare function withSecurityHeaders<TServeArg = string>(handler: (req: Request, serveArg?: TServeArg) => Response | Promise<Response>, option: SecurityHeadersOption | undefined): (req: Request, serveArg?: TServeArg) => Response | Promise<Response>;
23
+ export type CspExtension = Readonly<Record<string, readonly string[]>>;
24
+ export type CspOption = false | string | {
25
+ readonly extend: CspExtension;
26
+ };
27
+ export declare const DEFAULT_CSP_DIRECTIVES: Readonly<Record<string, readonly string[]>>;
28
+ export declare function isScriptHash(value: unknown): value is string;
29
+ export declare function buildDefaultCsp(scriptHashes: readonly string[], extend?: CspExtension): string;
30
+ export declare function resolveSecurityHeaders(option: SecurityHeadersOption | undefined, scriptHashes?: readonly string[]): ReadonlyArray<readonly [string, string]>;
31
+ export declare function withSecurityHeaders<TServeArg = string>(handler: (req: Request, serveArg?: TServeArg) => Response | Promise<Response>, option: SecurityHeadersOption | undefined, scriptHashes?: readonly string[]): (req: Request, serveArg?: TServeArg) => Response | Promise<Response>;
@@ -1,7 +1,57 @@
1
1
  const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
2
2
  const DEFAULT_FRAME_OPTIONS = "DENY";
3
3
  const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
4
- export function resolveSecurityHeaders(option) {
4
+ export const DEFAULT_CSP_DIRECTIVES = {
5
+ "default-src": ["'self'"],
6
+ "script-src": ["'self'"],
7
+ "style-src": ["'self'", "'unsafe-inline'"],
8
+ "img-src": ["'self'", "data:", "blob:"],
9
+ "connect-src": ["'self'"],
10
+ "frame-ancestors": ["'none'"],
11
+ "base-uri": ["'self'"],
12
+ "form-action": ["'self'"],
13
+ "object-src": ["'none'"],
14
+ };
15
+ const SCRIPT_HASH_PATTERN = /^sha256-[A-Za-z0-9+/]{43}=$/;
16
+ const DIRECTIVE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
17
+ // Separators that would let an extension value end its own
18
+ // directive, start a new one or break out of the header line.
19
+ const FORBIDDEN_SOURCE_CHARS = /[;,\s]/;
20
+ export function isScriptHash(value) {
21
+ return typeof value === "string" && SCRIPT_HASH_PATTERN.test(value);
22
+ }
23
+ function assertValidExtension(extend) {
24
+ for (const [directive, sources] of Object.entries(extend)) {
25
+ if (!DIRECTIVE_NAME_PATTERN.test(directive)) {
26
+ throw new Error(`[security-headers] csp.extend: invalid directive name "${directive}".`);
27
+ }
28
+ for (const source of sources) {
29
+ if (source.length === 0 || FORBIDDEN_SOURCE_CHARS.test(source)) {
30
+ throw new Error(`[security-headers] csp.extend["${directive}"]: source "${source}" must be a single token without whitespace, ";" or ",".`);
31
+ }
32
+ }
33
+ }
34
+ }
35
+ export function buildDefaultCsp(scriptHashes, extend = {}) {
36
+ assertValidExtension(extend);
37
+ const directives = new Map(Object.entries(DEFAULT_CSP_DIRECTIVES).map(([name, sources]) => [name, [...sources]]));
38
+ directives.get("script-src")?.push(...scriptHashes.map((hash) => `'${hash}'`));
39
+ for (const [name, sources] of Object.entries(extend)) {
40
+ const existing = directives.get(name) ?? [];
41
+ // 'none' is only valid as the sole source, so extending it replaces it.
42
+ const base = existing.length === 1 && existing[0] === "'none'" ? [] : existing;
43
+ directives.set(name, [...new Set([...base, ...sources])]);
44
+ }
45
+ return [...directives].map(([name, sources]) => [name, ...sources].join(" ")).join("; ");
46
+ }
47
+ function resolveCsp(option, scriptHashes) {
48
+ if (option === false)
49
+ return null;
50
+ if (typeof option === "string")
51
+ return option.length > 0 ? option : null;
52
+ return buildDefaultCsp(scriptHashes, option?.extend);
53
+ }
54
+ export function resolveSecurityHeaders(option, scriptHashes = []) {
5
55
  if (option === false)
6
56
  return [];
7
57
  const opt = option ?? {};
@@ -17,14 +67,15 @@ export function resolveSecurityHeaders(option) {
17
67
  const referrerPolicy = opt.referrerPolicy ?? DEFAULT_REFERRER_POLICY;
18
68
  if (referrerPolicy !== false)
19
69
  headers.push(["referrer-policy", referrerPolicy]);
20
- if (opt.csp)
21
- headers.push(["content-security-policy", opt.csp]);
70
+ const csp = resolveCsp(opt.csp, scriptHashes);
71
+ if (csp !== null)
72
+ headers.push(["content-security-policy", csp]);
22
73
  return headers;
23
74
  }
24
75
  // Sets each header only when absent so per-response values (e.g. the
25
76
  // per-host CSP from hostDispatch) always win over the runtime default.
26
- export function withSecurityHeaders(handler, option) {
27
- const defaults = resolveSecurityHeaders(option);
77
+ export function withSecurityHeaders(handler, option, scriptHashes = []) {
78
+ const defaults = resolveSecurityHeaders(option, scriptHashes);
28
79
  if (defaults.length === 0)
29
80
  return handler;
30
81
  return async (req, serveArg) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.369.0",
3
+ "version": "0.370.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>",
@@ -81,21 +81,26 @@
81
81
  "./build-prod-bundle": {
82
82
  "types": "./dist/build-prod-bundle.d.ts",
83
83
  "default": "./dist/build-prod-bundle.js"
84
+ },
85
+ "./boot-options/billing": {
86
+ "types": "./dist/boot-options/billing-option.d.ts",
87
+ "default": "./dist/boot-options/billing-option.js"
84
88
  }
85
89
  },
86
90
  "dependencies": {
87
- "@cosmicdrift/kumiko-bundled-features": "0.369.0",
88
- "@cosmicdrift/kumiko-framework": "0.369.0",
89
- "@cosmicdrift/kumiko-headless": "0.369.0",
90
- "@cosmicdrift/kumiko-renderer-mail-html": "0.369.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.369.0",
92
- "@cosmicdrift/kumiko-types": "0.369.0",
91
+ "@cosmicdrift/kumiko-bundled-features": "0.370.0",
92
+ "@cosmicdrift/kumiko-framework": "0.370.0",
93
+ "@cosmicdrift/kumiko-headless": "0.370.0",
94
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.370.0",
95
+ "@cosmicdrift/kumiko-renderer-web": "0.370.0",
96
+ "@cosmicdrift/kumiko-types": "0.370.0",
93
97
  "bun-types": "^1.3.13",
94
98
  "ioredis": "^5.10.1",
95
- "temporal-polyfill": "^0.3.2"
99
+ "temporal-polyfill": "^0.3.2",
100
+ "zod": "^4.4.3"
96
101
  },
97
102
  "devDependencies": {
98
- "@cosmicdrift/kumiko-testing": "0.369.0"
103
+ "@cosmicdrift/kumiko-testing": "0.370.0"
99
104
  },
100
105
  "publishConfig": {
101
106
  "registry": "https://registry.npmjs.org",
@@ -164,6 +169,10 @@
164
169
  "./build-prod-bundle": {
165
170
  "types": "./dist/build-prod-bundle.d.ts",
166
171
  "default": "./dist/build-prod-bundle.js"
172
+ },
173
+ "./boot-options/billing": {
174
+ "types": "./dist/boot-options/billing-option.d.ts",
175
+ "default": "./dist/boot-options/billing-option.js"
167
176
  }
168
177
  }
169
178
  },