@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 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.6.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.6.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
  }
@@ -50,6 +50,7 @@ export {
50
50
  readScreenStatic,
51
51
  } from "./round4";
52
52
  export {
53
+ extractEnvSchema,
53
54
  extractExposesApi,
54
55
  extractExtendsRegistrar,
55
56
  extractUsesApi,
@@ -1,9 +1,33 @@
1
1
  import type { CallExpression, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
- import type { ExposesApiPattern, ExtendsRegistrarPattern, UsesApiPattern } from "../patterns";
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
+ });