@cosmicdrift/kumiko-framework 0.294.0 → 0.295.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.294.0",
3
+ "version": "0.295.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>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.294.0",
202
- "@cosmicdrift/kumiko-types": "0.294.0",
201
+ "@cosmicdrift/kumiko-http": "0.295.0",
202
+ "@cosmicdrift/kumiko-types": "0.295.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.294.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.295.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -28,7 +28,7 @@ describe("runSchemaCli — Temporal polyfill", () => {
28
28
  // never retried on the next run.
29
29
  //
30
30
  // Asserting on globalThis.Temporal directly doesn't work here: the test
31
- // harness's own preload (test-setup/base.preload.ts) already calls
31
+ // harness's own preload (@cosmicdrift/kumiko-testing/preload/temporal) already calls
32
32
  // ensureTemporalPolyfill() once per process, and its idempotency cache
33
33
  // (a module-level flag, not re-derived from globalThis) short-circuits
34
34
  // any later call regardless of what a test does to globalThis.Temporal in
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.294.1",
4
+ "type": "fix",
5
+ "title": "Fix parseEnv for refined env schemas with ciphertext-only Key Manager slots",
6
+ "detail": "Relaxing a `kms` slot that is present only as `<NAME>_CIPHERTEXT` used `schema.extend`, which Zod 4 rejects for object schemas carrying refinements (\"Cannot overwrite keys on object schemas containing refinements\"). An app whose composed env schema ends in `.superRefine(...)` therefore failed at boot as soon as its slots were delivered as ciphertext. The relaxation now uses `safeExtend`, which keeps the refinements running."
7
+ },
2
8
  {
3
9
  "version": "0.294.0",
4
10
  "type": "improvement",
@@ -334,6 +334,23 @@ describe("kms env slots", () => {
334
334
  it("parseEnv still validates a kms slot that is set", () => {
335
335
  expect(() => parseEnv(schema, { MASTER_KEY: "" })).toThrow(KumikoBootError);
336
336
  });
337
+
338
+ describe("schema carrying a refinement", () => {
339
+ const refined = schema.superRefine((value, ctx) => {
340
+ if (value.PLAIN === "forbidden") {
341
+ ctx.addIssue({ code: "custom", path: ["PLAIN"], message: "PLAIN is forbidden" });
342
+ }
343
+ });
344
+
345
+ it("parseEnv relaxes a ciphertext-only slot without dropping the refinement", () => {
346
+ expect(parseEnv(refined, { MASTER_KEY_CIPHERTEXT: "abc" })["MASTER_KEY_CIPHERTEXT"]).toBe(
347
+ "abc",
348
+ );
349
+ expect(() => parseEnv(refined, { MASTER_KEY_CIPHERTEXT: "abc", PLAIN: "forbidden" })).toThrow(
350
+ KumikoBootError,
351
+ );
352
+ });
353
+ });
337
354
  });
338
355
 
339
356
  describe("composeEnvSchema kms twins", () => {
package/src/env/index.ts CHANGED
@@ -76,7 +76,8 @@ export function kmsSlotsOf(schema: z.ZodObject<z.ZodRawShape>): readonly string[
76
76
 
77
77
  // A ciphertext-only slot is satisfied by its `_CIPHERTEXT` twin: the plaintext
78
78
  // only exists after the boot-time decrypt, so requiring it here would reject
79
- // exactly the deployment this meta enables.
79
+ // exactly the deployment this meta enables. safeExtend, not extend: Zod 4 throws
80
+ // on extend for schemas carrying refinements (app-level superRefine).
80
81
  function relaxCiphertextOnlySlots<S extends z.ZodObject<z.ZodRawShape>>(
81
82
  schema: S,
82
83
  env: Readonly<Record<string, string>>,
@@ -88,7 +89,7 @@ function relaxCiphertextOnlySlots<S extends z.ZodObject<z.ZodRawShape>>(
88
89
  if (field && env[name] === undefined && env[`${name}_CIPHERTEXT`])
89
90
  relaxed[name] = field.optional();
90
91
  }
91
- return Object.keys(relaxed).length === 0 ? schema : schema.extend(relaxed);
92
+ return Object.keys(relaxed).length === 0 ? schema : schema.safeExtend(relaxed);
92
93
  }
93
94
 
94
95
  // --- Field-classification helpers (Zod v4 introspection) ---
@@ -42,7 +42,7 @@ let indexPrefix: string;
42
42
  describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
43
43
  beforeAll(async () => {
44
44
  client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
45
- indexPrefix = `test_${uuid().slice(-6)}_`;
45
+ indexPrefix = `test_${uuid()}_`;
46
46
  adapter = createMeilisearchAdapter({
47
47
  url: MEILI_URL,
48
48
  apiKey: MEILI_KEY,
@@ -110,10 +110,9 @@ describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
110
110
  });
111
111
 
112
112
  afterAll(async () => {
113
- // Clean up all test indices
114
113
  const indices = await client.getIndexes();
115
114
  for (const idx of indices.results) {
116
- if (idx.uid.startsWith("test_")) {
115
+ if (idx.uid.startsWith(indexPrefix)) {
117
116
  try {
118
117
  await client.index(idx.uid).delete().waitTask();
119
118
  } catch {
package/src/stack/db.ts CHANGED
@@ -12,7 +12,7 @@ function requireEnv(name: string): string {
12
12
  const value = process.env[name];
13
13
  if (!value) {
14
14
  throw new Error(
15
- `Missing required env var: ${name}. Copy .env.example to .env and fill in values.`,
15
+ `Missing required env var: ${name}. Copy .env.example to .env and fill in values. Tests also need the test services (Postgres, Redis) running, e.g. "docker compose up -d".`,
16
16
  );
17
17
  }
18
18
  return value;
@@ -0,0 +1,54 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { requireRealProviders } from "../real-providers";
3
+
4
+ const CI = "CI";
5
+ const FLAG = "KUMIKO_REAL_PROVIDERS";
6
+ const API_KEY = "ANTHROPIC_API_KEY";
7
+ const TOUCHED_ENV = [CI, FLAG, API_KEY];
8
+
9
+ describe("requireRealProviders", () => {
10
+ const saved = new Map<string, string | undefined>();
11
+
12
+ beforeEach(() => {
13
+ for (const name of TOUCHED_ENV) {
14
+ saved.set(name, process.env[name]);
15
+ delete process.env[name];
16
+ }
17
+ });
18
+
19
+ afterEach(() => {
20
+ for (const [name, value] of saved) {
21
+ if (value === undefined) delete process.env[name];
22
+ else process.env[name] = value;
23
+ }
24
+ });
25
+
26
+ test("throws when the flag is missing, even with an API key set", () => {
27
+ process.env[API_KEY] = "sk-test";
28
+ expect(() => requireRealProviders()).toThrow(/KUMIKO_REAL_PROVIDERS=1/);
29
+ });
30
+
31
+ test("throws when the flag has any value other than 1", () => {
32
+ process.env[FLAG] = "true";
33
+ expect(() => requireRealProviders()).toThrow(/KUMIKO_REAL_PROVIDERS=1/);
34
+ });
35
+
36
+ test("throws in CI even with the flag set", () => {
37
+ process.env[CI] = "true";
38
+ process.env[FLAG] = "1";
39
+ expect(() => requireRealProviders()).toThrow(/never run in CI/);
40
+ });
41
+
42
+ test("does not throw with the flag set outside CI", () => {
43
+ process.env[FLAG] = "1";
44
+ expect(() => requireRealProviders()).not.toThrow();
45
+ });
46
+
47
+ test("treats CI=false and CI=0 as not CI", () => {
48
+ process.env[FLAG] = "1";
49
+ process.env[CI] = "false";
50
+ expect(() => requireRealProviders()).not.toThrow();
51
+ process.env[CI] = "0";
52
+ expect(() => requireRealProviders()).not.toThrow();
53
+ });
54
+ });
@@ -44,6 +44,7 @@ export {
44
44
  createRecordingProvider,
45
45
  type RecordingProvider,
46
46
  } from "./observability-recorder";
47
+ export { requireRealProviders } from "./real-providers";
47
48
  export { deleteRows, seedRow, seedRows, updateRows } from "./seed";
48
49
  export {
49
50
  sharedItemEntity,
@@ -0,0 +1,20 @@
1
+ const REAL_PROVIDERS_FLAG = "KUMIKO_REAL_PROVIDERS";
2
+ const CI_FLAG = "CI";
3
+
4
+ function isCi(): boolean {
5
+ const ci = process.env[CI_FLAG];
6
+ return ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
7
+ }
8
+
9
+ export function requireRealProviders(): void {
10
+ if (isCi()) {
11
+ throw new Error(
12
+ `Real-provider tests never run in CI (CI is set). Run them locally with ${REAL_PROVIDERS_FLAG}=1 via the test:real / e2e:real script.`,
13
+ );
14
+ }
15
+ if (process.env[REAL_PROVIDERS_FLAG] !== "1") {
16
+ throw new Error(
17
+ `Real-provider test needs ${REAL_PROVIDERS_FLAG}=1 (an API key alone never enables it). Run it via the test:real / e2e:real script.`,
18
+ );
19
+ }
20
+ }