@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 +39 -0
- package/package.json +3 -3
- package/src/__tests__/env-schema.integration.ts +185 -0
- package/src/__tests__/env-schema.test.ts +93 -0
- package/src/env-schema.ts +64 -0
- package/src/index.ts +1 -0
- package/src/run-prod-app.ts +128 -0
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.
|
|
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.
|
|
52
|
-
"@cosmicdrift/kumiko-framework": "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
package/src/run-prod-app.ts
CHANGED
|
@@ -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();
|