@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.109.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.109.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
- const auditTable = buildEntityTable("audit", auditEntity);
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 = createEncryptionProvider(
55
- Buffer.from("0123456789abcdef0123456789abcdef").toString("base64"),
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 EncryptionProvider/);
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 { createEncryptionProvider } from "../encryption";
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 = createEncryptionProvider(TEST_KEY);
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 { createEncryptionProvider } from "../encryption";
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 = createEncryptionProvider(ENCRYPTION_TEST_KEY);
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 = createEncryptionProvider(ENCRYPTION_TEST_KEY);
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
- const table = buildEntityTable("mergeItem", entity);
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
- const table = buildEntityTable("tenantDbItem", entity);
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
 
@@ -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
- encryption?: EncryptionProvider,
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 an encryption provider would silently write
52
- // plaintext to a column the resolver later tries to decrypt — fail
53
- // loud at boot, not on first read in prod.
54
- if (keyDef.encrypted && !encryption) {
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 EncryptionProvider was supplied.`,
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 && encryption) {
81
- value = encryption.encrypt(value);
80
+ if (keyDef.encrypted && cipher) {
81
+ value = await cipher.encrypt(value, { tenantId });
82
82
  }
83
83
 
84
84
  const payload: Record<string, unknown> = {
@@ -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
- import type { EntityDefinition } from "../engine/types";
2
- import type { EncryptionProvider } from "./encryption";
3
- import { createEncryptionProvider } from "./encryption";
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
- function encryptFieldValue(
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
- encryption: EncryptionProvider,
19
- ): string {
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 encryption.encrypt(value);
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
- encryption: EncryptionProvider,
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, encryption);
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
- encryption: EncryptionProvider,
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] = encryption.decrypt(value);
76
+ out[name] = await cipher.decrypt(value, scope);
60
77
  }
61
78
  return out;
62
79
  }
63
80
 
64
- let cachedProvider: EncryptionProvider | undefined;
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 resolveEntityFieldEncryption(): EncryptionProvider {
67
- if (cachedProvider) return cachedProvider;
68
- const key = process.env["ENCRYPTION_KEY"];
69
- if (!key) {
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
- "ENCRYPTION_KEY environment variable is required (encrypted entity fields in use)",
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
- cachedProvider = createEncryptionProvider(key);
75
- return cachedProvider;
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
- cachedProvider = undefined;
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 ENCRYPTION_KEY for entity fields marked `encrypted: true`. */
123
- encryption?: EncryptionProvider;
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 encryptionProvider(): EncryptionProvider {
310
+ function fieldCipher(): EnvelopeCipher {
311
311
  if (options.encryption) return options.encryption;
312
312
  return resolveEntityFieldEncryption();
313
313
  }
314
314
 
315
- function encryptForStorage(
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, encryptionProvider(), {
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, encryptionProvider());
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(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
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 = rawRows.map((r) =>
1086
- coerceRow(decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
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(user.tenantId, entityName, payload.id, encryptForStorage(coerced));
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);