@cosmicdrift/kumiko-dev-server 0.110.0 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.110.0",
3
+ "version": "0.112.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -50,8 +50,8 @@
50
50
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "0.110.0",
54
- "@cosmicdrift/kumiko-framework": "0.110.0",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.112.0",
54
+ "@cosmicdrift/kumiko-framework": "0.112.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -1,6 +1,7 @@
1
1
  import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config";
2
- import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
4
5
 
5
6
  // Single boot-seed entry-point. runDevApp + runProdApp both call this
6
7
  // from their post-stack hook, so the wiring lives in exactly one place
@@ -12,7 +13,7 @@ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
12
13
  export async function applyBootSeeds(deps: {
13
14
  registry: Registry;
14
15
  db: DbConnection;
15
- encryption?: EncryptionProvider;
16
+ cipher?: EnvelopeCipher;
16
17
  }): Promise<void> {
17
- await seedAllConfigValues(deps.registry, deps.db, deps.encryption);
18
+ await seedAllConfigValues(deps.registry, deps.db, deps.cipher);
18
19
  }
@@ -0,0 +1,82 @@
1
+ // One place that turns env/options into the app-wide crypto objects:
2
+ // the MasterKeyProvider (KUMIKO_SECRETS_MASTER_KEY_V<n> keyring) and the
3
+ // EnvelopeCipher for `encrypted: true` config keys. runProdApp and
4
+ // runDevApp both resolve this once and thread it into
5
+ // buildBootExtraContext + applyBootSeeds so resolver, set-handler and
6
+ // seeds share the same cipher instance (and DEK cache).
7
+
8
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
9
+ import {
10
+ createDekCache,
11
+ createEnvelopeCipher,
12
+ createEnvMasterKeyProvider,
13
+ type DekCache,
14
+ type EnvelopeCipher,
15
+ type MasterKeyProvider,
16
+ } from "@cosmicdrift/kumiko-framework/secrets";
17
+
18
+ // Keyring detection without throwing: only build the env provider when at
19
+ // least one versioned KEK is actually set. Covers (a) apps without any
20
+ // encryption — no KEK requirement, and (b) dev with an app-supplied
21
+ // provider in options.masterKey — env stays irrelevant.
22
+ const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
23
+ export function envHasMasterKek(env: Record<string, string | undefined>): boolean {
24
+ return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
25
+ }
26
+
27
+ export type BootCrypto = {
28
+ readonly masterKeyProvider?: MasterKeyProvider;
29
+ // Cipher for encrypted config keys. Present exactly when a master key is
30
+ // available. Decrypts legacy CONFIG_ENCRYPTION_KEY values as fallback
31
+ // until the config re-encrypt job migrated them.
32
+ readonly configCipher?: EnvelopeCipher;
33
+ // Cipher for `encrypted: true` entity fields — same master key, but the
34
+ // legacy fallback reads the pre-envelope ENCRYPTION_KEY format. Identical
35
+ // to configCipher when no ENCRYPTION_KEY is set.
36
+ readonly entityFieldCipher?: EnvelopeCipher;
37
+ readonly dekCache: DekCache;
38
+ };
39
+
40
+ export function resolveBootCrypto(
41
+ envSource: Record<string, string | undefined>,
42
+ masterKeyOverride?: MasterKeyProvider,
43
+ ): BootCrypto {
44
+ const masterKeyProvider =
45
+ masterKeyOverride ??
46
+ (envHasMasterKek(envSource)
47
+ ? createEnvMasterKeyProvider({
48
+ // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
49
+ // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
50
+ env: {
51
+ ...envSource,
52
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
53
+ envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
54
+ },
55
+ })
56
+ : undefined);
57
+
58
+ const dekCache = createDekCache();
59
+ const legacyConfigKey = envSource["CONFIG_ENCRYPTION_KEY"];
60
+ const configCipher = masterKeyProvider
61
+ ? createEnvelopeCipher(masterKeyProvider, {
62
+ dekCache,
63
+ ...(legacyConfigKey ? { legacy: createEncryptionProvider(legacyConfigKey) } : {}),
64
+ })
65
+ : undefined;
66
+
67
+ const legacyEntityKey = envSource["ENCRYPTION_KEY"];
68
+ const entityFieldCipher =
69
+ masterKeyProvider && legacyEntityKey
70
+ ? createEnvelopeCipher(masterKeyProvider, {
71
+ dekCache,
72
+ legacy: createEncryptionProvider(legacyEntityKey),
73
+ })
74
+ : configCipher;
75
+
76
+ return {
77
+ ...(masterKeyProvider && { masterKeyProvider }),
78
+ ...(configCipher && { configCipher }),
79
+ ...(entityFieldCipher && { entityFieldCipher }),
80
+ dekCache,
81
+ };
82
+ }
@@ -37,6 +37,7 @@ import {
37
37
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
38
38
  import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
39
39
  import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
40
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
40
41
  import {
41
42
  collectWriteHandlerQns,
42
43
  createRegistry,
@@ -50,10 +51,11 @@ import {
50
51
  validateAppCustomScreenWriteQns,
51
52
  validateBoot,
52
53
  } from "@cosmicdrift/kumiko-framework/engine";
53
- import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
54
+ import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
54
55
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
55
56
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
56
57
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
58
+ import { resolveBootCrypto } from "./boot/boot-crypto";
57
59
 
58
60
  import { watchAndRegenerate } from "./codegen";
59
61
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
@@ -311,8 +313,17 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
311
313
  // throwaway-Registry hier extrahiert nur die config-Keys, weil der
312
314
  // configResolver vor dem Server-Boot konstruiert wird (stack.registry
313
315
  // gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
316
+ const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
317
+ // App-wide cipher for `encrypted: true` entity fields (symmetrisch zu
318
+ // runProdApp) — executors resolve it lazily.
319
+ configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
314
320
  const cfgExtra = effectiveAuth
315
- ? mergeConfigResolverDefault(options.extraContext, createRegistry(features), envSource)
321
+ ? mergeConfigResolverDefault(
322
+ options.extraContext,
323
+ createRegistry(features),
324
+ envSource,
325
+ bootCrypto.configCipher,
326
+ )
316
327
  : options.extraContext;
317
328
  // Auto-wire textContent (immer) + secrets (feature-gated), symmetrisch zu
318
329
  // runProdApp. Anders als prod existiert die db hier erst im Factory-deps
@@ -327,7 +338,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
327
338
  registry: deps.registry,
328
339
  hasAuth: false,
329
340
  sseBroker: deps.sseBroker,
330
- ...(options.masterKey && { masterKey: options.masterKey }),
341
+ crypto: bootCrypto,
331
342
  });
332
343
  const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
333
344
  return { ...boot, ...base };
@@ -482,7 +493,11 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
482
493
  // Runs before user-supplied seed callbacks so those can read /
483
494
  // override the deploy-defaults. The helper indirection is what
484
495
  // config-seed-boot.integration.ts pins — keep it as a single call.
485
- await applyBootSeeds({ registry: stack.registry, db: stack.db });
496
+ await applyBootSeeds({
497
+ registry: stack.registry,
498
+ db: stack.db,
499
+ ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
500
+ });
486
501
  for (const seed of options.seeds ?? []) {
487
502
  await seed(stack);
488
503
  }
@@ -518,10 +533,12 @@ export function mergeConfigResolverDefault(
518
533
  ctx: CreateKumikoServerOptions["extraContext"],
519
534
  registry: Registry,
520
535
  envSource: Record<string, string | undefined>,
536
+ cipher?: EnvelopeCipher,
521
537
  ): CreateKumikoServerOptions["extraContext"] {
522
538
  const defaults = {
523
539
  configResolver: createConfigResolver({
524
540
  appOverrides: buildEnvConfigOverrides(registry, envSource),
541
+ ...(cipher && { cipher }),
525
542
  }),
526
543
  };
527
544
  // ctx.config wird per-Request aus _configAccessorFactory geminted (siehe
@@ -78,6 +78,7 @@ import {
78
78
  type SseBroker,
79
79
  } from "@cosmicdrift/kumiko-framework/api";
80
80
  import {
81
+ configureEntityFieldEncryption,
81
82
  createDbConnection,
82
83
  type DbConnection,
83
84
  type DbRunner,
@@ -123,13 +124,11 @@ import {
123
124
  createEventDedup,
124
125
  createIdempotencyGuard,
125
126
  } from "@cosmicdrift/kumiko-framework/pipeline";
126
- import {
127
- createEnvMasterKeyProvider,
128
- type MasterKeyProvider,
129
- } from "@cosmicdrift/kumiko-framework/secrets";
127
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
130
128
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
131
129
  import Redis from "ioredis";
132
130
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
131
+ import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
133
132
  import { ASSETS_DIR } from "./build-prod-bundle";
134
133
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
135
134
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
@@ -597,18 +596,13 @@ export function addConfigAccessorFactory<T extends { readonly configResolver?: C
597
596
  // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
598
597
  // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
599
598
  // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
600
- // createEnvMasterKeyProvider nicht wirft. Das deckt zwei Fälle: (a) App ohne
601
- // secrets kein KEK-Zwang; (b) dev mit App-eigenem DEV-KEK in extraContext
602
- // (kein env-KEK) → kein Boot-Crash, die App-explizite secrets-Wiring gewinnt.
599
+ // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
600
+ // (envHasMasterKek, resolveBootCrypto) gemeinsam mit runDevApp.
603
601
  // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
604
602
  // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
605
603
  // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
606
604
  // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
607
605
  // passiert beim Caller (App gewinnt).
608
- const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
609
- function envHasMasterKek(env: Record<string, string | undefined>): boolean {
610
- return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
611
- }
612
606
 
613
607
  // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
614
608
  // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
@@ -635,12 +629,16 @@ export function buildBootExtraContext(opts: {
635
629
  readonly envSource: Record<string, string | undefined>;
636
630
  readonly registry: Registry;
637
631
  readonly hasAuth: boolean;
632
+ // Resolved once per boot via resolveBootCrypto — shared by secrets,
633
+ // config-resolver, config-set-handler and boot-seeds. Absent (tests
634
+ // that don't care about encryption) ⇒ resolved from envSource here.
635
+ readonly crypto?: BootCrypto;
638
636
  readonly masterKey?: MasterKeyProvider;
639
637
  readonly sseBroker?: SseBroker;
640
638
  }): Record<string, unknown> {
639
+ const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
641
640
  const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
642
- const wireSecrets =
643
- hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
641
+ const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
644
642
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
645
643
  return {
646
644
  textContent: createTextContentApi(opts.db),
@@ -651,25 +649,25 @@ export function buildBootExtraContext(opts: {
651
649
  ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
652
650
  }),
653
651
  }),
654
- ...(wireSecrets && {
655
- secrets: createSecretsContext({
656
- db: opts.db,
657
- masterKeyProvider:
658
- opts.masterKey ??
659
- createEnvMasterKeyProvider({
660
- // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
661
- // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
662
- env: {
663
- ...opts.envSource,
664
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
665
- opts.envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
666
- },
667
- }),
652
+ // Top-level provider so feature jobs (secrets rotate, config reencrypt)
653
+ // reach it via ctx — previously only test-stack wired it.
654
+ ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
655
+ // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
656
+ // whenever a master key exists — NOT gated on the secrets feature,
657
+ // config encryption must work without mounting ctx.secrets.
658
+ ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
659
+ ...(wireSecrets &&
660
+ crypto.masterKeyProvider && {
661
+ secrets: createSecretsContext({
662
+ db: opts.db,
663
+ masterKeyProvider: crypto.masterKeyProvider,
664
+ dekCache: crypto.dekCache,
665
+ }),
668
666
  }),
669
- }),
670
667
  ...(opts.hasAuth && {
671
668
  configResolver: createConfigResolver({
672
669
  appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
670
+ ...(crypto.configCipher && { cipher: crypto.configCipher }),
673
671
  }),
674
672
  }),
675
673
  };
@@ -920,6 +918,10 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
920
918
 
921
919
  // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
922
920
  // gewinnen immer (z.B. money-horse's eigener configResolver).
921
+ const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
922
+ // App-wide cipher for `encrypted: true` entity fields — executors resolve
923
+ // it lazily, entities without encrypted fields never touch it.
924
+ configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
923
925
  const autoExtraContext = buildBootExtraContext({
924
926
  db,
925
927
  features,
@@ -927,7 +929,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
927
929
  registry,
928
930
  hasAuth: !!effectiveAuth,
929
931
  sseBroker,
930
- ...(options.masterKey && { masterKey: options.masterKey }),
932
+ crypto: bootCrypto,
931
933
  });
932
934
  const extraContext = addConfigAccessorFactory(
933
935
  { ...autoExtraContext, ...resolvedExtraContext },
@@ -1106,7 +1108,11 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1106
1108
  if (effectiveAuth) {
1107
1109
  await seedAdmin(db, effectiveAuth.admin);
1108
1110
  }
1109
- await applyBootSeeds({ registry, db });
1111
+ await applyBootSeeds({
1112
+ registry,
1113
+ db,
1114
+ ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
1115
+ });
1110
1116
  for (const seed of options.seeds ?? []) {
1111
1117
  await seed({ db });
1112
1118
  }