@cosmicdrift/kumiko-framework 0.292.0 → 0.294.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.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/schema-cli-rebuild.integration.test.ts +0 -2
  3. package/src/changes.json +12 -0
  4. package/src/crypto/__tests__/kek-source.test.ts +143 -0
  5. package/src/crypto/kek-source.ts +34 -43
  6. package/src/db/__tests__/apply-entity-event-tenant.integration.test.ts +0 -2
  7. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +1 -2
  8. package/src/db/__tests__/blind-index.integration.test.ts +0 -2
  9. package/src/db/__tests__/entity-field-encryption.test.ts +29 -0
  10. package/src/db/__tests__/event-store-executor-list.integration.test.ts +0 -2
  11. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +0 -2
  12. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +0 -2
  13. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +0 -2
  14. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +0 -2
  15. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -2
  16. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +0 -2
  17. package/src/db/__tests__/unique-violation-mapping.integration.test.ts +0 -2
  18. package/src/db/entity-field-encryption.ts +5 -3
  19. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +0 -2
  20. package/src/engine/boot-validator/index.ts +5 -1
  21. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -2
  22. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -2
  23. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -2
  24. package/src/env/__tests__/compose-env-schema.test.ts +97 -1
  25. package/src/env/index.ts +63 -3
  26. package/src/es-ops/__tests__/context.integration.test.ts +0 -2
  27. package/src/es-ops/__tests__/runner.integration.test.ts +0 -2
  28. package/src/event-store/__tests__/admin-api.integration.test.ts +1 -2
  29. package/src/event-store/__tests__/backfill-pii.integration.test.ts +0 -2
  30. package/src/event-store/__tests__/event-store.integration.test.ts +0 -2
  31. package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +1 -2
  32. package/src/event-store/__tests__/perf.integration.test.ts +1 -8
  33. package/src/event-store/__tests__/provenance-append.integration.test.ts +1 -2
  34. package/src/event-store/__tests__/snapshot.integration.test.ts +0 -2
  35. package/src/event-store/__tests__/upcaster-dead-letter.integration.test.ts +1 -2
  36. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -2
  37. package/src/files/__tests__/files.integration.test.ts +1 -2
  38. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -2
  39. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +0 -2
  40. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +0 -2
  41. package/src/pipeline/__tests__/cascade-handler.integration.test.ts +0 -2
  42. package/src/pipeline/__tests__/perf-rebuild.integration.test.ts +0 -2
  43. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +1 -2
  44. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -2
  45. package/src/search/__tests__/reindex-entity.integration.test.ts +0 -2
  46. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.292.0",
3
+ "version": "0.294.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.292.0",
202
- "@cosmicdrift/kumiko-types": "0.292.0",
201
+ "@cosmicdrift/kumiko-http": "0.294.0",
202
+ "@cosmicdrift/kumiko-types": "0.294.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.292.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.294.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -18,7 +18,6 @@ import { buildEntityTable } from "../db/table-builder";
18
18
  import { createTenantDb, type TenantDb } from "../db/tenant-db";
19
19
  import { createEntity, createTextField, defineApply, defineFeature } from "../engine";
20
20
  import type { ProjectionDefinition } from "../engine/types";
21
- import { createEventsTable } from "../event-store";
22
21
  import { createProjectionStateTable } from "../pipeline";
23
22
  import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
24
23
  import {
@@ -101,7 +100,6 @@ beforeAll(async () => {
101
100
  await ensureTemporalPolyfill();
102
101
  testDb = await createTestDb();
103
102
  await unsafeCreateEntityTable(testDb.db, itemEntity, "apply-item");
104
- await createEventsTable(testDb.db);
105
103
  await createProjectionStateTable(testDb.db);
106
104
  await unsafePushTables(testDb.db, { applyItemsPerGroup: counterTable });
107
105
  tdb = createTenantDb(testDb.db, admin.tenantId);
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.294.0",
4
+ "type": "improvement",
5
+ "title": "Declare Key Manager ciphertext slots in the env schema",
6
+ "detail": "An env field can carry `.meta({ kumiko: { kms: true } })` to be deliverable as `<NAME>_CIPHERTEXT`. `runProdApp` now decrypts the schema-declared slots once at boot (`kmsSlotsOf(schema)` → `resolvePlatformKeks({ slots })`) and hands the resolved env to the boot-time master-key probe (`ValidateBootOptions.env`) and to the master-key keyring, so a ciphertext-only `KUMIKO_SECRETS_MASTER_KEY_V<n>` boots. `parseEnv` treats a `kms` slot as satisfied by its ciphertext twin. `composeEnvSchema` declares the `<NAME>_CIPHERTEXT` twin of every `kms` field itself (an already declared twin is kept), so a versioned family needs one declaration per version. `KUMIKO_SECRETS_MASTER_KEY_V1` of the secrets feature is such a slot. `runWorkerApp` takes the same slots as `kmsSlots` (it parses no schema). A deployment missing `KUMIKO_SECRETS_MASTER_KEY_V1` but carrying its `_CIPHERTEXT` twin now passes `parseEnv` and fails at the master-key probe if the decrypt yields nothing, instead of failing in `parseEnv`. `resolvePlatformKeks` without `slots` keeps resolving the three platform slots until consumers declare theirs."
7
+ },
8
+ {
9
+ "version": "0.293.0",
10
+ "type": "improvement",
11
+ "title": "Resolve the blind-index key from a Key Manager ciphertext",
12
+ "detail": "resolvePlatformKeks walks an allowlist of slots (PLATFORM_KEK, PLATFORM_KEK_PREVIOUS, KUMIKO_BLIND_INDEX_KEY) instead of two hardcoded KEK slots, so each slot's <SLOT>_CIPHERTEXT is unwrapped at boot. The blind-index key can leave the pod env in the clear with no app-code change, because resolveKmsWiringAsync already resolves before its trio check. Ciphertexts outside the allowlist are ignored rather than resolved: a foreign *_CIPHERTEXT must never fail boot."
13
+ },
2
14
  {
3
15
  "version": "0.292.0",
4
16
  "type": "fix",
@@ -295,4 +295,147 @@ describe("resolvePlatformKeks", () => {
295
295
 
296
296
  expect(lines).toEqual([]);
297
297
  });
298
+
299
+ test("decrypts KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT into KUMIKO_BLIND_INDEX_KEY", async () => {
300
+ const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
301
+ const env: KekSourceEnv = {
302
+ KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_A,
303
+ PLATFORM_KEK_KMS_KEY_ID: "key-1",
304
+ PLATFORM_KEK_KMS_TOKEN: TOKEN,
305
+ };
306
+
307
+ const result = await resolvePlatformKeks(env, { fetch });
308
+
309
+ expect(result.KUMIKO_BLIND_INDEX_KEY).toBe(PLAINTEXT_A);
310
+ expect(calls.length).toBe(1);
311
+ });
312
+
313
+ test("a plaintext KUMIKO_BLIND_INDEX_KEY wins over its ciphertext, no fetch", async () => {
314
+ const { fetch, calls } = trackedFetch([]);
315
+ const env: KekSourceEnv = {
316
+ KUMIKO_BLIND_INDEX_KEY: "blind-index-plaintext",
317
+ KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_A,
318
+ };
319
+
320
+ const result = await resolvePlatformKeks(env, { fetch });
321
+
322
+ expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
323
+ expect(calls.length).toBe(0);
324
+ });
325
+
326
+ test("ignores a ciphertext outside the allowlist entirely", async () => {
327
+ const { fetch, calls } = trackedFetch([]);
328
+ const env: KekSourceEnv = {
329
+ FOO_CIPHERTEXT: CIPHERTEXT_A,
330
+ PLATFORM_KEK_KMS_KEY_ID: "key-1",
331
+ PLATFORM_KEK_KMS_TOKEN: TOKEN,
332
+ };
333
+
334
+ const result = await resolvePlatformKeks(env, { fetch });
335
+
336
+ expect(calls.length).toBe(0);
337
+ expect(result["FOO"]).toBeUndefined();
338
+ expect(result).toBe(env);
339
+ });
340
+
341
+ test("decrypts PLATFORM_KEK and KUMIKO_BLIND_INDEX_KEY ciphertexts together", async () => {
342
+ const { fetch, calls } = trackedFetch([
343
+ jsonResponse(200, { plaintext: "active-plaintext" }),
344
+ jsonResponse(200, { plaintext: "blind-index-plaintext" }),
345
+ ]);
346
+ const env: KekSourceEnv = {
347
+ PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
348
+ KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_B,
349
+ PLATFORM_KEK_KMS_KEY_ID: "key-1",
350
+ PLATFORM_KEK_KMS_TOKEN: TOKEN,
351
+ };
352
+
353
+ const result = await resolvePlatformKeks(env, { fetch });
354
+
355
+ expect(result.PLATFORM_KEK).toBe("active-plaintext");
356
+ expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
357
+ expect(calls.length).toBe(2);
358
+ });
359
+
360
+ describe("with schema-declared slots", () => {
361
+ const KEY_MANAGER = { PLATFORM_KEK_KMS_KEY_ID: "key-1", PLATFORM_KEK_KMS_TOKEN: TOKEN };
362
+
363
+ test("resolves a versioned master-key family without the framework knowing the versions", async () => {
364
+ const { fetch, calls } = trackedFetch([
365
+ jsonResponse(200, { plaintext: "v1-plaintext" }),
366
+ jsonResponse(200, { plaintext: "v2-plaintext" }),
367
+ ]);
368
+ const env: KekSourceEnv = {
369
+ KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: CIPHERTEXT_A,
370
+ KUMIKO_SECRETS_MASTER_KEY_V2_CIPHERTEXT: CIPHERTEXT_B,
371
+ ...KEY_MANAGER,
372
+ };
373
+
374
+ const result = await resolvePlatformKeks(env, {
375
+ fetch,
376
+ slots: ["KUMIKO_SECRETS_MASTER_KEY_V1", "KUMIKO_SECRETS_MASTER_KEY_V2"],
377
+ });
378
+
379
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V1"]).toBe("v1-plaintext");
380
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V2"]).toBe("v2-plaintext");
381
+ expect(calls.length).toBe(2);
382
+ });
383
+
384
+ test("a plaintext beats its ciphertext per slot, the other slot still resolves", async () => {
385
+ const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: "v2-plaintext" })]);
386
+ const env: KekSourceEnv = {
387
+ KUMIKO_SECRETS_MASTER_KEY_V1: "v1-plaintext",
388
+ KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: CIPHERTEXT_A,
389
+ KUMIKO_SECRETS_MASTER_KEY_V2_CIPHERTEXT: CIPHERTEXT_B,
390
+ ...KEY_MANAGER,
391
+ };
392
+
393
+ const result = await resolvePlatformKeks(env, {
394
+ fetch,
395
+ slots: ["KUMIKO_SECRETS_MASTER_KEY_V1", "KUMIKO_SECRETS_MASTER_KEY_V2"],
396
+ });
397
+
398
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V1"]).toBe("v1-plaintext");
399
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V2"]).toBe("v2-plaintext");
400
+ expect(calls.length).toBe(1);
401
+ });
402
+
403
+ test("does not touch the platform slots that the caller did not name", async () => {
404
+ const { fetch, calls } = trackedFetch([]);
405
+ const env: KekSourceEnv = { PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A, ...KEY_MANAGER };
406
+
407
+ const result = await resolvePlatformKeks(env, { fetch, slots: [] });
408
+
409
+ expect(result).toBe(env);
410
+ expect(calls.length).toBe(0);
411
+ });
412
+
413
+ test("logs the source line for a declared slot", async () => {
414
+ const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "v1-plaintext" })]);
415
+ const lines: string[] = [];
416
+ const env: KekSourceEnv = {
417
+ KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: CIPHERTEXT_A,
418
+ ...KEY_MANAGER,
419
+ };
420
+
421
+ await resolvePlatformKeks(env, {
422
+ fetch,
423
+ slots: ["KUMIKO_SECRETS_MASTER_KEY_V1"],
424
+ log: (line) => lines.push(line),
425
+ });
426
+
427
+ expect(lines).toEqual([
428
+ "KUMIKO_SECRETS_MASTER_KEY_V1 source=key-manager keyId=key-1 region=fr-par",
429
+ ]);
430
+ });
431
+
432
+ test("still rejects a previous KEK without its version when that slot is declared", async () => {
433
+ const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "previous-plaintext" })]);
434
+ const env: KekSourceEnv = { PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B, ...KEY_MANAGER };
435
+
436
+ await expect(
437
+ resolvePlatformKeks(env, { fetch, slots: ["PLATFORM_KEK_PREVIOUS"] }),
438
+ ).rejects.toThrow(/PLATFORM_KEK_PREVIOUS_VERSION must be set/);
439
+ });
440
+ });
298
441
  });
@@ -1,12 +1,16 @@
1
- // Resolves PLATFORM_KEK / PLATFORM_KEK_PREVIOUS from a Key Manager ciphertext
2
- // when no plaintext is set, so the KEK need not sit in the pod env in the
3
- // clear. `PLATFORM_KEK` stays the source of truth: a plaintext value always
4
- // wins over its ciphertext sibling, with no request made at all.
1
+ // Resolves a caller-named set of secret slots from a Key Manager ciphertext
2
+ // when no plaintext is set, so they need not sit in the pod env in the clear.
3
+ // A slot's plaintext always wins over its ciphertext sibling, with no request
4
+ // made at all. Any `*_CIPHERTEXT` in the env that names no requested slot is
5
+ // ignored — a foreign ciphertext must never fail boot.
5
6
 
6
7
  const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
7
8
  const DEFAULT_REGION = "fr-par";
8
9
  const DECRYPT_TIMEOUT_MS = 5_000;
9
10
  const RETRY_DELAYS_MS = [200, 800];
11
+ // Used when the caller names no slots. Apps that predate `kms` schema meta
12
+ // rely on it; drop once every consumer declares its slots in the env schema.
13
+ const LEGACY_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"] as const;
10
14
 
11
15
  export type KekSourceEnv = {
12
16
  readonly PLATFORM_KEK?: string | undefined;
@@ -17,6 +21,8 @@ export type KekSourceEnv = {
17
21
  readonly PLATFORM_KEK_KMS_KEY_ID?: string | undefined;
18
22
  readonly PLATFORM_KEK_KMS_TOKEN?: string | undefined;
19
23
  readonly PLATFORM_KEK_KMS_REGION?: string | undefined;
24
+ readonly KUMIKO_BLIND_INDEX_KEY?: string | undefined;
25
+ readonly KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT?: string | undefined;
20
26
  readonly [key: string]: string | undefined;
21
27
  };
22
28
 
@@ -25,6 +31,9 @@ export type KekSourceOptions = {
25
31
  readonly logPrefix?: string;
26
32
  /** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
27
33
  readonly log?: (message: string) => void;
34
+ /** Env names to resolve, e.g. `kmsSlotsOf(schema)`. Defaults to the three
35
+ * platform slots. */
36
+ readonly slots?: readonly string[];
28
37
  };
29
38
 
30
39
  function isRetryableStatus(status: number): boolean {
@@ -97,13 +106,14 @@ async function decryptCiphertext(
97
106
  }
98
107
 
99
108
  async function resolveSlot(
100
- plaintext: string | undefined,
101
- ciphertext: string | undefined,
109
+ name: string,
102
110
  env: KekSourceEnv,
103
111
  options: KekSourceOptions,
104
112
  fetchImpl: typeof globalThis.fetch,
105
113
  ): Promise<string | undefined> {
114
+ const plaintext = env[name];
106
115
  if (plaintext) return plaintext;
116
+ const ciphertext = env[`${name}_CIPHERTEXT`];
107
117
  if (!ciphertext) return undefined;
108
118
 
109
119
  const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
@@ -111,7 +121,7 @@ async function resolveSlot(
111
121
  if (!keyId || !token) {
112
122
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
113
123
  throw new Error(
114
- `${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext — a partial set means the KMS wiring is broken.`,
124
+ `${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext (slot ${name}) — a partial set means the KMS wiring is broken.`,
115
125
  );
116
126
  }
117
127
  const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
@@ -121,12 +131,9 @@ async function resolveSlot(
121
131
  // A leftover plaintext beside a ciphertext boots green while nothing was
122
132
  // migrated, which is indistinguishable from a finished cutover unless the
123
133
  // boot says which source won. Never carries a key value, only its origin.
124
- function describeKekSource(
125
- name: string,
126
- plaintext: string | undefined,
127
- ciphertext: string | undefined,
128
- env: KekSourceEnv,
129
- ): string | undefined {
134
+ function describeKekSource(name: string, env: KekSourceEnv): string | undefined {
135
+ const plaintext = env[name];
136
+ const ciphertext = env[`${name}_CIPHERTEXT`];
130
137
  if (plaintext) {
131
138
  return ciphertext
132
139
  ? `${name} source=plaintext-env (ciphertext present and ignored)`
@@ -138,52 +145,36 @@ function describeKekSource(
138
145
  }
139
146
 
140
147
  // Each slot resolves independently so a rollback that clears one slot's
141
- // plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks the
142
- // other slot's fallback path — the trio check downstream still applies.
148
+ // plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
149
+ // another slot's fallback path — the trio check downstream still applies.
143
150
  export async function resolvePlatformKeks(
144
151
  env: KekSourceEnv,
145
152
  options: KekSourceOptions = {},
146
153
  ): Promise<KekSourceEnv> {
147
154
  const fetchImpl = options.fetch ?? globalThis.fetch;
148
155
 
149
- const active = await resolveSlot(
150
- env.PLATFORM_KEK,
151
- env.PLATFORM_KEK_CIPHERTEXT,
152
- env,
153
- options,
154
- fetchImpl,
155
- );
156
- const previous = await resolveSlot(
157
- env.PLATFORM_KEK_PREVIOUS,
158
- env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
159
- env,
160
- options,
161
- fetchImpl,
162
- );
156
+ const slots = options.slots ?? LEGACY_SLOTS;
157
+ const resolved: Record<string, string | undefined> = {};
158
+ for (const name of slots) {
159
+ resolved[name] = await resolveSlot(name, env, options, fetchImpl);
160
+ }
163
161
 
164
162
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
165
163
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
166
164
  const log = options.log ?? console.info;
167
- for (const line of [
168
- describeKekSource("PLATFORM_KEK", env.PLATFORM_KEK, env.PLATFORM_KEK_CIPHERTEXT, env),
169
- describeKekSource(
170
- "PLATFORM_KEK_PREVIOUS",
171
- env.PLATFORM_KEK_PREVIOUS,
172
- env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
173
- env,
174
- ),
175
- ]) {
165
+ for (const name of slots) {
166
+ const line = describeKekSource(name, env);
176
167
  if (line) log(`${prefix}${line}`);
177
168
  }
178
169
 
179
- if (previous && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
170
+ if (resolved["PLATFORM_KEK_PREVIOUS"] && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
180
171
  throw new Error(
181
172
  `${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`,
182
173
  );
183
174
  }
184
175
 
185
- if (active === env.PLATFORM_KEK && previous === env.PLATFORM_KEK_PREVIOUS) {
186
- return env;
187
- }
188
- return { ...env, PLATFORM_KEK: active, PLATFORM_KEK_PREVIOUS: previous };
176
+ const changed = slots.some((name) => resolved[name] !== env[name]);
177
+ if (!changed) return env;
178
+
179
+ return { ...env, ...resolved };
189
180
  }
@@ -21,7 +21,6 @@ import { asRawClient, selectMany } from "../../db/query";
21
21
  import { createEntity, createTextField } from "../../engine/factories";
22
22
  import type { TenantId } from "../../engine/types";
23
23
  import type { StoredEvent } from "../../event-store";
24
- import { createEventsTable } from "../../event-store";
25
24
  import { ensureTemporalPolyfill } from "../../time/polyfill";
26
25
  import { applyEntityEvent } from "../apply-entity-event";
27
26
  import { buildEntityTable } from "../table-builder";
@@ -39,7 +38,6 @@ let testDb: BunTestDb;
39
38
  beforeAll(async () => {
40
39
  await ensureTemporalPolyfill();
41
40
  testDb = await createTestDb();
42
- await createEventsTable(testDb.db);
43
41
  await asRawClient(testDb.db).unsafe(`
44
42
  CREATE TABLE read_apply_tenant_check (
45
43
  id uuid PRIMARY KEY,
@@ -14,7 +14,7 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
14
14
  import { asRawClient, insertOne, selectMany } from "../../db/query";
15
15
  import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
16
16
  import { createRegistry } from "../../engine/registry";
17
- import { archiveStream, createArchivedStreamsTable, createEventsTable } from "../../event-store";
17
+ import { archiveStream, createArchivedStreamsTable } from "../../event-store";
18
18
  import { rebuildProjection } from "../../pipeline";
19
19
  import { createProjectionStateTable } from "../../pipeline/projection-state";
20
20
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
@@ -49,7 +49,6 @@ beforeAll(async () => {
49
49
  await ensureTemporalPolyfill();
50
50
  testDb = await createTestDb();
51
51
  await unsafeCreateEntityTable(testDb.db, userEntity, "user");
52
- await createEventsTable(testDb.db);
53
52
  await createArchivedStreamsTable(testDb.db);
54
53
  await createProjectionStateTable(testDb.db);
55
54
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
@@ -20,7 +20,6 @@ import {
20
20
  import { defineFeature } from "../../engine/define-feature";
21
21
  import { createEntity, createTextField } from "../../engine/factories";
22
22
  import { createRegistry } from "../../engine/registry";
23
- import { createEventsTable } from "../../event-store";
24
23
  import { rebuildProjection } from "../../pipeline";
25
24
  import { createProjectionStateTable } from "../../pipeline/projection-state";
26
25
  import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
@@ -65,7 +64,6 @@ const adminUser = TestUsers.admin;
65
64
  beforeAll(async () => {
66
65
  testDb = await createTestDb();
67
66
  await unsafeCreateEntityTable(testDb.db, personEntity, "person");
68
- await createEventsTable(testDb.db);
69
67
  await createProjectionStateTable(testDb.db);
70
68
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
71
69
  });
@@ -5,6 +5,7 @@ import {
5
5
  collectEncryptedFieldNames,
6
6
  decryptEntityFieldValues,
7
7
  encryptEntityFieldValues,
8
+ validateEntityFieldEncryptionAvailable,
8
9
  } from "../entity-field-encryption";
9
10
 
10
11
  const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
@@ -43,3 +44,31 @@ describe("entity-field-encryption", () => {
43
44
  expect(stored["secretNote"]).not.toBe("note");
44
45
  });
45
46
  });
47
+
48
+ describe("validateEntityFieldEncryptionAvailable", () => {
49
+ test("accepts a keyring made only of the env it is handed", () => {
50
+ expect(() =>
51
+ validateEntityFieldEncryptionAvailable({ KUMIKO_SECRETS_MASTER_KEY_V1: TEST_KEY }),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ test("rejects a handed env without any master key, whatever process.env holds", () => {
56
+ const previous = process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
57
+ process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = TEST_KEY;
58
+ try {
59
+ expect(() => validateEntityFieldEncryptionAvailable({})).toThrow(/no usable master key/);
60
+ } finally {
61
+ if (previous === undefined) delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
62
+ else process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = previous;
63
+ }
64
+ });
65
+
66
+ test("a pinned CURRENT_VERSION without that version's key still fails", () => {
67
+ expect(() =>
68
+ validateEntityFieldEncryptionAvailable({
69
+ KUMIKO_SECRETS_MASTER_KEY_V1: TEST_KEY,
70
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
71
+ }),
72
+ ).toThrow(/no usable master key/);
73
+ });
74
+ });
@@ -15,7 +15,6 @@ import {
15
15
  SYSTEM_TENANT_ID,
16
16
  } from "../../engine";
17
17
  import { UnprocessableError } from "../../errors";
18
- import { createEventsTable } from "../../event-store";
19
18
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
20
19
  import { ensureTemporalPolyfill } from "../../time/polyfill";
21
20
  import { encodeCursor } from "../cursor";
@@ -46,7 +45,6 @@ beforeAll(async () => {
46
45
  await ensureTemporalPolyfill();
47
46
  testDb = await createTestDb();
48
47
  await unsafeCreateEntityTable(testDb.db, entity, "pagerItem");
49
- await createEventsTable(testDb.db);
50
48
  tdb = createTenantDb(testDb.db, admin.tenantId);
51
49
  });
52
50
 
@@ -9,7 +9,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
9
9
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
10
10
  import { asRawClient } from "../../db/query";
11
11
  import { createEntity, createMoneyField, createTextField, from } from "../../engine";
12
- import { createEventsTable } from "../../event-store";
13
12
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
14
13
  import { createTestEnvelopeCipher } from "../../testing";
15
14
  import { ensureTemporalPolyfill } from "../../time/polyfill";
@@ -59,7 +58,6 @@ beforeAll(async () => {
59
58
  await ensureTemporalPolyfill();
60
59
  testDb = await createTestDb();
61
60
  await unsafeCreateEntityTable(testDb.db, entity, "moneyOrder");
62
- await createEventsTable(testDb.db);
63
61
  tdb = createTenantDb(testDb.db, admin.tenantId);
64
62
  configureEntityFieldEncryption(cipher);
65
63
  });
@@ -11,7 +11,6 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
11
11
  import { asRawClient } from "../../db/query";
12
12
  import { createEntity, createTextField, from } from "../../engine";
13
13
  import type { EntityDefinition } from "../../engine/types";
14
- import { createEventsTable } from "../../event-store";
15
14
  import { createInMemorySearchAdapter } from "../../search";
16
15
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
17
16
  import { seedRows } from "../../testing";
@@ -156,7 +155,6 @@ beforeAll(async () => {
156
155
  await unsafeCreateEntityTable(testDb.db, piiCustomerEntity, "refSearchPiiCustomer");
157
156
  await unsafeCreateEntityTable(testDb.db, piiOrderEntity, "refSearchPiiOrder");
158
157
  await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
159
- await createEventsTable(testDb.db);
160
158
  tdbA = createTenantDb(testDb.db, admin.tenantId);
161
159
  tdbB = createTenantDb(testDb.db, otherTenantAdmin.tenantId);
162
160
  });
@@ -9,7 +9,6 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
9
9
  import { asRawClient } from "../../db/query";
10
10
  import { createEntity, createTextField } from "../../engine";
11
11
  import type { EntityDefinition } from "../../engine/types";
12
- import { createEventsTable } from "../../event-store";
13
12
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
14
13
  import { seedRows } from "../../testing";
15
14
  import { ensureTemporalPolyfill } from "../../time/polyfill";
@@ -107,7 +106,6 @@ beforeAll(async () => {
107
106
  await unsafeCreateEntityTable(testDb.db, customerEntity, "refSortCustomer");
108
107
  await unsafeCreateEntityTable(testDb.db, orderEntity, "refSortOrder");
109
108
  await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSortRestrictedOrder");
110
- await createEventsTable(testDb.db);
111
109
  tdbA = createTenantDb(testDb.db, admin.tenantId);
112
110
  });
113
111
 
@@ -9,7 +9,6 @@ import { asRawClient, transaction } from "../../db/query";
9
9
  import { createEntity, createTextField } from "../../engine";
10
10
  import { from } from "../../engine/ownership";
11
11
  import { createSystemUser } from "../../engine/system-user";
12
- import { createEventsTable } from "../../event-store";
13
12
  import type { EntityCache } from "../../pipeline/entity-cache";
14
13
  import {
15
14
  createTestDb,
@@ -29,7 +28,6 @@ const nonAdmin = TestUsers.user;
29
28
 
30
29
  beforeAll(async () => {
31
30
  testDb = await createTestDb();
32
- await createEventsTable(testDb.db);
33
31
  tdb = createTenantDb(testDb.db, admin.tenantId);
34
32
  });
35
33
 
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
2
2
  import { InMemoryKmsAdapter, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL } from "../../crypto";
3
3
  import { asRawClient } from "../../db/query";
4
4
  import { createBooleanField, createEntity, createTextField } from "../../engine";
5
- import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
5
+ import { append, loadEventsAfterVersion } from "../../event-store";
6
6
  import type { EntityCache } from "../../pipeline/entity-cache";
7
7
  import {
8
8
  createTestDb,
@@ -41,7 +41,6 @@ const adminUser = TestUsers.admin;
41
41
  beforeAll(async () => {
42
42
  testDb = await createTestDb();
43
43
  await unsafeCreateEntityTable(testDb.db, entity, "esExecUser");
44
- await createEventsTable(testDb.db);
45
44
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
46
45
  });
47
46
 
@@ -19,7 +19,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr
19
19
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
20
20
  import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
21
21
  import { createRegistry } from "../../engine/registry";
22
- import { createEventsTable } from "../../event-store";
23
22
  import { rebuildProjection } from "../../pipeline";
24
23
  import { createProjectionStateTable } from "../../pipeline/projection-state";
25
24
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
@@ -52,7 +51,6 @@ beforeAll(async () => {
52
51
  await ensureTemporalPolyfill();
53
52
  testDb = await createTestDb();
54
53
  await unsafeCreateEntityTable(testDb.db, userEntity, "user");
55
- await createEventsTable(testDb.db);
56
54
  await createProjectionStateTable(testDb.db);
57
55
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
58
56
  });
@@ -17,7 +17,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
17
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
18
  import { asRawClient, selectMany, transaction } from "../../db/query";
19
19
  import { createEntity, createTextField } from "../../engine";
20
- import { createEventsTable } from "../../event-store";
21
20
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
22
21
  import { ensureTemporalPolyfill } from "../../time/polyfill";
23
22
  import { createEventStoreExecutor } from "../event-store-executor";
@@ -52,7 +51,6 @@ beforeAll(async () => {
52
51
  await ensureTemporalPolyfill();
53
52
  testDb = await createTestDb();
54
53
  await unsafeCreateEntityTable(testDb.db, userEntity, "unique-user");
55
- await createEventsTable(testDb.db);
56
54
  tdb = createTenantDb(testDb.db, admin.tenantId);
57
55
  });
58
56
 
@@ -134,15 +134,17 @@ export function resetEntityFieldEncryptionCacheForTests(): void {
134
134
  // surfaces missing AND malformed keys (wrong length, bad base64) at boot
135
135
  // instead of on the first encrypted read in prod. validateBoot runs before
136
136
  // configureEntityFieldEncryption, so the env probe is the common path.
137
- export function validateEntityFieldEncryptionAvailable(): void {
137
+ export function validateEntityFieldEncryptionAvailable(
138
+ env: Readonly<Record<string, string | undefined>> = process.env,
139
+ ): void {
138
140
  // skip: an injected cipher (test seam / custom KMS) satisfies availability
139
141
  if (injectedCipher) return;
140
142
  try {
141
143
  createEnvMasterKeyProvider({
142
144
  env: {
143
- ...process.env,
145
+ ...env,
144
146
  KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
145
- process.env["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
147
+ env["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
146
148
  },
147
149
  });
148
150
  } catch (err) {
@@ -1,6 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { type BunTestDb, createTestDb } from "../../../bun-db/__tests__/bun-test-db";
3
- import { createEventsTable } from "../../../event-store/events-schema";
4
3
  import { asRawClient } from "../../query";
5
4
  import { ensureIdempotencyKeyIndex } from "../event-store";
6
5
 
@@ -8,7 +7,6 @@ let testDb: BunTestDb;
8
7
 
9
8
  beforeAll(async () => {
10
9
  testDb = await createTestDb();
11
- await createEventsTable(testDb.db);
12
10
  });
13
11
 
14
12
  afterAll(async () => {
@@ -100,6 +100,10 @@ export type ValidateBootOptions = {
100
100
  * screen is reachable another way (e.g. as a sub-page of a generated
101
101
  * settings hub). Suppresses the navAllowlist warning for these QNs. */
102
102
  readonly navAllowlistExempt?: ReadonlySet<string>;
103
+ /** Env the encrypted-field master-key probe reads. Defaults to
104
+ * `process.env`; runners pass their resolved env so a ciphertext-only
105
+ * key (already decrypted at boot) is visible to the probe. */
106
+ readonly env?: Readonly<Record<string, string | undefined>>;
103
107
  };
104
108
 
105
109
  /**
@@ -276,7 +280,7 @@ export function validateBoot(
276
280
  // catches malformed keys (wrong length, bad base64) at boot instead of
277
281
  // on the first encrypted read in prod. An injected cipher (test seam,
278
282
  // custom KMS provider) satisfies the requirement without env keys.
279
- validateEntityFieldEncryptionAvailable();
283
+ validateEntityFieldEncryptionAvailable(options?.env);
280
284
  }
281
285
 
282
286
  if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {