@cosmicdrift/kumiko-dev-server 0.7.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,44 @@
1
1
  # @cosmicdrift/kumiko-dev-server
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
+ ### Patch Changes
35
+
36
+ - Updated dependencies [145b8df]
37
+ - Updated dependencies [f34af9a]
38
+ - Updated dependencies [dff4123]
39
+ - @cosmicdrift/kumiko-bundled-features@0.8.0
40
+ - @cosmicdrift/kumiko-framework@0.8.0
41
+
3
42
  ## 0.7.0
4
43
 
5
44
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.7.0",
3
+ "version": "0.8.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>",
@@ -48,8 +48,8 @@
48
48
  "kumiko-dev": "./bin/kumiko-dev.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-bundled-features": "0.7.0",
52
- "@cosmicdrift/kumiko-framework": "0.7.0"
51
+ "@cosmicdrift/kumiko-bundled-features": "0.8.0",
52
+ "@cosmicdrift/kumiko-framework": "0.8.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,185 @@
1
+ // Integration-style spec for runProdApp's envSchema boot-stage.
2
+ // Focus: the env-validation path BEFORE any DB/Redis connection.
3
+ // We never reach `requireEnv("DATABASE_URL")` here — invalid env
4
+ // throws (via bootErrorReporter override) and KUMIKO_DRY_RUN_ENV
5
+ // returns the dry-run handle without booting.
6
+
7
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { composeEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
9
+ import { describe, expect, it } from "vitest";
10
+ import { z } from "zod";
11
+ import { frameworkCoreEnvSchema } from "../env-schema";
12
+ import * as devServerPublicApi from "../index";
13
+ import { runProdApp } from "../run-prod-app";
14
+
15
+ const secretsFeature = defineFeature("secrets", (r) => {
16
+ r.envSchema(
17
+ z.object({
18
+ KUMIKO_SECRETS_MASTER_KEY_V1: z
19
+ .string()
20
+ .describe("AES-256 KEK")
21
+ .meta({ kumiko: { pulumi: { generator: "openssl rand -base64 32", secret: true } } }),
22
+ }),
23
+ );
24
+ });
25
+
26
+ const authFeature = defineFeature("auth-email-password", (r) => {
27
+ r.envSchema(
28
+ z.object({
29
+ JWT_SECRET: z.string().min(32).describe("JWT signing key"),
30
+ }),
31
+ );
32
+ });
33
+
34
+ const composed = composeEnvSchema({
35
+ features: [secretsFeature, authFeature],
36
+ extend: z.object({
37
+ STUDIO_ADMIN_EMAIL: z.email(),
38
+ }),
39
+ });
40
+
41
+ describe("public-export smoke", () => {
42
+ it("re-exports frameworkCoreEnvSchema via dev-server's package entry", () => {
43
+ expect(devServerPublicApi.frameworkCoreEnvSchema).toBe(frameworkCoreEnvSchema);
44
+ });
45
+ });
46
+
47
+ describe("runProdApp envSchema integration", () => {
48
+ it("aggregates all env-errors at boot, not first-fail", async () => {
49
+ let captured: KumikoBootError | undefined;
50
+ try {
51
+ await runProdApp({
52
+ features: [],
53
+ envSchema: composed,
54
+ pulumiPrefix: "studio",
55
+ envSource: {
56
+ // KUMIKO_SECRETS_MASTER_KEY_V1 missing
57
+ JWT_SECRET: "short", // invalid (min 32)
58
+ STUDIO_ADMIN_EMAIL: "not-an-email", // invalid format
59
+ },
60
+ bootErrorReporter: (err) => {
61
+ captured = err;
62
+ throw err;
63
+ },
64
+ });
65
+ } catch (err) {
66
+ expect(err).toBeInstanceOf(KumikoBootError);
67
+ }
68
+ expect(captured).toBeDefined();
69
+ expect(captured!.errors.length).toBe(3);
70
+ const names = captured!.errors.map((e) => e.name).sort();
71
+ expect(names).toEqual(["JWT_SECRET", "KUMIKO_SECRETS_MASTER_KEY_V1", "STUDIO_ADMIN_EMAIL"]);
72
+ const kek = captured!.errors.find((e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_V1");
73
+ expect(kek?.kind).toBe("missing");
74
+ expect(kek?.suggestion).toBe(
75
+ 'Set via: pulumi config set --secret studioKumikoSecretsMasterKeyV1 "$(openssl rand -base64 32)"',
76
+ );
77
+ });
78
+
79
+ it("KUMIKO_DRY_RUN_ENV=human prints inventory and returns a dry-run handle", async () => {
80
+ const logs: string[] = [];
81
+ const realLog = console.log;
82
+ console.log = (...args: unknown[]) => {
83
+ logs.push(args.map(String).join(" "));
84
+ };
85
+ try {
86
+ const handle = await runProdApp({
87
+ features: [],
88
+ envSchema: composed,
89
+ envSource: {
90
+ KUMIKO_DRY_RUN_ENV: "human",
91
+ },
92
+ });
93
+ expect(handle).toBeDefined();
94
+ const out = logs.join("\n");
95
+ expect(out).toContain("Required env-vars:");
96
+ expect(out).toContain("KUMIKO_SECRETS_MASTER_KEY_V1");
97
+ expect(out).toContain("(secrets)");
98
+ expect(out).toContain("JWT_SECRET");
99
+ expect(out).toContain("(auth-email-password)");
100
+ expect(out).toContain("STUDIO_ADMIN_EMAIL");
101
+ expect(out).toContain("(app)");
102
+ } finally {
103
+ console.log = realLog;
104
+ }
105
+ });
106
+
107
+ it("KUMIKO_DRY_RUN_ENV=pulumi emits `pulumi config set` lines with prefix", async () => {
108
+ const logs: string[] = [];
109
+ const realLog = console.log;
110
+ console.log = (...args: unknown[]) => {
111
+ logs.push(args.map(String).join(" "));
112
+ };
113
+ try {
114
+ await runProdApp({
115
+ features: [],
116
+ envSchema: composed,
117
+ pulumiPrefix: "studio",
118
+ envSource: { KUMIKO_DRY_RUN_ENV: "pulumi" },
119
+ });
120
+ const out = logs.join("\n");
121
+ expect(out).toContain(
122
+ 'pulumi config set --secret studioKumikoSecretsMasterKeyV1 "$(openssl rand -base64 32)"',
123
+ );
124
+ expect(out).toContain('pulumi config set studioStudioAdminEmail "<set-me>"');
125
+ } finally {
126
+ console.log = realLog;
127
+ }
128
+ });
129
+
130
+ it("composes frameworkCoreEnvSchema with feature schemas; reports core-source on missing PORT/DATABASE_URL", async () => {
131
+ const composedWithCore = composeEnvSchema({
132
+ core: frameworkCoreEnvSchema,
133
+ features: [secretsFeature, authFeature],
134
+ extend: z.object({ STUDIO_ADMIN_EMAIL: z.email() }),
135
+ });
136
+ let captured: KumikoBootError | undefined;
137
+ try {
138
+ await runProdApp({
139
+ features: [],
140
+ envSchema: composedWithCore,
141
+ pulumiPrefix: "studio",
142
+ envSource: {
143
+ // DATABASE_URL + REDIS_URL missing (framework-core)
144
+ // JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1 missing (features)
145
+ // STUDIO_ADMIN_EMAIL missing (app)
146
+ PORT: "invalid-port",
147
+ },
148
+ bootErrorReporter: (err) => {
149
+ captured = err;
150
+ throw err;
151
+ },
152
+ });
153
+ } catch (err) {
154
+ expect(err).toBeInstanceOf(KumikoBootError);
155
+ }
156
+ expect(captured).toBeDefined();
157
+ const port = captured!.errors.find((e) => e.name === "PORT");
158
+ expect(port?.source).toBe("framework-core");
159
+ expect(port?.kind).toBe("invalid");
160
+ const db = captured!.errors.find((e) => e.name === "DATABASE_URL");
161
+ expect(db?.source).toBe("framework-core");
162
+ expect(db?.kind).toBe("missing");
163
+ const formatted = captured!.format();
164
+ expect(formatted).toContain("✗ DATABASE_URL (framework-core, required, missing)");
165
+ expect(formatted).toContain("✗ REDIS_URL (framework-core, required, missing)");
166
+ });
167
+
168
+ it("KUMIKO_DRY_RUN_ENV=1 aliases to human-mode", async () => {
169
+ const logs: string[] = [];
170
+ const realLog = console.log;
171
+ console.log = (...args: unknown[]) => {
172
+ logs.push(args.map(String).join(" "));
173
+ };
174
+ try {
175
+ await runProdApp({
176
+ features: [],
177
+ envSchema: composed,
178
+ envSource: { KUMIKO_DRY_RUN_ENV: "1" },
179
+ });
180
+ expect(logs.join("\n")).toContain("Required env-vars:");
181
+ } finally {
182
+ console.log = realLog;
183
+ }
184
+ });
185
+ });
@@ -0,0 +1,93 @@
1
+ import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko-framework/env";
2
+ import { describe, expect, it } from "vitest";
3
+ import { z } from "zod";
4
+ import { type FrameworkCoreEnv, frameworkCoreEnvSchema } from "../env-schema";
5
+
6
+ describe("frameworkCoreEnvSchema", () => {
7
+ it("accepts a valid minimal env", () => {
8
+ const env = parseEnv(frameworkCoreEnvSchema, {
9
+ DATABASE_URL: "postgres://localhost:5432/db",
10
+ REDIS_URL: "redis://localhost:6379",
11
+ });
12
+ // Defaults populated, optional keys remain undefined.
13
+ expect(env.PORT).toBe("3000");
14
+ expect(env.DATABASE_URL).toBe("postgres://localhost:5432/db");
15
+ expect(env.REDIS_URL).toBe("redis://localhost:6379");
16
+ expect(env.KUMIKO_INSTANCE_ID).toBeUndefined();
17
+ expect(env.KUMIKO_SKIP_ES_OPS).toBeUndefined();
18
+ });
19
+
20
+ it("aggregates missing required vars (DATABASE_URL + REDIS_URL)", () => {
21
+ try {
22
+ parseEnv(frameworkCoreEnvSchema, {});
23
+ throw new Error("should have thrown");
24
+ } catch (err) {
25
+ expect(err).toBeInstanceOf(KumikoBootError);
26
+ const boot = err as KumikoBootError;
27
+ const names = boot.errors.map((e) => e.name).sort();
28
+ expect(names).toContain("DATABASE_URL");
29
+ expect(names).toContain("REDIS_URL");
30
+ // PORT has a default → not in the error set
31
+ expect(names).not.toContain("PORT");
32
+ }
33
+ });
34
+
35
+ it("rejects an invalid PORT (non-numeric)", () => {
36
+ try {
37
+ parseEnv(frameworkCoreEnvSchema, {
38
+ DATABASE_URL: "postgres://localhost:5432/db",
39
+ REDIS_URL: "redis://localhost:6379",
40
+ PORT: "not-a-port",
41
+ });
42
+ throw new Error("should have thrown");
43
+ } catch (err) {
44
+ expect(err).toBeInstanceOf(KumikoBootError);
45
+ const port = (err as KumikoBootError).errors.find((e) => e.name === "PORT");
46
+ expect(port?.kind).toBe("invalid");
47
+ }
48
+ });
49
+
50
+ it("KUMIKO_SKIP_ES_OPS accepts any string (matches runtime semantics)", () => {
51
+ // Runtime check is `!== "1"` — non-"1" values are silently ignored.
52
+ // The schema mirrors that: validate string-or-unset, not "1"-only.
53
+ const env = parseEnv(frameworkCoreEnvSchema, {
54
+ DATABASE_URL: "postgres://localhost:5432/db",
55
+ REDIS_URL: "redis://localhost:6379",
56
+ KUMIKO_SKIP_ES_OPS: "true",
57
+ });
58
+ expect(env.KUMIKO_SKIP_ES_OPS).toBe("true");
59
+ });
60
+
61
+ it("composes into an app-wide schema with source attribution", () => {
62
+ const { schema, sources } = composeEnvSchema({
63
+ core: frameworkCoreEnvSchema,
64
+ features: [],
65
+ extend: z.object({ STUDIO_ADMIN_EMAIL: z.email() }),
66
+ });
67
+ expect(sources["DATABASE_URL"]).toBe("framework-core");
68
+ expect(sources["PORT"]).toBe("framework-core");
69
+ expect(sources["STUDIO_ADMIN_EMAIL"]).toBe("app");
70
+
71
+ try {
72
+ parseEnv(schema, {}, { sources });
73
+ } catch (err) {
74
+ const boot = err as KumikoBootError;
75
+ const formatted = boot.format();
76
+ expect(formatted).toContain("✗ DATABASE_URL (framework-core, required, missing)");
77
+ expect(formatted).toContain("✗ REDIS_URL (framework-core, required, missing)");
78
+ expect(formatted).toContain("✗ STUDIO_ADMIN_EMAIL (app, required, missing)");
79
+ }
80
+ });
81
+
82
+ it("z.infer<typeof frameworkCoreEnvSchema> typechecks the expected shape", () => {
83
+ // Compile-time test — typecheck must accept this shape exactly.
84
+ const env: FrameworkCoreEnv = {
85
+ PORT: "3000",
86
+ DATABASE_URL: "postgres://localhost/x",
87
+ REDIS_URL: "redis://localhost",
88
+ KUMIKO_INSTANCE_ID: "pod-7",
89
+ KUMIKO_SKIP_ES_OPS: "1",
90
+ };
91
+ expect(env.PORT).toBe("3000");
92
+ });
93
+ });
@@ -0,0 +1,64 @@
1
+ // Framework-core env-schema — the vars that runProdApp + buildServer
2
+ // + the connection-pool read directly from process.env today. Apps merge
3
+ // this into their app-wide schema via `composeEnvSchema({ core, ... })`.
4
+ //
5
+ // Sprint 9.2 is purely additive: the schema is exposed, but the call-sites
6
+ // in run-prod-app.ts (requireEnv("DATABASE_URL"), …) and api/server.ts
7
+ // (process.env["KUMIKO_INSTANCE_ID"]) keep reading process.env directly.
8
+ // Apps that opt into the schema get aggregated boot-validation errors
9
+ // BEFORE those legacy reads run; apps that don't, behave as before.
10
+ //
11
+ // Feature-specific vars (JWT_SECRET, KUMIKO_SECRETS_MASTER_KEY_*) live
12
+ // in their owning feature's envSchema — Phase 2 (bundled-features).
13
+
14
+ import { z } from "zod";
15
+
16
+ /** Env-vars read by framework-core (api/server, db/connection,
17
+ * dev-server/run-prod-app). NOT including feature-specific vars.
18
+ *
19
+ * PORT-default "3000" is the same fallback as
20
+ * `packages/dev-server/src/run-prod-app.ts:533` — keep in sync when the
21
+ * call-site is refactored to consume parsed env (Sprint 9.5 Phase 4). */
22
+ export const frameworkCoreEnvSchema = z.object({
23
+ PORT: z
24
+ .string()
25
+ .regex(/^\d+$/, "PORT must be a positive integer string")
26
+ .default("3000")
27
+ .describe("HTTP listen port. runProdApp defaults to 3000 when unset."),
28
+
29
+ DATABASE_URL: z
30
+ .url("DATABASE_URL must be a valid postgres:// URL")
31
+ .describe("Primary Postgres connection string (write + read).")
32
+ .meta({ kumiko: { pulumi: { secret: true } } }),
33
+
34
+ REDIS_URL: z
35
+ .url("REDIS_URL must be a valid redis:// URL")
36
+ .describe("Redis connection string for SSE-broker + job-queues.")
37
+ .meta({ kumiko: { pulumi: { secret: true } } }),
38
+
39
+ KUMIKO_INSTANCE_ID: z
40
+ .string()
41
+ .min(1)
42
+ .optional()
43
+ .describe(
44
+ "Stable per-process identifier (pod name, hostname). " +
45
+ "Multi-instance deploys SHOULD set this so per-instance consumers " +
46
+ "(SSE) don't accumulate orphaned cursor-rows on restart.",
47
+ ),
48
+
49
+ // `z.string().optional()` (not `z.literal("1")`) — the run-prod-app
50
+ // call-site (`process.env["KUMIKO_SKIP_ES_OPS"] !== "1"`) ignores any
51
+ // value other than literal "1". A stricter schema would reject e.g.
52
+ // "true" / "yes" that the runtime silently ignores, surfacing
53
+ // boot-errors for inputs the framework doesn't actually care about.
54
+ KUMIKO_SKIP_ES_OPS: z
55
+ .string()
56
+ .optional()
57
+ .describe(
58
+ "Set to '1' to skip event-store ops (seed/migrate) at boot. " +
59
+ "Any other value is treated as 'not set' by run-prod-app. " +
60
+ "Used by integration-test stacks that manage ES-ops out-of-band.",
61
+ ),
62
+ });
63
+
64
+ export type FrameworkCoreEnv = z.infer<typeof frameworkCoreEnvSchema>;
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  type KumikoServerHandle,
33
33
  resolveStylesheet,
34
34
  } from "./create-kumiko-server";
35
+ export { type FrameworkCoreEnv, frameworkCoreEnvSchema } from "./env-schema";
35
36
  export type {
36
37
  AuthoringStyle,
37
38
  BuildFewShotCorpusOptions,
@@ -58,6 +58,12 @@ import {
58
58
  type ApiEntrypointOptions,
59
59
  createApiEntrypoint,
60
60
  } from "@cosmicdrift/kumiko-framework/entrypoint";
61
+ import {
62
+ type ComposedEnvSchema,
63
+ KumikoBootError,
64
+ parseEnv,
65
+ } from "@cosmicdrift/kumiko-framework/env";
66
+ import { type DryRunMode, renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
61
67
  import {
62
68
  createEsOperationsTable,
63
69
  createSeedMigrationContext,
@@ -120,6 +126,48 @@ function readEnv(name: string): string | undefined {
120
126
  return value === undefined || value === "" ? undefined : value;
121
127
  }
122
128
 
129
+ // Parse `KUMIKO_DRY_RUN_ENV=…` into a DryRunMode. Truthy "1" aliases
130
+ // "human" — the most common deploy-Q quick-look. Unknown values are
131
+ // warned-and-ignored: a typo like `=humans` would otherwise look like
132
+ // a confused boot 30 seconds later when the schema fails.
133
+ function parseDryRunMode(raw: string | undefined): DryRunMode | null {
134
+ if (!raw) return null;
135
+ const v = raw.toLowerCase();
136
+ if (v === "1" || v === "true" || v === "human") return "human";
137
+ if (v === "json" || v === "pulumi" || v === "k8s") return v;
138
+ // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
139
+ console.warn(
140
+ `[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
141
+ `(expected 1|human|json|pulumi|k8s); continuing with normal boot.`,
142
+ );
143
+ return null;
144
+ }
145
+
146
+ function defaultBootErrorReporter(err: KumikoBootError): never {
147
+ // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
148
+ console.error(err.format());
149
+ process.exit(1);
150
+ }
151
+
152
+ // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
153
+ // was passed (= test-mode). The handle is intentionally inert — listen()
154
+ // and stop() are no-ops; tests inspect the dry-run console output and
155
+ // move on.
156
+ function makeDryRunHandle(): ProdAppHandle {
157
+ const noop = async () => {
158
+ /* dry-run handle: no server was constructed */
159
+ };
160
+ return {
161
+ // @cast-boundary dry-run-mode: no ApiEntrypoint exists because no
162
+ // boot ran; the handle only surfaces test/CLI inspection and the
163
+ // entrypoint is never reached by callers in dry-run.
164
+ entrypoint: undefined as unknown as ApiEntrypoint,
165
+ fetch: () => new Response("dry-run", { status: 503 }),
166
+ listen: noop,
167
+ stop: noop,
168
+ };
169
+ }
170
+
123
171
  /** Wrapper-API für den Password-Reset-Flow.
124
172
  *
125
173
  * Setup = Feature-Options (PasswordResetOptions = hmacSecret +
@@ -374,9 +422,39 @@ export type RunProdAppOptions = {
374
422
  * createLateBoundHolder + post-boot runtime.initialize in einem
375
423
  * seed-fn (db ist erst nach migrations + features ready). */
376
424
  readonly effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
425
+ /** Composed Zod-schema for env-validation (from `composeEnvSchema({
426
+ * features, extend })` in @cosmicdrift/kumiko-framework/env). When set:
427
+ * - `process.env` is parsed against it BEFORE any boot work; missing
428
+ * or invalid vars throw a `KumikoBootError` listing ALL problems
429
+ * at once (not first-fail).
430
+ * - `KUMIKO_DRY_RUN_ENV=human|json|pulumi|k8s` introspects the schema
431
+ * and prints the env-var inventory, then exits without booting.
432
+ *
433
+ * 9.1 is additive: features that still read `process.env` directly
434
+ * keep working. Migration to the schema is Sprint-9.2-9.5. */
435
+ readonly envSchema?: ComposedEnvSchema;
436
+ /** Prefix for `pulumi config set <prefix><CamelCase(VAR)>` in dry-run
437
+ * output and boot-error suggestions. Without this, suggestions use
438
+ * bare `camelCase(VAR)` and ops has to guess the app prefix. */
439
+ readonly pulumiPrefix?: string;
440
+ /** Handler for KumikoBootError. Default: print formatted error to
441
+ * stderr and `process.exit(1)` so the container restarts with a
442
+ * visible log line. Override in tests that drive runProdApp directly
443
+ * (avoid the exit). Return type is `void` rather than `never` to keep
444
+ * test-overrides honest — if a reporter returns, runProdApp falls
445
+ * through to a regular `throw err` as the safety net. */
446
+ readonly bootErrorReporter?: (err: KumikoBootError) => void;
447
+ /** Override `process.env` for env-validation. Default: `process.env`.
448
+ * Tests use this to feed crafted env-maps without polluting the
449
+ * global. */
450
+ readonly envSource?: Record<string, string | undefined>;
377
451
  };
378
452
 
379
453
  export type ProdAppHandle = {
454
+ /** The composed ApiEntrypoint. In KUMIKO_DRY_RUN_ENV mode WITH
455
+ * `envSource` injected (test path), no boot ran and this slot is an
456
+ * undefined-cast — do not access. Production dry-run hits
457
+ * `process.exit(0)` before returning a handle. */
380
458
  readonly entrypoint: ApiEntrypoint;
381
459
  /** The fetch-handler — wired into Bun.serve in production, called
382
460
  * directly in tests. Composes Hono + static-fallback. */
@@ -390,6 +468,56 @@ export type ProdAppHandle = {
390
468
  };
391
469
 
392
470
  export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
471
+ // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
472
+ // - operators can introspect env-requirements without a real boot
473
+ // (no DB connection needed, KUMIKO_DRY_RUN_ENV=… → render + exit)
474
+ // - missing/invalid env-vars produce a structured KumikoBootError
475
+ // with ALL problems aggregated (not first-fail), before we waste
476
+ // seconds on a Postgres connection that was never configured.
477
+ //
478
+ // Both code paths are no-ops when no envSchema is passed — Sprint-9
479
+ // migration is per-feature additive; pre-migration apps keep the
480
+ // legacy `requireEnv("DATABASE_URL")` checks below.
481
+ //
482
+ // Ordering invariant: this step runs BEFORE the Temporal polyfill,
483
+ // so env-schemas MUST use only Temporal-free Zod types. Don't author
484
+ // `z.iso.date()`/`Temporal.Instant` fields on env-vars — they'd crash
485
+ // at parse-time before the polyfill loads. Plain strings + .regex /
486
+ // .min / .email / .url cover every env-var shape we've actually
487
+ // needed in 9.1's audit (37 references, 25 distinct vars).
488
+ if (options.envSchema) {
489
+ const envSource = options.envSource ?? process.env;
490
+ const dryRunMode = parseDryRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
491
+ if (dryRunMode !== null) {
492
+ // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
493
+ console.log(
494
+ renderDryRun(options.envSchema, dryRunMode, {
495
+ ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
496
+ sources: options.envSchema.sources,
497
+ }),
498
+ );
499
+ // Tests inject envSource and want a return-value, not exit. Detecting
500
+ // "this is a test" via envSource is brittle; instead exit when running
501
+ // against the real process.env (the deploy-flow), return otherwise.
502
+ if (options.envSource === undefined) {
503
+ process.exit(0);
504
+ }
505
+ return makeDryRunHandle();
506
+ }
507
+ try {
508
+ parseEnv(options.envSchema.schema, envSource, {
509
+ sources: options.envSchema.sources,
510
+ ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
511
+ });
512
+ } catch (err) {
513
+ if (err instanceof KumikoBootError) {
514
+ const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
515
+ reporter(err);
516
+ }
517
+ throw err;
518
+ }
519
+ }
520
+
393
521
  // 1. Polyfill before anything else — feature code references Temporal.
394
522
  const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
395
523
  await ensureTemporalPolyfill();