@cosmicdrift/kumiko-framework 0.109.0 → 0.111.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 +2 -2
- package/src/api/__tests__/batch.integration.test.ts +4 -1
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/event-store-executor.integration.test.ts +3 -4
- package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -1
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/config-seed.ts +9 -9
- package/src/db/encryption.ts +7 -0
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/event-store-executor.ts +37 -24
- package/src/db/index.ts +3 -0
- package/src/db/tenant-db.ts +14 -9
- package/src/engine/__tests__/boot-validator.test.ts +33 -9
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/types/handlers.ts +3 -2
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
- package/src/secrets/__tests__/envelope-cipher.test.ts +87 -0
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +81 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +10 -2
- package/src/testing/index.ts +2 -0
- package/src/testing/mutable-master-key-provider.ts +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.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>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.111.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import type { TableColumns } from "../../db/dialect";
|
|
4
5
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
5
6
|
import { asRawClient, selectMany } from "../../db/query";
|
|
6
7
|
import { buildEntityTable } from "../../db/table-builder";
|
|
@@ -36,7 +37,9 @@ const auditEntity = createEntity({
|
|
|
36
37
|
itemId: createTextField({ required: true }),
|
|
37
38
|
},
|
|
38
39
|
});
|
|
39
|
-
|
|
40
|
+
// Brand (#742) is compile-time-only; the postSave hook writes this sink via method-form,
|
|
41
|
+
// so hold it at the unbranded TableColumns view (identical runtime shape).
|
|
42
|
+
const auditTable: TableColumns = buildEntityTable("audit", auditEntity);
|
|
40
43
|
|
|
41
44
|
// Hook invocation logs — reset per test. Captures which phase each hook saw.
|
|
42
45
|
const inTxHookLog: Array<{ id: EntityId; name: string }> = [];
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { expect, test } from "bun:test";
|
|
9
9
|
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
10
10
|
import { buildEntityTable } from "../../db/table-builder";
|
|
11
|
+
import type { TenantDb } from "../../db/tenant-db";
|
|
11
12
|
import { createEntity, createTextField } from "../../engine";
|
|
12
13
|
import { type AnyDb, deleteMany, insertOne, selectMany, updateMany } from "../query";
|
|
13
14
|
|
|
@@ -41,8 +42,28 @@ async function _readAllowsManagedEntity(db: AnyDb): Promise<void> {
|
|
|
41
42
|
await selectMany(db, brandedEntity, { id: "1" });
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// Method-form (ctx.db.insertOne/updateMany/deleteMany) rejects the brand too — a
|
|
46
|
+
// projection written past its event stream is wiped on rebuild whether the write
|
|
47
|
+
// went through the free function or the TenantDb method.
|
|
48
|
+
async function _methodFormRejectsManagedEntity(db: TenantDb): Promise<void> {
|
|
49
|
+
// @ts-expect-error — managed EntityTable is executor-only; method-form insert is a compile error.
|
|
50
|
+
await db.insertOne(brandedEntity, { title: "x" });
|
|
51
|
+
// @ts-expect-error — method-form update on a managed EntityTable is rejected.
|
|
52
|
+
await db.updateMany(brandedEntity, { title: "y" }, { id: "1" });
|
|
53
|
+
// @ts-expect-error — method-form delete on a managed EntityTable is rejected.
|
|
54
|
+
await db.deleteMany(brandedEntity, { id: "1" });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function _methodFormReadAllowsManagedEntity(db: TenantDb): Promise<void> {
|
|
58
|
+
// Method-form reads on a managed EntityTable stay fine (reads keep the plain param).
|
|
59
|
+
await db.selectMany(brandedEntity, { id: "1" });
|
|
60
|
+
await db.fetchOne(brandedEntity, { id: "1" });
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
test("ES-write brand: compile-time contracts are wired", () => {
|
|
45
64
|
expect(_writeRejectsManagedEntity).toBeDefined();
|
|
46
65
|
expect(_writeAllowsUnmanagedTable).toBeDefined();
|
|
47
66
|
expect(_readAllowsManagedEntity).toBeDefined();
|
|
67
|
+
expect(_methodFormRejectsManagedEntity).toBeDefined();
|
|
68
|
+
expect(_methodFormReadAllowsManagedEntity).toBeDefined();
|
|
48
69
|
});
|
|
@@ -10,10 +10,11 @@ import {
|
|
|
10
10
|
SYSTEM_TENANT_ID,
|
|
11
11
|
} from "../../engine";
|
|
12
12
|
import type { ConfigSeedDef, Registry } from "../../engine/types";
|
|
13
|
+
import { createEnvMasterKeyProvider } from "../../secrets/env-master-key-provider";
|
|
14
|
+
import { createEnvelopeCipher } from "../../secrets/envelope-cipher";
|
|
13
15
|
import { unsafeCreateEntityTable } from "../../stack";
|
|
14
16
|
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
15
17
|
import { seedConfigValues } from "../config-seed";
|
|
16
|
-
import { createEncryptionProvider } from "../encryption";
|
|
17
18
|
import { buildEntityTable } from "../table-builder";
|
|
18
19
|
|
|
19
20
|
// --- Test Entity ---
|
|
@@ -51,8 +52,15 @@ const mockRegistry = {
|
|
|
51
52
|
} as unknown as Registry;
|
|
52
53
|
|
|
53
54
|
// --- Helpers ---
|
|
54
|
-
const encryption =
|
|
55
|
-
|
|
55
|
+
const encryption = createEnvelopeCipher(
|
|
56
|
+
createEnvMasterKeyProvider({
|
|
57
|
+
env: {
|
|
58
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
59
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: Buffer.from("0123456789abcdef0123456789abcdef").toString(
|
|
60
|
+
"base64",
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
56
64
|
);
|
|
57
65
|
|
|
58
66
|
let testDb: BunTestDb;
|
|
@@ -214,7 +222,7 @@ describe("seedConfigValues", () => {
|
|
|
214
222
|
|
|
215
223
|
await expect(
|
|
216
224
|
seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
|
|
217
|
-
).rejects.toThrow(/encrypted but no
|
|
225
|
+
).rejects.toThrow(/encrypted but no EnvelopeCipher/);
|
|
218
226
|
});
|
|
219
227
|
|
|
220
228
|
test("encrypted seed with provider stores ciphertext, not plaintext", async () => {
|
|
@@ -240,7 +248,7 @@ describe("seedConfigValues", () => {
|
|
|
240
248
|
// resolver later runs `decrypt → JSON.parse` to get the primitive
|
|
241
249
|
// back; we replay the same round-trip here.
|
|
242
250
|
expect(row!.value).not.toContain("sk_live_secret_token");
|
|
243
|
-
expect(JSON.parse(encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
|
|
251
|
+
expect(JSON.parse(await encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
|
|
244
252
|
});
|
|
245
253
|
|
|
246
254
|
test("race-safe parallel boot — two concurrent calls result in 1 created + 1 skipped", async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createEntity, createTextField } from "../../engine";
|
|
3
|
-
import {
|
|
3
|
+
import { createTestEnvelopeCipher } from "../../testing";
|
|
4
4
|
import {
|
|
5
5
|
collectEncryptedFieldNames,
|
|
6
6
|
decryptEntityFieldValues,
|
|
@@ -18,25 +18,25 @@ describe("entity-field-encryption", () => {
|
|
|
18
18
|
},
|
|
19
19
|
});
|
|
20
20
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
21
|
-
const encryption =
|
|
21
|
+
const encryption = createTestEnvelopeCipher(TEST_KEY);
|
|
22
22
|
|
|
23
23
|
test("collectEncryptedFieldNames finds encrypted text fields only", () => {
|
|
24
24
|
expect([...encryptedFields]).toEqual(["secretNote"]);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("encrypt on write / decrypt on read round-trip", () => {
|
|
27
|
+
test("encrypt on write / decrypt on read round-trip", async () => {
|
|
28
28
|
const plain = { email: "a@b.de", secretNote: "top secret" };
|
|
29
|
-
const stored = encryptEntityFieldValues(plain, encryptedFields, encryption);
|
|
29
|
+
const stored = await encryptEntityFieldValues(plain, encryptedFields, encryption);
|
|
30
30
|
expect(stored["email"]).toBe(plain.email);
|
|
31
31
|
expect(stored["secretNote"]).not.toBe("top secret");
|
|
32
32
|
|
|
33
|
-
const read = decryptEntityFieldValues(stored, encryptedFields, encryption);
|
|
33
|
+
const read = await decryptEntityFieldValues(stored, encryptedFields, encryption);
|
|
34
34
|
expect(read).toEqual(plain);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test("onlyKeys limits encryption to changed fields", () => {
|
|
37
|
+
test("onlyKeys limits encryption to changed fields", async () => {
|
|
38
38
|
const row = { email: "a@b.de", secretNote: "note" };
|
|
39
|
-
const stored = encryptEntityFieldValues(row, encryptedFields, encryption, {
|
|
39
|
+
const stored = await encryptEntityFieldValues(row, encryptedFields, encryption, {
|
|
40
40
|
onlyKeys: ["secretNote"],
|
|
41
41
|
});
|
|
42
42
|
expect(stored["email"]).toBe("a@b.de");
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
testTenantId,
|
|
11
11
|
unsafeCreateEntityTable,
|
|
12
12
|
} from "../../stack";
|
|
13
|
-
import {
|
|
13
|
+
import { createTestEnvelopeCipher } from "../../testing";
|
|
14
14
|
import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
|
|
15
15
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
16
16
|
import { buildEntityTable } from "../table-builder";
|
|
@@ -324,7 +324,7 @@ const encryptedSoftDeleteEntity = createEntity({
|
|
|
324
324
|
const encryptedSoftDeleteTable = buildEntityTable("esExecEncSoft", encryptedSoftDeleteEntity);
|
|
325
325
|
|
|
326
326
|
describe("event-store-executor — encrypted fields", () => {
|
|
327
|
-
const encryption =
|
|
327
|
+
const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
|
|
328
328
|
const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
|
|
329
329
|
entityName: "esExecEncrypted",
|
|
330
330
|
encryption,
|
|
@@ -337,7 +337,6 @@ describe("event-store-executor — encrypted fields", () => {
|
|
|
337
337
|
);
|
|
338
338
|
|
|
339
339
|
beforeAll(async () => {
|
|
340
|
-
process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
|
|
341
340
|
resetEntityFieldEncryptionCacheForTests();
|
|
342
341
|
await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
|
|
343
342
|
await unsafeCreateEntityTable(testDb.db, encryptedSoftDeleteEntity, "esExecEncSoft");
|
|
@@ -524,7 +523,7 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
|
|
|
524
523
|
store.delete(`${tenantId}:${name}:${id}`);
|
|
525
524
|
},
|
|
526
525
|
};
|
|
527
|
-
const encryption =
|
|
526
|
+
const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
|
|
528
527
|
const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
|
|
529
528
|
entityName: "esExecEncrypted",
|
|
530
529
|
entityCache,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createEntity, createTextField } from "../../engine";
|
|
3
3
|
import { testTenantId } from "../../stack";
|
|
4
|
+
import type { TableColumns } from "../dialect";
|
|
4
5
|
import { buildEntityTable } from "../table-builder";
|
|
5
6
|
import { createTenantDb } from "../tenant-db";
|
|
6
7
|
|
|
@@ -13,7 +14,9 @@ const entity = createEntity({
|
|
|
13
14
|
table: "merge_items",
|
|
14
15
|
fields: { name: createTextField({ required: true }) },
|
|
15
16
|
});
|
|
16
|
-
|
|
17
|
+
// Brand (#742) is compile-time-only; hold the handle at the unbranded TableColumns
|
|
18
|
+
// view so the method-form scoping test still compiles (runtime shape is identical).
|
|
19
|
+
const table: TableColumns = buildEntityTable("mergeItem", entity);
|
|
17
20
|
|
|
18
21
|
const own = testTenantId(1);
|
|
19
22
|
const foreign = testTenantId(2);
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
unsafeCreateEntityTable,
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "../../stack";
|
|
13
|
-
import { table as pgTable, serial, text, timestamp } from "../dialect";
|
|
13
|
+
import { table as pgTable, serial, type TableColumns, text, timestamp } from "../dialect";
|
|
14
14
|
import { buildEntityTable } from "../table-builder";
|
|
15
15
|
import { createTenantDb } from "../tenant-db";
|
|
16
16
|
|
|
@@ -26,7 +26,11 @@ const entity = createEntity({
|
|
|
26
26
|
softDelete: true,
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// The ES-write brand (#742) is a compile-time phantom — no runtime property. This
|
|
30
|
+
// suite exercises TenantDb method-form scoping on a real entity table, so the handle
|
|
31
|
+
// is held at the unbranded TableColumns view: identical runtime shape, and both reads
|
|
32
|
+
// and method-form writes accept it (a branded EntityTable is now rejected).
|
|
33
|
+
const table: TableColumns = buildEntityTable("tenantDbItem", entity);
|
|
30
34
|
|
|
31
35
|
// --- System table (no tenantId — like job_runs) ---
|
|
32
36
|
|
package/src/db/config-seed.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { v5 as uuidv5 } from "uuid";
|
|
|
2
2
|
import type { EntityDefinition } from "../engine";
|
|
3
3
|
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine";
|
|
4
4
|
import type { ConfigSeedDef, Registry } from "../engine/types";
|
|
5
|
+
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
5
6
|
import type { DbConnection } from "./connection";
|
|
6
|
-
import type { EncryptionProvider } from "./encryption";
|
|
7
7
|
import { createEventStoreExecutor } from "./event-store-executor";
|
|
8
8
|
import type { EntityTable } from "./table-builder";
|
|
9
9
|
import { createTenantDb } from "./tenant-db";
|
|
@@ -30,7 +30,7 @@ export async function seedConfigValues<E extends EntityDefinition>(
|
|
|
30
30
|
entity: E,
|
|
31
31
|
registry: Registry,
|
|
32
32
|
db: DbConnection,
|
|
33
|
-
|
|
33
|
+
cipher?: EnvelopeCipher,
|
|
34
34
|
): Promise<{ created: number; skipped: number }> {
|
|
35
35
|
let created = 0;
|
|
36
36
|
let skipped = 0;
|
|
@@ -48,12 +48,12 @@ export async function seedConfigValues<E extends EntityDefinition>(
|
|
|
48
48
|
continue;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// Encrypted keys without
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
if (keyDef.encrypted && !
|
|
51
|
+
// Encrypted keys without a cipher would silently write plaintext to a
|
|
52
|
+
// column the resolver later tries to decrypt — fail loud at boot, not
|
|
53
|
+
// on first read in prod.
|
|
54
|
+
if (keyDef.encrypted && !cipher) {
|
|
55
55
|
throw new Error(
|
|
56
|
-
`seedConfigValues: key "${seed.key}" is encrypted but no
|
|
56
|
+
`seedConfigValues: key "${seed.key}" is encrypted but no EnvelopeCipher was supplied.`,
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -77,8 +77,8 @@ export async function seedConfigValues<E extends EntityDefinition>(
|
|
|
77
77
|
const aggregateId = uuidv5(idSource, CONFIG_SEED_NS);
|
|
78
78
|
|
|
79
79
|
let value = JSON.stringify(seed.value);
|
|
80
|
-
if (keyDef.encrypted &&
|
|
81
|
-
value =
|
|
80
|
+
if (keyDef.encrypted && cipher) {
|
|
81
|
+
value = await cipher.encrypt(value, { tenantId });
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const payload: Record<string, unknown> = {
|
package/src/db/encryption.ts
CHANGED
|
@@ -9,6 +9,13 @@ export type EncryptionProvider = {
|
|
|
9
9
|
decrypt(ciphertext: string): string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* @deprecated Legacy single-key format: base64(iv+tag+ct) with NO key id —
|
|
14
|
+
* a key change makes existing ciphertexts permanently undecryptable. Use
|
|
15
|
+
* `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code; this
|
|
16
|
+
* provider remains only as the decrypt-fallback for pre-envelope values
|
|
17
|
+
* (EnvelopeCipherOptions.legacy) until a re-encrypt job migrated them.
|
|
18
|
+
*/
|
|
12
19
|
export function createEncryptionProvider(key: string): EncryptionProvider {
|
|
13
20
|
// Key must be 32 bytes for AES-256
|
|
14
21
|
const keyBuffer = Buffer.from(key, "base64");
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// Entity-field encryption (`encrypted: true` on text/longText fields),
|
|
2
|
+
// backed by the same envelope cipher as encrypted config values: JSON
|
|
3
|
+
// StoredEnvelope in the TEXT column, kekVersion per value, legacy
|
|
4
|
+
// single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
|
|
5
|
+
// the cipher's legacy fallback until re-encrypted.
|
|
6
|
+
|
|
7
|
+
import type { EntityDefinition, TenantId } from "../engine/types";
|
|
8
|
+
import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider";
|
|
9
|
+
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
10
|
+
import type { KeyScope } from "../secrets/types";
|
|
4
11
|
|
|
5
12
|
export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string> {
|
|
6
13
|
const names = new Set<string>();
|
|
@@ -12,70 +19,120 @@ export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySe
|
|
|
12
19
|
return names;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
// BYOK hook: thread the row's tenant to the cipher when the row carries one
|
|
23
|
+
// (system-scope rows may not).
|
|
24
|
+
function scopeOf(row: Record<string, unknown>): KeyScope | undefined {
|
|
25
|
+
const tenantId = row["tenantId"];
|
|
26
|
+
return typeof tenantId === "string" ? { tenantId: tenantId as TenantId } : undefined; // @cast-boundary db-row
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function encryptFieldValue(
|
|
16
30
|
fieldName: string,
|
|
17
31
|
value: unknown,
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
cipher: EnvelopeCipher,
|
|
33
|
+
scope: KeyScope | undefined,
|
|
34
|
+
): Promise<string> {
|
|
20
35
|
if (value === null || value === undefined) {
|
|
21
36
|
throw new Error(`encrypted field "${fieldName}" cannot be null or undefined`);
|
|
22
37
|
}
|
|
23
38
|
if (typeof value !== "string") {
|
|
24
39
|
throw new Error(`encrypted field "${fieldName}" must be a string, got ${typeof value}`);
|
|
25
40
|
}
|
|
26
|
-
return
|
|
41
|
+
return cipher.encrypt(value, scope);
|
|
27
42
|
}
|
|
28
43
|
|
|
29
|
-
export function encryptEntityFieldValues(
|
|
44
|
+
export async function encryptEntityFieldValues(
|
|
30
45
|
row: Record<string, unknown>,
|
|
31
46
|
encryptedFields: ReadonlySet<string>,
|
|
32
|
-
|
|
47
|
+
cipher: EnvelopeCipher,
|
|
33
48
|
opts?: { onlyKeys?: Iterable<string> },
|
|
34
|
-
): Record<string, unknown
|
|
49
|
+
): Promise<Record<string, unknown>> {
|
|
35
50
|
if (encryptedFields.size === 0) return row;
|
|
36
51
|
const only = opts?.onlyKeys ? new Set(opts.onlyKeys) : null;
|
|
37
52
|
const out = { ...row };
|
|
53
|
+
const scope = scopeOf(row);
|
|
38
54
|
for (const name of encryptedFields) {
|
|
39
55
|
if (only && !only.has(name)) continue;
|
|
40
56
|
if (!(name in out)) continue;
|
|
41
57
|
const value = out[name];
|
|
42
58
|
if (value === null || value === undefined) continue;
|
|
43
|
-
out[name] = encryptFieldValue(name, value,
|
|
59
|
+
out[name] = await encryptFieldValue(name, value, cipher, scope);
|
|
44
60
|
}
|
|
45
61
|
return out;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
export function decryptEntityFieldValues(
|
|
64
|
+
export async function decryptEntityFieldValues(
|
|
49
65
|
row: Record<string, unknown>,
|
|
50
66
|
encryptedFields: ReadonlySet<string>,
|
|
51
|
-
|
|
52
|
-
): Record<string, unknown
|
|
67
|
+
cipher: EnvelopeCipher,
|
|
68
|
+
): Promise<Record<string, unknown>> {
|
|
53
69
|
if (encryptedFields.size === 0) return row;
|
|
54
70
|
const out = { ...row };
|
|
71
|
+
const scope = scopeOf(row);
|
|
55
72
|
for (const name of encryptedFields) {
|
|
56
73
|
const value = out[name];
|
|
57
74
|
if (value === null || value === undefined) continue;
|
|
58
75
|
if (typeof value !== "string") continue;
|
|
59
|
-
out[name] =
|
|
76
|
+
out[name] = await cipher.decrypt(value, scope);
|
|
60
77
|
}
|
|
61
78
|
return out;
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
|
|
81
|
+
// Boot-injected app-wide cipher. run{Prod,Dev}App (and test setups that
|
|
82
|
+
// exercise encrypted fields) call configureEntityFieldEncryption once with
|
|
83
|
+
// the same cipher instance the config feature uses; executors resolve it
|
|
84
|
+
// lazily so entities without encrypted fields never touch it.
|
|
85
|
+
let injectedCipher: EnvelopeCipher | undefined;
|
|
65
86
|
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
export function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void {
|
|
88
|
+
injectedCipher = cipher;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveEntityFieldEncryption(): EnvelopeCipher {
|
|
92
|
+
if (!injectedCipher) {
|
|
70
93
|
throw new Error(
|
|
71
|
-
"
|
|
94
|
+
"entity-field encryption is not configured — encrypted entity fields need a master key " +
|
|
95
|
+
"(KUMIKO_SECRETS_MASTER_KEY_V<n>); run{Prod,Dev}App wire the cipher automatically, " +
|
|
96
|
+
"custom boots call configureEntityFieldEncryption(cipher).",
|
|
72
97
|
);
|
|
73
98
|
}
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
return injectedCipher;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Non-throwing probe for callers that degrade gracefully (DSGVO export
|
|
103
|
+
// replaces encrypted fields with an explicit marker instead of failing
|
|
104
|
+
// the whole export when no cipher is wired).
|
|
105
|
+
export function configuredEntityFieldEncryption(): EnvelopeCipher | undefined {
|
|
106
|
+
return injectedCipher;
|
|
76
107
|
}
|
|
77
108
|
|
|
78
109
|
/** @internal test-only */
|
|
79
110
|
export function resetEntityFieldEncryptionCacheForTests(): void {
|
|
80
|
-
|
|
111
|
+
injectedCipher = undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Boot-time availability probe for validateBoot: either a cipher was
|
|
115
|
+
// injected, or the env keyring must be constructible — eager construction
|
|
116
|
+
// surfaces missing AND malformed keys (wrong length, bad base64) at boot
|
|
117
|
+
// instead of on the first encrypted read in prod. validateBoot runs before
|
|
118
|
+
// configureEntityFieldEncryption, so the env probe is the common path.
|
|
119
|
+
export function validateEntityFieldEncryptionAvailable(): void {
|
|
120
|
+
// skip: an injected cipher (test seam / custom KMS) satisfies availability
|
|
121
|
+
if (injectedCipher) return;
|
|
122
|
+
try {
|
|
123
|
+
createEnvMasterKeyProvider({
|
|
124
|
+
env: {
|
|
125
|
+
...process.env,
|
|
126
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
|
|
127
|
+
process.env["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
132
|
+
throw new Error(
|
|
133
|
+
`encrypted entity fields in use but no usable master key (${reason}) — set ` +
|
|
134
|
+
"KUMIKO_SECRETS_MASTER_KEY_V1 (32 bytes, base64) or inject a cipher via " +
|
|
135
|
+
"configureEntityFieldEncryption().",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
81
138
|
}
|
|
@@ -39,13 +39,13 @@ import {
|
|
|
39
39
|
} from "../event-store";
|
|
40
40
|
import type { EntityCache } from "../pipeline/entity-cache";
|
|
41
41
|
import type { SearchAdapter } from "../search/types";
|
|
42
|
+
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
42
43
|
import { assertUnreachable, generateId } from "../utils";
|
|
43
44
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
44
45
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
45
46
|
import type { DbRow } from "./connection";
|
|
46
47
|
import { decodeCursor, encodeCursor } from "./cursor";
|
|
47
48
|
import type { TableColumns } from "./dialect";
|
|
48
|
-
import type { EncryptionProvider } from "./encryption";
|
|
49
49
|
import {
|
|
50
50
|
collectEncryptedFieldNames,
|
|
51
51
|
decryptEntityFieldValues,
|
|
@@ -119,8 +119,8 @@ export type EventStoreExecutorOptions = {
|
|
|
119
119
|
searchAdapter?: SearchAdapter;
|
|
120
120
|
entityName: string; // required — the aggregateType marker on every event
|
|
121
121
|
entityCache?: EntityCache;
|
|
122
|
-
/** Override
|
|
123
|
-
encryption?:
|
|
122
|
+
/** Override the boot-injected cipher for fields marked `encrypted: true`. */
|
|
123
|
+
encryption?: EnvelopeCipher;
|
|
124
124
|
};
|
|
125
125
|
|
|
126
126
|
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
@@ -307,24 +307,28 @@ export function createEventStoreExecutor(
|
|
|
307
307
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
308
308
|
const hasEncryptedFields = encryptedFields.size > 0;
|
|
309
309
|
|
|
310
|
-
function
|
|
310
|
+
function fieldCipher(): EnvelopeCipher {
|
|
311
311
|
if (options.encryption) return options.encryption;
|
|
312
312
|
return resolveEntityFieldEncryption();
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
|
|
315
|
+
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
316
|
+
// MasterKeyProvider. Callers MUST await — a missed await here writes
|
|
317
|
+
// "[object Promise]" into the projection, which the Promise return
|
|
318
|
+
// types turn into a compile error at every call site.
|
|
319
|
+
async function encryptForStorage(
|
|
316
320
|
row: Record<string, unknown>,
|
|
317
321
|
onlyKeys?: Iterable<string>,
|
|
318
|
-
): Record<string, unknown
|
|
322
|
+
): Promise<Record<string, unknown>> {
|
|
319
323
|
if (!hasEncryptedFields) return row;
|
|
320
|
-
return encryptEntityFieldValues(row, encryptedFields,
|
|
324
|
+
return encryptEntityFieldValues(row, encryptedFields, fieldCipher(), {
|
|
321
325
|
...(onlyKeys !== undefined && { onlyKeys }),
|
|
322
326
|
});
|
|
323
327
|
}
|
|
324
328
|
|
|
325
|
-
function decryptForRead(row: Record<string, unknown>): Record<string, unknown
|
|
329
|
+
async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
326
330
|
if (!hasEncryptedFields) return row;
|
|
327
|
-
return decryptEntityFieldValues(row, encryptedFields,
|
|
331
|
+
return decryptEntityFieldValues(row, encryptedFields, fieldCipher());
|
|
328
332
|
}
|
|
329
333
|
|
|
330
334
|
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -356,7 +360,7 @@ export function createEventStoreExecutor(
|
|
|
356
360
|
async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
|
|
357
361
|
const row = await db.fetchOne(table, idFilter(id));
|
|
358
362
|
if (!row) return null;
|
|
359
|
-
return decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
|
|
363
|
+
return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
|
|
360
364
|
}
|
|
361
365
|
|
|
362
366
|
// Archive guard for the CRUD write paths. Archived streams are read-only —
|
|
@@ -465,7 +469,7 @@ export function createEventStoreExecutor(
|
|
|
465
469
|
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
466
470
|
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
467
471
|
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
468
|
-
const flatData = encryptForStorage(flattenCompoundTypes(data, entity));
|
|
472
|
+
const flatData = await encryptForStorage(flattenCompoundTypes(data, entity));
|
|
469
473
|
|
|
470
474
|
// 1. Append event (same TX as the projection write — both must succeed
|
|
471
475
|
// or both roll back; the dispatcher wraps both in one transaction).
|
|
@@ -541,7 +545,9 @@ export function createEventStoreExecutor(
|
|
|
541
545
|
const row = result.row;
|
|
542
546
|
// Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
|
|
543
547
|
// Compound-Types in einem Pass.
|
|
544
|
-
const projection = decryptForRead(
|
|
548
|
+
const projection = await decryptForRead(
|
|
549
|
+
rehydrateCompoundTypes(row as DbRow, entity) as DbRow,
|
|
550
|
+
);
|
|
545
551
|
|
|
546
552
|
if (entityCache && entityName) {
|
|
547
553
|
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
@@ -644,7 +650,7 @@ export function createEventStoreExecutor(
|
|
|
644
650
|
|
|
645
651
|
try {
|
|
646
652
|
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
647
|
-
const flatChanges = encryptForStorage(
|
|
653
|
+
const flatChanges = await encryptForStorage(
|
|
648
654
|
flattenCompoundTypes(payload.changes, entity),
|
|
649
655
|
Object.keys(payload.changes),
|
|
650
656
|
);
|
|
@@ -667,7 +673,7 @@ export function createEventStoreExecutor(
|
|
|
667
673
|
type: entityEventName(entityName, "updated"),
|
|
668
674
|
payload: {
|
|
669
675
|
changes: stripSensitive(flatChanges),
|
|
670
|
-
previous: stripSensitive(encryptForStorage(previous)),
|
|
676
|
+
previous: stripSensitive(await encryptForStorage(previous)),
|
|
671
677
|
},
|
|
672
678
|
metadata: buildEventMetadata(user),
|
|
673
679
|
});
|
|
@@ -695,7 +701,7 @@ export function createEventStoreExecutor(
|
|
|
695
701
|
return writeFailure(new InternalError({ message: "projection update returned no row" }));
|
|
696
702
|
}
|
|
697
703
|
const row = result.row;
|
|
698
|
-
const data = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
|
|
704
|
+
const data = await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
|
|
699
705
|
|
|
700
706
|
if (entityCache && entityName) {
|
|
701
707
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
@@ -777,7 +783,7 @@ export function createEventStoreExecutor(
|
|
|
777
783
|
tenantId: streamTenantFor(user),
|
|
778
784
|
expectedVersion: currentVersion,
|
|
779
785
|
type: entityEventName(entityName, "deleted"),
|
|
780
|
-
payload: { previous: stripSensitive(encryptForStorage(existing)) },
|
|
786
|
+
payload: { previous: stripSensitive(await encryptForStorage(existing)) },
|
|
781
787
|
metadata: buildEventMetadata(user),
|
|
782
788
|
});
|
|
783
789
|
|
|
@@ -810,7 +816,7 @@ export function createEventStoreExecutor(
|
|
|
810
816
|
async forget(payload, user, db) {
|
|
811
817
|
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
812
818
|
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
813
|
-
const existing = decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
|
|
819
|
+
const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
|
|
814
820
|
|
|
815
821
|
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
816
822
|
return writeFailure(
|
|
@@ -937,7 +943,7 @@ export function createEventStoreExecutor(
|
|
|
937
943
|
// row and `previous` snapshot must be plaintext for `encrypted` fields,
|
|
938
944
|
// same as every other executor method — `data`/`restored` are raw rows
|
|
939
945
|
// (selectMany / applyEntityEvent), never decrypted before this point.
|
|
940
|
-
const restoredHydrated = decryptForRead(
|
|
946
|
+
const restoredHydrated = await decryptForRead(
|
|
941
947
|
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
942
948
|
);
|
|
943
949
|
|
|
@@ -948,7 +954,7 @@ export function createEventStoreExecutor(
|
|
|
948
954
|
id: payload.id,
|
|
949
955
|
data: restoredHydrated,
|
|
950
956
|
changes: { isDeleted: false },
|
|
951
|
-
previous: decryptForRead(data),
|
|
957
|
+
previous: await decryptForRead(data),
|
|
952
958
|
isNew: false,
|
|
953
959
|
entityName,
|
|
954
960
|
event,
|
|
@@ -1082,8 +1088,10 @@ export function createEventStoreExecutor(
|
|
|
1082
1088
|
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
1083
1089
|
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
|
|
1084
1090
|
const tableInfo = extractTableInfo(table);
|
|
1085
|
-
const rows =
|
|
1086
|
-
|
|
1091
|
+
const rows = await Promise.all(
|
|
1092
|
+
rawRows.map(async (r) =>
|
|
1093
|
+
coerceRow(await decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
|
|
1094
|
+
),
|
|
1087
1095
|
);
|
|
1088
1096
|
|
|
1089
1097
|
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
@@ -1156,19 +1164,24 @@ export function createEventStoreExecutor(
|
|
|
1156
1164
|
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
1157
1165
|
// `encrypted` field's plaintext never sits in a second at-rest
|
|
1158
1166
|
// store (Redis) the field-encryption feature doesn't cover.
|
|
1159
|
-
return withStreamVersion(decryptForRead(cached));
|
|
1167
|
+
return withStreamVersion(await decryptForRead(cached));
|
|
1160
1168
|
}
|
|
1161
1169
|
}
|
|
1162
1170
|
|
|
1163
1171
|
const rows = await loadWithOwnership(db, idWhere, ownership);
|
|
1164
1172
|
const raw = rows[0];
|
|
1165
1173
|
if (!raw) return null;
|
|
1166
|
-
const row = decryptForRead(rehydrateCompoundTypes(raw, entity));
|
|
1174
|
+
const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
|
|
1167
1175
|
const rowInfo = extractTableInfo(table);
|
|
1168
1176
|
const coerced = coerceRow(row, rowInfo);
|
|
1169
1177
|
|
|
1170
1178
|
if (entityCache && entityName) {
|
|
1171
|
-
await entityCache.set(
|
|
1179
|
+
await entityCache.set(
|
|
1180
|
+
user.tenantId,
|
|
1181
|
+
entityName,
|
|
1182
|
+
payload.id,
|
|
1183
|
+
await encryptForStorage(coerced),
|
|
1184
|
+
);
|
|
1172
1185
|
}
|
|
1173
1186
|
|
|
1174
1187
|
return withStreamVersion(coerced);
|