@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 +3 -3
- package/src/boot/apply-boot-seeds.ts +4 -3
- package/src/boot/boot-crypto.ts +82 -0
- package/src/run-dev-app.ts +21 -4
- package/src/run-prod-app.ts +36 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "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.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "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
|
|
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
|
-
|
|
16
|
+
cipher?: EnvelopeCipher;
|
|
16
17
|
}): Promise<void> {
|
|
17
|
-
await seedAllConfigValues(deps.registry, deps.db, deps.
|
|
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
|
+
}
|
package/src/run-dev-app.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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({
|
|
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
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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
|
-
//
|
|
601
|
-
//
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
}
|