@cosmicdrift/kumiko-framework 0.6.0 → 0.8.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/CHANGELOG.md +37 -0
- package/package.json +10 -2
- package/src/engine/define-feature.ts +13 -0
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round5.ts +25 -1
- package/src/engine/feature-ast/parse.ts +4 -0
- package/src/engine/feature-ast/patterns.ts +11 -0
- package/src/engine/feature-ast/render.ts +7 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
- package/src/engine/pattern-library/library.ts +19 -0
- package/src/engine/types/feature.ts +19 -0
- package/src/env/__tests__/compose-env-schema.test.ts +283 -0
- package/src/env/__tests__/dry-run.test.ts +121 -0
- package/src/env/_zod-introspect.ts +51 -0
- package/src/env/dry-run.ts +191 -0
- package/src/env/index.ts +349 -0
- package/src/es-ops/README.md +4 -2
- package/src/es-ops/__tests__/context.integration.ts +100 -10
- package/src/es-ops/context.ts +21 -4
- package/src/es-ops/types.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-framework
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f34af9a: Add framework-core env-schema (Sprint 9.2, Migration Phase 1).
|
|
8
|
+
|
|
9
|
+
**New API:**
|
|
10
|
+
|
|
11
|
+
- `frameworkCoreEnvSchema` exported from `@cosmicdrift/kumiko-dev-server` — Zod-object covering the vars read by framework-core: `PORT` (default `"3000"`), `DATABASE_URL`, `REDIS_URL`, `KUMIKO_INSTANCE_ID`, `KUMIKO_SKIP_ES_OPS`. `DATABASE_URL` + `REDIS_URL` carry `.meta({ kumiko: { pulumi: { secret: true } } })` so `KUMIKO_DRY_RUN_ENV=pulumi` emits `--secret` flags. Plus `FrameworkCoreEnv` type via `z.infer`. `NODE_ENV` is excluded: build-prod-bundle inlines it as a literal at build-time (esbuild define), so runtime env-validation can't observe it.
|
|
12
|
+
- `composeEnvSchema({ core, features, extend, optionalFeatures })` accepts a new `core?` option. Keys from `core` are tagged with source `"framework-core"` in the resulting sources map and in `KumikoBootError.format()` output. Conflict detection runs across core/features/extend — a feature or `extend` block that re-declares a core var throws `KumikoBootError` at compose-time.
|
|
13
|
+
|
|
14
|
+
**Why:** Phase 1 of the Sprint 9 env-schema migration (`kumiko-studio/docs/plans/sprint-9-env-schemas.md`). Apps wire `composeEnvSchema({ core: frameworkCoreEnvSchema, features, extend })` into `runProdApp` to get aggregated boot-validation for the vars that framework-core reads. `KUMIKO_DRY_RUN_ENV=pulumi|k8s` then enumerates them with source attribution per row — operators see "(framework-core)" next to `DATABASE_URL` rather than guessing whether the framework or the app is the consumer.
|
|
15
|
+
|
|
16
|
+
**Backward-compat:** Purely additive. `runProdApp`'s existing `requireEnv("DATABASE_URL")` / `process.env["KUMIKO_INSTANCE_ID"]` reads remain unchanged. Apps that don't pass `envSchema` behave exactly as before.
|
|
17
|
+
|
|
18
|
+
**Feature-specific vars (Phase 2):** `JWT_SECRET` (auth-email-password), `KUMIKO_SECRETS_MASTER_KEY_*` (secrets), `SMTP_*` (channel-email-smtp), `STRIPE_*` / `MOLLIE_*` (subscription-\*) stay scoped to their owning feature's `r.envSchema()` and are NOT in `frameworkCoreEnvSchema`.
|
|
19
|
+
|
|
20
|
+
- dff4123: Add Zod-based env-schema declarations and boot-time validation (Sprint 9.1).
|
|
21
|
+
|
|
22
|
+
**New API:**
|
|
23
|
+
|
|
24
|
+
- `r.envSchema(z.object({...}))` — declare per-feature env-vars at registration time.
|
|
25
|
+
- `@cosmicdrift/kumiko-framework/env`: `composeEnvSchema({features, extend, optionalFeatures})` merges feature schemas into one app-wide schema, returning `{schema, sources}`. `parseEnv(schema, env, {sources, pulumiPrefix})` validates `process.env` and throws `KumikoBootError` listing ALL problems at once (aggregated, not first-fail).
|
|
26
|
+
- `@cosmicdrift/kumiko-framework/env/dry-run`: `renderDryRun(composed, mode, opts)` for `human|json|pulumi|k8s` introspection of the required env-vars without booting.
|
|
27
|
+
- `runProdApp({envSchema, pulumiPrefix, bootErrorReporter, envSource})` runs schema validation before any DB/Redis connection. `KUMIKO_DRY_RUN_ENV=1|human|json|pulumi|k8s` prints the inventory and exits.
|
|
28
|
+
- Per-var metadata via Zod's `.meta({ kumiko: { pulumi: { name, generator, secret } } })` for deploy-time tooling overrides.
|
|
29
|
+
|
|
30
|
+
**Backward-compat:** Apps without `envSchema` keep working — existing `requireEnv("DATABASE_URL")` calls in `runProdApp` are untouched. Sprint-9.2-9.5 migrates framework + bundled-features + apps to schema-only env handling.
|
|
31
|
+
|
|
32
|
+
**Why:** 2026-05-21 Studio deploy stacked 7 hacks chasing missing env-vars (10+ pipeline-fail iterations, ended in rollback). Schema-first boot validation surfaces ALL misconfigs upfront with `pulumi config set …` suggestions, replacing the discover-by-failing loop with a single dry-run + secrets-bootstrap pass.
|
|
33
|
+
|
|
34
|
+
## 0.7.0
|
|
35
|
+
|
|
36
|
+
### Minor Changes
|
|
37
|
+
|
|
38
|
+
- bcf43b6: es-ops: `SeedMembershipRow` exposes `streamTenantId` (stream-tenant aus `kumiko_events.v1`) neben dem payload-`tenantId`. Seed-Authors müssen den `kumiko_events`-JOIN nicht mehr selbst bauen — `m.streamTenantId` ist der korrekte Wert für `systemWriteAs`'s `tenantIdOverride` wenn das Aggregate von einem fremden Executor angelegt wurde (typisches `seedTenantMembership(by=systemAdmin)`-Pattern).
|
|
39
|
+
|
|
3
40
|
## 0.6.0
|
|
4
41
|
|
|
5
42
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
"types": "./src/engine/types/index.ts",
|
|
45
45
|
"default": "./src/engine/types/index.ts"
|
|
46
46
|
},
|
|
47
|
+
"./env": {
|
|
48
|
+
"types": "./src/env/index.ts",
|
|
49
|
+
"default": "./src/env/index.ts"
|
|
50
|
+
},
|
|
51
|
+
"./env/dry-run": {
|
|
52
|
+
"types": "./src/env/dry-run.ts",
|
|
53
|
+
"default": "./src/env/dry-run.ts"
|
|
54
|
+
},
|
|
47
55
|
"./engine/codemod": {
|
|
48
56
|
"types": "./src/engine/codemod/index.ts",
|
|
49
57
|
"default": "./src/engine/codemod/index.ts"
|
|
@@ -163,7 +171,7 @@
|
|
|
163
171
|
"zod": "^4.4.3"
|
|
164
172
|
},
|
|
165
173
|
"devDependencies": {
|
|
166
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
174
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.8.0",
|
|
167
175
|
"@types/uuid": "^11.0.0",
|
|
168
176
|
"bun-types": "^1.3.13",
|
|
169
177
|
"drizzle-kit": "^0.31.10",
|
|
@@ -155,6 +155,9 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
155
155
|
// closure-let-var nicht mit der `treeActions(...)` registrar-Methode.
|
|
156
156
|
let treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
|
|
157
157
|
let treeProvider: TreeChildrenSubscribe | undefined;
|
|
158
|
+
// Optional Zod-schema for env-vars this feature reads at runtime,
|
|
159
|
+
// declared via r.envSchema(). At-most-one per feature.
|
|
160
|
+
let envSchema: z.ZodObject<z.ZodRawShape> | undefined;
|
|
158
161
|
|
|
159
162
|
// Map handler name to entity via colon convention.
|
|
160
163
|
// "task:create" → entity "task". No colon → standalone handler, no mapping.
|
|
@@ -573,6 +576,15 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
573
576
|
metrics[shortName] = { shortName, ...options };
|
|
574
577
|
},
|
|
575
578
|
|
|
579
|
+
envSchema(schema: z.ZodObject<z.ZodRawShape>): void {
|
|
580
|
+
if (envSchema !== undefined) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`[Feature ${name}] r.envSchema() called twice — declare one composed Zod-object per feature.`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
envSchema = schema;
|
|
586
|
+
},
|
|
587
|
+
|
|
576
588
|
secret(shortName: string, options: SecretOptions): SecretKeyHandle {
|
|
577
589
|
if (secretKeys[shortName]) {
|
|
578
590
|
throw new Error(
|
|
@@ -874,5 +886,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
874
886
|
rawTables,
|
|
875
887
|
...(treeActions !== undefined && { treeActions }),
|
|
876
888
|
...(treeProvider !== undefined && { treeProvider }),
|
|
889
|
+
...(envSchema !== undefined && { envSchema }),
|
|
877
890
|
};
|
|
878
891
|
}
|
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import type { CallExpression, SourceFile } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
EnvSchemaPattern,
|
|
5
|
+
ExposesApiPattern,
|
|
6
|
+
ExtendsRegistrarPattern,
|
|
7
|
+
UsesApiPattern,
|
|
8
|
+
} from "../patterns";
|
|
4
9
|
import { sourceLocationFromNode } from "../source-location";
|
|
5
10
|
import { type ExtractOutput, fail, ok } from "./shared";
|
|
6
11
|
|
|
12
|
+
export function extractEnvSchema(
|
|
13
|
+
call: CallExpression,
|
|
14
|
+
sourceFile: SourceFile,
|
|
15
|
+
): ExtractOutput<EnvSchemaPattern> {
|
|
16
|
+
const arg = call.getArguments()[0];
|
|
17
|
+
if (!arg) {
|
|
18
|
+
return fail(
|
|
19
|
+
"envSchema",
|
|
20
|
+
sourceLocationFromNode(call, sourceFile),
|
|
21
|
+
"expected a single Zod-object schema argument (e.g. z.object({...}))",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return ok({
|
|
25
|
+
kind: "envSchema",
|
|
26
|
+
source: sourceLocationFromNode(call, sourceFile),
|
|
27
|
+
schemaBody: sourceLocationFromNode(arg, sourceFile),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
export function extractExtendsRegistrar(
|
|
8
32
|
call: CallExpression,
|
|
9
33
|
sourceFile: SourceFile,
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
extractDefineEvent,
|
|
33
33
|
extractEntity,
|
|
34
34
|
extractEntityHook,
|
|
35
|
+
extractEnvSchema,
|
|
35
36
|
extractEventMigration,
|
|
36
37
|
extractExposesApi,
|
|
37
38
|
extractExtendsRegistrar,
|
|
@@ -359,6 +360,9 @@ function dispatchExtractor(
|
|
|
359
360
|
return extractTreeActions(call, sourceFile);
|
|
360
361
|
case "tree":
|
|
361
362
|
return extractTree(call, sourceFile);
|
|
363
|
+
// Round 7 — env-schema contract (opaque, Zod-expression argument)
|
|
364
|
+
case "envSchema":
|
|
365
|
+
return extractEnvSchema(call, sourceFile);
|
|
362
366
|
// Unknown method — UnknownPattern signal so Designer/AI surface it
|
|
363
367
|
// as "custom call" without losing the source location.
|
|
364
368
|
default:
|
|
@@ -387,6 +387,15 @@ export type ExposesApiPattern = {
|
|
|
387
387
|
// API exists and needs its own pattern type here.
|
|
388
388
|
// =============================================================================
|
|
389
389
|
|
|
390
|
+
// r.envSchema(z.object({...})) — the env-vars contract for a feature.
|
|
391
|
+
// Argument is a Zod-expression (computed); we keep the source-location of
|
|
392
|
+
// the schema body so Designer / AI render the raw TS code (opaque).
|
|
393
|
+
export type EnvSchemaPattern = {
|
|
394
|
+
readonly kind: "envSchema";
|
|
395
|
+
readonly source: SourceLocation;
|
|
396
|
+
readonly schemaBody: SourceLocation;
|
|
397
|
+
};
|
|
398
|
+
|
|
390
399
|
export type UnknownPattern = {
|
|
391
400
|
readonly kind: "unknown";
|
|
392
401
|
readonly source: SourceLocation;
|
|
@@ -435,6 +444,7 @@ export type FeaturePattern =
|
|
|
435
444
|
| EventMigrationPattern
|
|
436
445
|
| ExtendsRegistrarPattern
|
|
437
446
|
| TreePattern
|
|
447
|
+
| EnvSchemaPattern
|
|
438
448
|
// Catch-all
|
|
439
449
|
| UnknownPattern;
|
|
440
450
|
|
|
@@ -492,6 +502,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
|
|
|
492
502
|
case "authClaims":
|
|
493
503
|
case "extendsRegistrar":
|
|
494
504
|
case "tree":
|
|
505
|
+
case "envSchema":
|
|
495
506
|
case "unknown":
|
|
496
507
|
return "opaque";
|
|
497
508
|
default: {
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
DefineEventPattern,
|
|
24
24
|
EntityHookPattern,
|
|
25
25
|
EntityPattern,
|
|
26
|
+
EnvSchemaPattern,
|
|
26
27
|
EventMigrationPattern,
|
|
27
28
|
ExposesApiPattern,
|
|
28
29
|
ExtendsRegistrarPattern,
|
|
@@ -134,6 +135,8 @@ export function renderPattern(pattern: FeaturePattern): string {
|
|
|
134
135
|
return renderTreeActions(pattern);
|
|
135
136
|
case "tree":
|
|
136
137
|
return renderTree(pattern);
|
|
138
|
+
case "envSchema":
|
|
139
|
+
return renderEnvSchema(pattern);
|
|
137
140
|
case "unknown":
|
|
138
141
|
return renderUnknown(pattern);
|
|
139
142
|
default: {
|
|
@@ -495,6 +498,10 @@ function renderExtendsRegistrar(p: ExtendsRegistrarPattern): string {
|
|
|
495
498
|
return `r.extendsRegistrar(${JSON.stringify(p.extensionName)}, ${p.defBody.raw});`;
|
|
496
499
|
}
|
|
497
500
|
|
|
501
|
+
function renderEnvSchema(p: EnvSchemaPattern): string {
|
|
502
|
+
return `r.envSchema(${p.schemaBody.raw});`;
|
|
503
|
+
}
|
|
504
|
+
|
|
498
505
|
function renderUsesApi(p: UsesApiPattern): string {
|
|
499
506
|
return `r.usesApi(${JSON.stringify(p.apiName)});`;
|
|
500
507
|
}
|
|
@@ -59,6 +59,7 @@ const ALL_KINDS: readonly FeaturePatternKind[] = [
|
|
|
59
59
|
"exposesApi",
|
|
60
60
|
"treeActions",
|
|
61
61
|
"tree",
|
|
62
|
+
"envSchema",
|
|
62
63
|
"unknown",
|
|
63
64
|
];
|
|
64
65
|
|
|
@@ -346,6 +347,8 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
|
|
|
346
347
|
return { kind, source: PLACEHOLDER_LOC, definitions: {} };
|
|
347
348
|
case "tree":
|
|
348
349
|
return { kind, source: PLACEHOLDER_LOC, providerBody: PLACEHOLDER_BODY_LOC };
|
|
350
|
+
case "envSchema":
|
|
351
|
+
return { kind, source: PLACEHOLDER_LOC, schemaBody: PLACEHOLDER_BODY_LOC };
|
|
349
352
|
case "unknown":
|
|
350
353
|
return { kind, source: PLACEHOLDER_LOC, methodName: "x" };
|
|
351
354
|
case "usesApi":
|
|
@@ -1092,6 +1092,24 @@ const treeSchema: PatternFormSchema = {
|
|
|
1092
1092
|
],
|
|
1093
1093
|
};
|
|
1094
1094
|
|
|
1095
|
+
const envSchemaSchema: PatternFormSchema = {
|
|
1096
|
+
kind: "envSchema",
|
|
1097
|
+
label: { en: "Env schema", de: "Env-Schema" },
|
|
1098
|
+
summary: {
|
|
1099
|
+
en: "Zod-object declaring this feature's required env-vars. Apps merge it via composeEnvSchema for boot-validation.",
|
|
1100
|
+
},
|
|
1101
|
+
category: "advanced",
|
|
1102
|
+
editability: "opaque",
|
|
1103
|
+
fields: [
|
|
1104
|
+
{
|
|
1105
|
+
path: "schemaBody",
|
|
1106
|
+
label: { en: "Schema", de: "Schema" },
|
|
1107
|
+
input: "json-readonly",
|
|
1108
|
+
readOnly: true,
|
|
1109
|
+
},
|
|
1110
|
+
],
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1095
1113
|
const unknownSchema: PatternFormSchema = {
|
|
1096
1114
|
kind: "unknown",
|
|
1097
1115
|
label: { en: "Unknown call", de: "Unbekannter Call" },
|
|
@@ -1153,6 +1171,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
|
|
|
1153
1171
|
exposesApi: exposesApiSchema,
|
|
1154
1172
|
treeActions: treeActionsSchema,
|
|
1155
1173
|
tree: treeSchema,
|
|
1174
|
+
envSchema: envSchemaSchema,
|
|
1156
1175
|
unknown: unknownSchema,
|
|
1157
1176
|
} satisfies Readonly<Record<FeaturePatternKind, PatternFormSchema>>;
|
|
1158
1177
|
|
|
@@ -260,6 +260,12 @@ export type FeatureDefinition = {
|
|
|
260
260
|
// system. Keyed by feature-local short name. The registry attaches
|
|
261
261
|
// featureName on aggregation, lifting RawTableEntry → RawTableDef.
|
|
262
262
|
readonly rawTables: Readonly<Record<string, RawTableEntry>>;
|
|
263
|
+
// Optional Zod-schema for env-vars this feature reads at runtime.
|
|
264
|
+
// Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
|
|
265
|
+
// this to build one app-wide schema for boot-validation + dry-run
|
|
266
|
+
// rendering. Absence means the feature reads no env-vars (or hasn't
|
|
267
|
+
// been migrated yet — Sprint-9 migration is add-only per phase).
|
|
268
|
+
readonly envSchema?: z.ZodObject<z.ZodRawShape>;
|
|
263
269
|
};
|
|
264
270
|
|
|
265
271
|
// --- Feature Registrar (the "r" object in defineFeature) ---
|
|
@@ -574,6 +580,19 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
574
580
|
actions: TActions,
|
|
575
581
|
): TreeActionsHandle<TFeature, TActions>;
|
|
576
582
|
|
|
583
|
+
// Declare the Zod-schema for env-vars this feature reads at runtime.
|
|
584
|
+
// At-most-one call per feature. composeEnvSchema reads it across all
|
|
585
|
+
// features to build one app-wide schema, which runProdApp parses
|
|
586
|
+
// process.env against at boot. App-Authors can also call
|
|
587
|
+
// `KUMIKO_DRY_RUN_ENV=human|json|pulumi|k8s` to introspect the
|
|
588
|
+
// required env-vars without booting.
|
|
589
|
+
//
|
|
590
|
+
// Convention: keys are SHOUTING_SNAKE_CASE env-var names. Per-var
|
|
591
|
+
// metadata (Pulumi-config-key override, openssl-generator suggestion,
|
|
592
|
+
// k8s-secret hints) goes into `.meta({ kumiko: { pulumi: {...} } })`
|
|
593
|
+
// — see framework/env/index.ts for the meta-shape.
|
|
594
|
+
envSchema(schema: z.ZodObject<z.ZodRawShape>): void;
|
|
595
|
+
|
|
577
596
|
// Register the tree-provider for this feature — the Subscribe-Function
|
|
578
597
|
// that emits the top-level Tree-Knoten when the Visual-Workspace
|
|
579
598
|
// (navigation: "tree") mounts. At-most-one call per feature.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
4
|
+
import { camelCase, composeEnvSchema, KumikoBootError, parseEnv, pulumiConfigKey } from "../index";
|
|
5
|
+
|
|
6
|
+
describe("composeEnvSchema", () => {
|
|
7
|
+
it("merges per-feature schemas and tags sources", () => {
|
|
8
|
+
const secretsFeature = defineFeature("secrets", (r) => {
|
|
9
|
+
r.envSchema(
|
|
10
|
+
z.object({
|
|
11
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: z.string().describe("AES-256 KEK"),
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
const authFeature = defineFeature("auth", (r) => {
|
|
16
|
+
r.envSchema(
|
|
17
|
+
z.object({
|
|
18
|
+
JWT_SECRET: z.string().min(32).describe("Session JWT signing key"),
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const { schema, sources } = composeEnvSchema({
|
|
24
|
+
features: [secretsFeature, authFeature],
|
|
25
|
+
extend: z.object({
|
|
26
|
+
STUDIO_ADMIN_EMAIL: z.email(),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(Object.keys(schema.shape).sort()).toEqual([
|
|
31
|
+
"JWT_SECRET",
|
|
32
|
+
"KUMIKO_SECRETS_MASTER_KEY_V1",
|
|
33
|
+
"STUDIO_ADMIN_EMAIL",
|
|
34
|
+
]);
|
|
35
|
+
expect(sources).toEqual({
|
|
36
|
+
JWT_SECRET: "auth",
|
|
37
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: "secrets",
|
|
38
|
+
STUDIO_ADMIN_EMAIL: "app",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("detects feature/feature env-var conflicts", () => {
|
|
43
|
+
const a = defineFeature("feat-a", (r) => {
|
|
44
|
+
r.envSchema(z.object({ JWT_SECRET: z.string() }));
|
|
45
|
+
});
|
|
46
|
+
const b = defineFeature("feat-b", (r) => {
|
|
47
|
+
r.envSchema(z.object({ JWT_SECRET: z.string() }));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(() => composeEnvSchema({ features: [a, b] })).toThrow(KumikoBootError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects feature/app env-var conflicts", () => {
|
|
54
|
+
const a = defineFeature("feat-a", (r) => {
|
|
55
|
+
r.envSchema(z.object({ DATABASE_URL: z.string() }));
|
|
56
|
+
});
|
|
57
|
+
expect(() =>
|
|
58
|
+
composeEnvSchema({
|
|
59
|
+
features: [a],
|
|
60
|
+
extend: z.object({ DATABASE_URL: z.string() }),
|
|
61
|
+
}),
|
|
62
|
+
).toThrow(KumikoBootError);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("wraps optionalFeatures' vars as .optional()", () => {
|
|
66
|
+
const smtp = defineFeature("channel-email-smtp", (r) => {
|
|
67
|
+
r.envSchema(z.object({ SMTP_HOST: z.string() }));
|
|
68
|
+
});
|
|
69
|
+
const { schema } = composeEnvSchema({
|
|
70
|
+
features: [smtp],
|
|
71
|
+
optionalFeatures: ["channel-email-smtp"],
|
|
72
|
+
});
|
|
73
|
+
// Parsing without SMTP_HOST should now succeed.
|
|
74
|
+
const result = schema.safeParse({});
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("double-wrapping an already-optional field stays parseable", () => {
|
|
79
|
+
// Edge case: feature declares the field optional AND the app marks
|
|
80
|
+
// the whole feature as optionalFeatures. composeEnvSchema wraps again.
|
|
81
|
+
// Zod v4 treats .optional().optional() as idempotent.
|
|
82
|
+
const smtp = defineFeature("channel-email-smtp", (r) => {
|
|
83
|
+
r.envSchema(z.object({ SMTP_HOST: z.string().optional() }));
|
|
84
|
+
});
|
|
85
|
+
const { schema } = composeEnvSchema({
|
|
86
|
+
features: [smtp],
|
|
87
|
+
optionalFeatures: ["channel-email-smtp"],
|
|
88
|
+
});
|
|
89
|
+
expect(schema.safeParse({}).success).toBe(true);
|
|
90
|
+
expect(schema.safeParse({ SMTP_HOST: "mail.example" }).success).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("ignores features without envSchema", () => {
|
|
94
|
+
const noEnv = defineFeature("no-env", () => {
|
|
95
|
+
// declares nothing
|
|
96
|
+
});
|
|
97
|
+
const { schema, sources } = composeEnvSchema({ features: [noEnv] });
|
|
98
|
+
expect(Object.keys(schema.shape)).toEqual([]);
|
|
99
|
+
expect(sources).toEqual({});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("tags `core` vars with source 'framework-core'", () => {
|
|
103
|
+
const core = z.object({
|
|
104
|
+
PORT: z.string().default("3000"),
|
|
105
|
+
DATABASE_URL: z.url(),
|
|
106
|
+
});
|
|
107
|
+
const feature = defineFeature("auth", (r) => {
|
|
108
|
+
r.envSchema(z.object({ JWT_SECRET: z.string().min(32) }));
|
|
109
|
+
});
|
|
110
|
+
const { schema, sources } = composeEnvSchema({
|
|
111
|
+
core,
|
|
112
|
+
features: [feature],
|
|
113
|
+
extend: z.object({ STUDIO_ADMIN_EMAIL: z.email() }),
|
|
114
|
+
});
|
|
115
|
+
expect(Object.keys(schema.shape).sort()).toEqual([
|
|
116
|
+
"DATABASE_URL",
|
|
117
|
+
"JWT_SECRET",
|
|
118
|
+
"PORT",
|
|
119
|
+
"STUDIO_ADMIN_EMAIL",
|
|
120
|
+
]);
|
|
121
|
+
expect(sources).toEqual({
|
|
122
|
+
DATABASE_URL: "framework-core",
|
|
123
|
+
JWT_SECRET: "auth",
|
|
124
|
+
PORT: "framework-core",
|
|
125
|
+
STUDIO_ADMIN_EMAIL: "app",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("detects core/feature env-var conflicts (feature shadows core)", () => {
|
|
130
|
+
const core = z.object({ PORT: z.string() });
|
|
131
|
+
const sneaky = defineFeature("sneaky", (r) => {
|
|
132
|
+
r.envSchema(z.object({ PORT: z.string() }));
|
|
133
|
+
});
|
|
134
|
+
expect(() => composeEnvSchema({ core, features: [sneaky] })).toThrow(KumikoBootError);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("detects core/extend env-var conflicts (app shadows core)", () => {
|
|
138
|
+
const core = z.object({ DATABASE_URL: z.url() });
|
|
139
|
+
expect(() =>
|
|
140
|
+
composeEnvSchema({
|
|
141
|
+
core,
|
|
142
|
+
features: [],
|
|
143
|
+
extend: z.object({ DATABASE_URL: z.string() }),
|
|
144
|
+
}),
|
|
145
|
+
).toThrow(KumikoBootError);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("r.envSchema()", () => {
|
|
150
|
+
it("hangs the schema off the FeatureDefinition", () => {
|
|
151
|
+
const feature = defineFeature("foo", (r) => {
|
|
152
|
+
r.envSchema(z.object({ FOO_BAR: z.string() }));
|
|
153
|
+
});
|
|
154
|
+
expect(feature.envSchema).toBeDefined();
|
|
155
|
+
expect(Object.keys(feature.envSchema!.shape)).toEqual(["FOO_BAR"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("throws when called twice", () => {
|
|
159
|
+
expect(() =>
|
|
160
|
+
defineFeature("foo", (r) => {
|
|
161
|
+
r.envSchema(z.object({ A: z.string() }));
|
|
162
|
+
r.envSchema(z.object({ B: z.string() }));
|
|
163
|
+
}),
|
|
164
|
+
).toThrow(/envSchema\(\) called twice/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("parseEnv", () => {
|
|
169
|
+
it("returns the typed value on success", () => {
|
|
170
|
+
const schema = z.object({
|
|
171
|
+
PORT: z.string().regex(/^\d+$/),
|
|
172
|
+
JWT_SECRET: z.string().min(32),
|
|
173
|
+
});
|
|
174
|
+
const value = parseEnv(schema, {
|
|
175
|
+
PORT: "3000",
|
|
176
|
+
JWT_SECRET: "x".repeat(32),
|
|
177
|
+
});
|
|
178
|
+
expect(value.PORT).toBe("3000");
|
|
179
|
+
expect(value.JWT_SECRET.length).toBe(32);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("aggregates ALL errors, not first-fail", () => {
|
|
183
|
+
const schema = z.object({
|
|
184
|
+
DATABASE_URL: z.url(),
|
|
185
|
+
JWT_SECRET: z.string().min(32),
|
|
186
|
+
ADMIN_EMAIL: z.email(),
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
parseEnv(schema, {
|
|
190
|
+
DATABASE_URL: "not-a-url",
|
|
191
|
+
// JWT_SECRET missing
|
|
192
|
+
ADMIN_EMAIL: "not-an-email",
|
|
193
|
+
});
|
|
194
|
+
throw new Error("should have thrown");
|
|
195
|
+
} catch (err) {
|
|
196
|
+
expect(err).toBeInstanceOf(KumikoBootError);
|
|
197
|
+
const boot = err as KumikoBootError;
|
|
198
|
+
expect(boot.errors.length).toBe(3);
|
|
199
|
+
const names = boot.errors.map((e) => e.name).sort();
|
|
200
|
+
expect(names).toEqual(["ADMIN_EMAIL", "DATABASE_URL", "JWT_SECRET"]);
|
|
201
|
+
const jwt = boot.errors.find((e) => e.name === "JWT_SECRET");
|
|
202
|
+
expect(jwt?.kind).toBe("missing");
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("strips undefined values so missing vars get a 'missing' kind", () => {
|
|
207
|
+
const schema = z.object({ FOO: z.string() });
|
|
208
|
+
try {
|
|
209
|
+
parseEnv(schema, { FOO: undefined });
|
|
210
|
+
throw new Error("should have thrown");
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const boot = err as KumikoBootError;
|
|
213
|
+
expect(boot.errors[0]!.kind).toBe("missing");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("attaches pulumi suggestions when prefix is set", () => {
|
|
218
|
+
const schema = z.object({
|
|
219
|
+
JWT_SECRET: z
|
|
220
|
+
.string()
|
|
221
|
+
.min(32)
|
|
222
|
+
.meta({ kumiko: { pulumi: { generator: "openssl rand -base64 48", secret: true } } }),
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
parseEnv(schema, {}, { pulumiPrefix: "studio" });
|
|
226
|
+
} catch (err) {
|
|
227
|
+
const boot = err as KumikoBootError;
|
|
228
|
+
expect(boot.errors[0]!.suggestion).toBe(
|
|
229
|
+
'Set via: pulumi config set --secret studioJwtSecret "$(openssl rand -base64 48)"',
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("formats aggregate output with all errors", () => {
|
|
235
|
+
const schema = z.object({
|
|
236
|
+
A: z.string(),
|
|
237
|
+
B: z.string().min(10).describe("Long-ish key"),
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
parseEnv(schema, { B: "short" });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
const formatted = (err as KumikoBootError).format();
|
|
243
|
+
expect(formatted).toContain("Boot failed: 2 env-var problems");
|
|
244
|
+
expect(formatted).toContain("✗ A (required, missing)");
|
|
245
|
+
expect(formatted).toContain("✗ B (invalid)");
|
|
246
|
+
expect(formatted).toContain("Long-ish key");
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("populates EnvError.source from options.sources, surfaced in format()", () => {
|
|
251
|
+
const schema = z.object({ JWT_SECRET: z.string().min(32) });
|
|
252
|
+
try {
|
|
253
|
+
parseEnv(schema, {}, { sources: { JWT_SECRET: "auth-email-password" } });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const boot = err as KumikoBootError;
|
|
256
|
+
expect(boot.errors[0]!.source).toBe("auth-email-password");
|
|
257
|
+
expect(boot.format()).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("pulumiConfigKey + camelCase", () => {
|
|
263
|
+
it("camelCases SCREAMING_SNAKE_CASE", () => {
|
|
264
|
+
expect(camelCase("JWT_SECRET")).toBe("jwtSecret");
|
|
265
|
+
expect(camelCase("STUDIO_ADMIN_EMAIL")).toBe("studioAdminEmail");
|
|
266
|
+
expect(camelCase("KUMIKO_SECRETS_MASTER_KEY_V1")).toBe("kumikoSecretsMasterKeyV1");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("applies a prefix with PascalCase'd tail", () => {
|
|
270
|
+
expect(pulumiConfigKey("JWT_SECRET", undefined, "studio")).toBe("studioJwtSecret");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("uses meta.pulumi.name override when set", () => {
|
|
274
|
+
const f = z.string().meta({ kumiko: { pulumi: { name: "secretsMasterKey" } } });
|
|
275
|
+
expect(pulumiConfigKey("KUMIKO_SECRETS_MASTER_KEY_V1", f, "studio")).toBe(
|
|
276
|
+
"studioSecretsMasterKey",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns the camelCase name when no prefix", () => {
|
|
281
|
+
expect(pulumiConfigKey("JWT_SECRET", undefined, undefined)).toBe("jwtSecret");
|
|
282
|
+
});
|
|
283
|
+
});
|