@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.
- package/package.json +4 -4
- package/src/__tests__/schema-cli-rebuild.integration.test.ts +0 -2
- package/src/changes.json +12 -0
- package/src/crypto/__tests__/kek-source.test.ts +143 -0
- package/src/crypto/kek-source.ts +34 -43
- package/src/db/__tests__/apply-entity-event-tenant.integration.test.ts +0 -2
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +1 -2
- package/src/db/__tests__/blind-index.integration.test.ts +0 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +29 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +0 -2
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +0 -2
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +0 -2
- package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +0 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +0 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +0 -2
- package/src/db/__tests__/unique-violation-mapping.integration.test.ts +0 -2
- package/src/db/entity-field-encryption.ts +5 -3
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +0 -2
- package/src/engine/boot-validator/index.ts +5 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -2
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -2
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -2
- package/src/env/__tests__/compose-env-schema.test.ts +97 -1
- package/src/env/index.ts +63 -3
- package/src/es-ops/__tests__/context.integration.test.ts +0 -2
- package/src/es-ops/__tests__/runner.integration.test.ts +0 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +1 -2
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +0 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +0 -2
- package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +1 -2
- package/src/event-store/__tests__/perf.integration.test.ts +1 -8
- package/src/event-store/__tests__/provenance-append.integration.test.ts +1 -2
- package/src/event-store/__tests__/snapshot.integration.test.ts +0 -2
- package/src/event-store/__tests__/upcaster-dead-letter.integration.test.ts +1 -2
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -2
- package/src/files/__tests__/files.integration.test.ts +1 -2
- package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -2
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +0 -2
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +0 -2
- package/src/pipeline/__tests__/cascade-handler.integration.test.ts +0 -2
- package/src/pipeline/__tests__/perf-rebuild.integration.test.ts +0 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +1 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -2
- package/src/search/__tests__/reindex-entity.integration.test.ts +0 -2
- 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.
|
|
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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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
|
});
|
package/src/crypto/kek-source.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// Resolves
|
|
2
|
-
// when no plaintext is set, so
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
|
142
|
-
//
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
env,
|
|
153
|
-
|
|
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
|
|
168
|
-
describeKekSource(
|
|
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 (
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return { ...env,
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
...
|
|
145
|
+
...env,
|
|
144
146
|
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
|
|
145
|
-
|
|
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]) {
|