@cosmicdrift/kumiko-framework 0.113.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/schema-cli.integration.test.ts +0 -29
  3. package/src/api/__tests__/batch.integration.test.ts +1 -4
  4. package/src/api/__tests__/request-id-middleware.test.ts +0 -51
  5. package/src/api/auth-middleware.ts +1 -65
  6. package/src/api/auth-routes.ts +0 -11
  7. package/src/api/index.ts +1 -3
  8. package/src/api/jwt.ts +4 -1
  9. package/src/api/request-context.ts +0 -3
  10. package/src/api/request-id-middleware.ts +0 -2
  11. package/src/api/routes.ts +1 -26
  12. package/src/api/server.ts +1 -29
  13. package/src/bun-db/__tests__/write-brand.test.ts +0 -21
  14. package/src/bun-db/query.ts +0 -17
  15. package/src/db/__tests__/config-seed.integration.test.ts +5 -13
  16. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
  18. package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
  19. package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
  20. package/src/db/config-seed.ts +9 -9
  21. package/src/db/encryption.ts +0 -7
  22. package/src/db/entity-field-encryption.ts +24 -81
  23. package/src/db/event-store-executor.ts +24 -37
  24. package/src/db/index.ts +0 -3
  25. package/src/db/query.ts +0 -1
  26. package/src/db/tenant-db.ts +9 -14
  27. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
  28. package/src/engine/__tests__/boot-validator.test.ts +9 -33
  29. package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
  30. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
  31. package/src/engine/__tests__/registry.test.ts +0 -28
  32. package/src/engine/__tests__/screen.test.ts +0 -40
  33. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
  34. package/src/engine/boot-validator/gdpr-storage.ts +1 -2
  35. package/src/engine/boot-validator/index.ts +2 -7
  36. package/src/engine/boot-validator/screens-nav.ts +0 -21
  37. package/src/engine/define-feature.ts +0 -17
  38. package/src/engine/define-handler.ts +2 -16
  39. package/src/engine/extensions/user-data.ts +1 -12
  40. package/src/engine/index.ts +2 -5
  41. package/src/engine/registry.ts +21 -44
  42. package/src/engine/soft-delete-cleanup.ts +2 -6
  43. package/src/engine/types/feature.ts +21 -29
  44. package/src/engine/types/fields.ts +1 -3
  45. package/src/engine/types/handlers.ts +2 -16
  46. package/src/engine/types/index.ts +0 -2
  47. package/src/engine/types/projection.ts +0 -22
  48. package/src/engine/types/screen.ts +0 -29
  49. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  50. package/src/event-store/index.ts +0 -8
  51. package/src/files/provider-resolver.ts +15 -28
  52. package/src/i18n/required-surface-keys.ts +0 -12
  53. package/src/observability/__tests__/observability.integration.test.ts +1 -4
  54. package/src/pipeline/dispatcher.ts +6 -2
  55. package/src/pipeline/msp-rebuild.ts +3 -36
  56. package/src/pipeline/projection-rebuild.ts +3 -61
  57. package/src/schema-cli.ts +15 -24
  58. package/src/secrets/__tests__/contains-secret.test.ts +0 -34
  59. package/src/secrets/dek-cache.ts +5 -26
  60. package/src/secrets/envelope.ts +3 -5
  61. package/src/secrets/index.ts +1 -13
  62. package/src/secrets/types.ts +3 -18
  63. package/src/testing/e2e-generator.ts +0 -3
  64. package/src/testing/index.ts +0 -2
  65. package/src/testing/mutable-master-key-provider.ts +3 -29
  66. package/src/time/__tests__/tz-dateline.test.ts +10 -8
  67. package/src/ui-types/index.ts +0 -1
  68. package/src/api/__tests__/pat-scope.test.ts +0 -36
  69. package/src/api/pat-scope.ts +0 -14
  70. package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
  71. package/src/event-store/rebuild-dead-letter.ts +0 -111
  72. package/src/files/__tests__/provider-resolver.test.ts +0 -70
  73. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
  74. package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
  75. package/src/secrets/envelope-cipher.ts +0 -81
  76. package/src/secrets/stored-envelope.ts +0 -46
@@ -10,7 +10,7 @@ import {
10
10
  unsafeCreateEntityTable,
11
11
  unsafePushTables,
12
12
  } from "../../stack";
13
- import { table as pgTable, serial, type TableColumns, text, timestamp } from "../dialect";
13
+ import { table as pgTable, serial, text, timestamp } from "../dialect";
14
14
  import { buildEntityTable } from "../table-builder";
15
15
  import { createTenantDb } from "../tenant-db";
16
16
 
@@ -26,11 +26,7 @@ const entity = createEntity({
26
26
  softDelete: true,
27
27
  });
28
28
 
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);
29
+ const table = buildEntityTable("tenantDbItem", entity);
34
30
 
35
31
  // --- System table (no tenantId — like job_runs) ---
36
32
 
@@ -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";
6
5
  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
- cipher?: EnvelopeCipher,
33
+ encryption?: EncryptionProvider,
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 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) {
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) {
55
55
  throw new Error(
56
- `seedConfigValues: key "${seed.key}" is encrypted but no EnvelopeCipher was supplied.`,
56
+ `seedConfigValues: key "${seed.key}" is encrypted but no EncryptionProvider 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 && cipher) {
81
- value = await cipher.encrypt(value, { tenantId });
80
+ if (keyDef.encrypted && encryption) {
81
+ value = encryption.encrypt(value);
82
82
  }
83
83
 
84
84
  const payload: Record<string, unknown> = {
@@ -9,13 +9,6 @@ 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
- */
19
12
  export function createEncryptionProvider(key: string): EncryptionProvider {
20
13
  // Key must be 32 bytes for AES-256
21
14
  const keyBuffer = Buffer.from(key, "base64");
@@ -1,13 +1,6 @@
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";
1
+ import type { EntityDefinition } from "../engine/types";
2
+ import type { EncryptionProvider } from "./encryption";
3
+ import { createEncryptionProvider } from "./encryption";
11
4
 
12
5
  export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string> {
13
6
  const names = new Set<string>();
@@ -19,120 +12,70 @@ export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySe
19
12
  return names;
20
13
  }
21
14
 
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(
15
+ function encryptFieldValue(
30
16
  fieldName: string,
31
17
  value: unknown,
32
- cipher: EnvelopeCipher,
33
- scope: KeyScope | undefined,
34
- ): Promise<string> {
18
+ encryption: EncryptionProvider,
19
+ ): string {
35
20
  if (value === null || value === undefined) {
36
21
  throw new Error(`encrypted field "${fieldName}" cannot be null or undefined`);
37
22
  }
38
23
  if (typeof value !== "string") {
39
24
  throw new Error(`encrypted field "${fieldName}" must be a string, got ${typeof value}`);
40
25
  }
41
- return cipher.encrypt(value, scope);
26
+ return encryption.encrypt(value);
42
27
  }
43
28
 
44
- export async function encryptEntityFieldValues(
29
+ export function encryptEntityFieldValues(
45
30
  row: Record<string, unknown>,
46
31
  encryptedFields: ReadonlySet<string>,
47
- cipher: EnvelopeCipher,
32
+ encryption: EncryptionProvider,
48
33
  opts?: { onlyKeys?: Iterable<string> },
49
- ): Promise<Record<string, unknown>> {
34
+ ): Record<string, unknown> {
50
35
  if (encryptedFields.size === 0) return row;
51
36
  const only = opts?.onlyKeys ? new Set(opts.onlyKeys) : null;
52
37
  const out = { ...row };
53
- const scope = scopeOf(row);
54
38
  for (const name of encryptedFields) {
55
39
  if (only && !only.has(name)) continue;
56
40
  if (!(name in out)) continue;
57
41
  const value = out[name];
58
42
  if (value === null || value === undefined) continue;
59
- out[name] = await encryptFieldValue(name, value, cipher, scope);
43
+ out[name] = encryptFieldValue(name, value, encryption);
60
44
  }
61
45
  return out;
62
46
  }
63
47
 
64
- export async function decryptEntityFieldValues(
48
+ export function decryptEntityFieldValues(
65
49
  row: Record<string, unknown>,
66
50
  encryptedFields: ReadonlySet<string>,
67
- cipher: EnvelopeCipher,
68
- ): Promise<Record<string, unknown>> {
51
+ encryption: EncryptionProvider,
52
+ ): Record<string, unknown> {
69
53
  if (encryptedFields.size === 0) return row;
70
54
  const out = { ...row };
71
- const scope = scopeOf(row);
72
55
  for (const name of encryptedFields) {
73
56
  const value = out[name];
74
57
  if (value === null || value === undefined) continue;
75
58
  if (typeof value !== "string") continue;
76
- out[name] = await cipher.decrypt(value, scope);
59
+ out[name] = encryption.decrypt(value);
77
60
  }
78
61
  return out;
79
62
  }
80
63
 
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;
64
+ let cachedProvider: EncryptionProvider | undefined;
86
65
 
87
- export function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void {
88
- injectedCipher = cipher;
89
- }
90
-
91
- export function resolveEntityFieldEncryption(): EnvelopeCipher {
92
- if (!injectedCipher) {
66
+ export function resolveEntityFieldEncryption(): EncryptionProvider {
67
+ if (cachedProvider) return cachedProvider;
68
+ const key = process.env["ENCRYPTION_KEY"];
69
+ if (!key) {
93
70
  throw new Error(
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).",
71
+ "ENCRYPTION_KEY environment variable is required (encrypted entity fields in use)",
97
72
  );
98
73
  }
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;
74
+ cachedProvider = createEncryptionProvider(key);
75
+ return cachedProvider;
107
76
  }
108
77
 
109
78
  /** @internal test-only */
110
79
  export function resetEntityFieldEncryptionCacheForTests(): void {
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
- }
80
+ cachedProvider = undefined;
138
81
  }
@@ -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";
43
42
  import { assertUnreachable, generateId } from "../utils";
44
43
  import { applyEntityEvent } from "./apply-entity-event";
45
44
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
46
45
  import type { DbRow } from "./connection";
47
46
  import { decodeCursor, encodeCursor } from "./cursor";
48
47
  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 the boot-injected cipher for fields marked `encrypted: true`. */
123
- encryption?: EnvelopeCipher;
122
+ /** Override ENCRYPTION_KEY for entity fields marked `encrypted: true`. */
123
+ encryption?: EncryptionProvider;
124
124
  };
125
125
 
126
126
  // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
@@ -307,28 +307,24 @@ export function createEventStoreExecutor(
307
307
  const encryptedFields = collectEncryptedFieldNames(entity);
308
308
  const hasEncryptedFields = encryptedFields.size > 0;
309
309
 
310
- function fieldCipher(): EnvelopeCipher {
310
+ function encryptionProvider(): EncryptionProvider {
311
311
  if (options.encryption) return options.encryption;
312
312
  return resolveEntityFieldEncryption();
313
313
  }
314
314
 
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(
315
+ function encryptForStorage(
320
316
  row: Record<string, unknown>,
321
317
  onlyKeys?: Iterable<string>,
322
- ): Promise<Record<string, unknown>> {
318
+ ): Record<string, unknown> {
323
319
  if (!hasEncryptedFields) return row;
324
- return encryptEntityFieldValues(row, encryptedFields, fieldCipher(), {
320
+ return encryptEntityFieldValues(row, encryptedFields, encryptionProvider(), {
325
321
  ...(onlyKeys !== undefined && { onlyKeys }),
326
322
  });
327
323
  }
328
324
 
329
- async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
325
+ function decryptForRead(row: Record<string, unknown>): Record<string, unknown> {
330
326
  if (!hasEncryptedFields) return row;
331
- return decryptEntityFieldValues(row, encryptedFields, fieldCipher());
327
+ return decryptEntityFieldValues(row, encryptedFields, encryptionProvider());
332
328
  }
333
329
 
334
330
  function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
@@ -360,7 +356,7 @@ export function createEventStoreExecutor(
360
356
  async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
361
357
  const row = await db.fetchOne(table, idFilter(id));
362
358
  if (!row) return null;
363
- return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
359
+ return decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
364
360
  }
365
361
 
366
362
  // Archive guard for the CRUD write paths. Archived streams are read-only —
@@ -469,7 +465,7 @@ export function createEventStoreExecutor(
469
465
  // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
470
466
  // dieselbe Pipeline. Caller schickt combined API-Form, Framework
471
467
  // speichert flat DB-Form. Siehe db/compound-types.ts.
472
- const flatData = await encryptForStorage(flattenCompoundTypes(data, entity));
468
+ const flatData = encryptForStorage(flattenCompoundTypes(data, entity));
473
469
 
474
470
  // 1. Append event (same TX as the projection write — both must succeed
475
471
  // or both roll back; the dispatcher wraps both in one transaction).
@@ -545,9 +541,7 @@ export function createEventStoreExecutor(
545
541
  const row = result.row;
546
542
  // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
547
543
  // Compound-Types in einem Pass.
548
- const projection = await decryptForRead(
549
- rehydrateCompoundTypes(row as DbRow, entity) as DbRow,
550
- );
544
+ const projection = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
551
545
 
552
546
  if (entityCache && entityName) {
553
547
  await entityCache.del(user.tenantId, entityName, aggregateId);
@@ -650,7 +644,7 @@ export function createEventStoreExecutor(
650
644
 
651
645
  try {
652
646
  // Compound-Types Auto-Convert (alle in einem Pass).
653
- const flatChanges = await encryptForStorage(
647
+ const flatChanges = encryptForStorage(
654
648
  flattenCompoundTypes(payload.changes, entity),
655
649
  Object.keys(payload.changes),
656
650
  );
@@ -673,7 +667,7 @@ export function createEventStoreExecutor(
673
667
  type: entityEventName(entityName, "updated"),
674
668
  payload: {
675
669
  changes: stripSensitive(flatChanges),
676
- previous: stripSensitive(await encryptForStorage(previous)),
670
+ previous: stripSensitive(encryptForStorage(previous)),
677
671
  },
678
672
  metadata: buildEventMetadata(user),
679
673
  });
@@ -701,7 +695,7 @@ export function createEventStoreExecutor(
701
695
  return writeFailure(new InternalError({ message: "projection update returned no row" }));
702
696
  }
703
697
  const row = result.row;
704
- const data = await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
698
+ const data = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
705
699
 
706
700
  if (entityCache && entityName) {
707
701
  await entityCache.del(user.tenantId, entityName, payload.id);
@@ -783,7 +777,7 @@ export function createEventStoreExecutor(
783
777
  tenantId: streamTenantFor(user),
784
778
  expectedVersion: currentVersion,
785
779
  type: entityEventName(entityName, "deleted"),
786
- payload: { previous: stripSensitive(await encryptForStorage(existing)) },
780
+ payload: { previous: stripSensitive(encryptForStorage(existing)) },
787
781
  metadata: buildEventMetadata(user),
788
782
  });
789
783
 
@@ -816,7 +810,7 @@ export function createEventStoreExecutor(
816
810
  async forget(payload, user, db) {
817
811
  const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
818
812
  if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
819
- const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
813
+ const existing = decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
820
814
 
821
815
  if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
822
816
  return writeFailure(
@@ -943,7 +937,7 @@ export function createEventStoreExecutor(
943
937
  // row and `previous` snapshot must be plaintext for `encrypted` fields,
944
938
  // same as every other executor method — `data`/`restored` are raw rows
945
939
  // (selectMany / applyEntityEvent), never decrypted before this point.
946
- const restoredHydrated = await decryptForRead(
940
+ const restoredHydrated = decryptForRead(
947
941
  rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
948
942
  );
949
943
 
@@ -954,7 +948,7 @@ export function createEventStoreExecutor(
954
948
  id: payload.id,
955
949
  data: restoredHydrated,
956
950
  changes: { isDeleted: false },
957
- previous: await decryptForRead(data),
951
+ previous: decryptForRead(data),
958
952
  isNew: false,
959
953
  entityName,
960
954
  event,
@@ -1088,10 +1082,8 @@ export function createEventStoreExecutor(
1088
1082
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
1089
1083
  // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
1090
1084
  const tableInfo = extractTableInfo(table);
1091
- const rows = await Promise.all(
1092
- rawRows.map(async (r) =>
1093
- coerceRow(await decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1094
- ),
1085
+ const rows = rawRows.map((r) =>
1086
+ coerceRow(decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1095
1087
  );
1096
1088
 
1097
1089
  // list rows carry the READ-ROW version (display-only), never an optimistic-lock
@@ -1164,24 +1156,19 @@ export function createEventStoreExecutor(
1164
1156
  // Cached rows are stored re-encrypted (see the `set` below) so an
1165
1157
  // `encrypted` field's plaintext never sits in a second at-rest
1166
1158
  // store (Redis) the field-encryption feature doesn't cover.
1167
- return withStreamVersion(await decryptForRead(cached));
1159
+ return withStreamVersion(decryptForRead(cached));
1168
1160
  }
1169
1161
  }
1170
1162
 
1171
1163
  const rows = await loadWithOwnership(db, idWhere, ownership);
1172
1164
  const raw = rows[0];
1173
1165
  if (!raw) return null;
1174
- const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
1166
+ const row = decryptForRead(rehydrateCompoundTypes(raw, entity));
1175
1167
  const rowInfo = extractTableInfo(table);
1176
1168
  const coerced = coerceRow(row, rowInfo);
1177
1169
 
1178
1170
  if (entityCache && entityName) {
1179
- await entityCache.set(
1180
- user.tenantId,
1181
- entityName,
1182
- payload.id,
1183
- await encryptForStorage(coerced),
1184
- );
1171
+ await entityCache.set(user.tenantId, entityName, payload.id, encryptForStorage(coerced));
1185
1172
  }
1186
1173
 
1187
1174
  return withStreamVersion(coerced);
package/src/db/index.ts CHANGED
@@ -38,11 +38,8 @@ export type { EncryptionProvider } from "./encryption";
38
38
  export { createEncryptionProvider } from "./encryption";
39
39
  export {
40
40
  collectEncryptedFieldNames,
41
- configuredEntityFieldEncryption,
42
- configureEntityFieldEncryption,
43
41
  decryptEntityFieldValues,
44
42
  encryptEntityFieldValues,
45
- resetEntityFieldEncryptionCacheForTests,
46
43
  } from "./entity-field-encryption";
47
44
  export type {
48
45
  BuildEntityTableMetaOptions,
package/src/db/query.ts CHANGED
@@ -17,7 +17,6 @@ export {
17
17
  insertMany,
18
18
  insertOne,
19
19
  type OrderByClause,
20
- runInSavepoint,
21
20
  type SelectOptions,
22
21
  selectMany,
23
22
  type TableInfo,
@@ -12,21 +12,10 @@ import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
12
12
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
13
13
  import type { DbRunner } from "./connection";
14
14
  import type { TableColumns } from "./dialect";
15
- import type { EntityTableMeta } from "./entity-table-meta";
16
- import type { NotExecutorOnly } from "./table-builder";
17
15
 
18
16
  // biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
19
17
  type Table = TableColumns<any>;
20
18
 
21
- // Method-form writes reject the executor-only brand exactly like the free-function
22
- // helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
23
- // directly — free-function OR method-form — drifts the row past its event stream and
24
- // a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
25
- // metas are not projections → writable); `& NotExecutorOnly` strips only branded
26
- // EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
27
- // the plain `Table` param.
28
- type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
29
-
30
19
  /**
31
20
  * TenantDb scope modes:
32
21
  *
@@ -56,15 +45,21 @@ export type TenantDb = {
56
45
  ): Promise<readonly T[]>;
57
46
  fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
58
47
  insertOne<T = Record<string, unknown>>(
59
- table: WritableTable,
48
+ table: Table,
60
49
  values: Record<string, unknown>,
61
50
  ): Promise<T | undefined>;
62
51
  updateMany<T = Record<string, unknown>>(
63
- table: WritableTable,
52
+ table: Table,
64
53
  set: Record<string, unknown>,
65
54
  where: WhereObject,
66
55
  ): Promise<readonly T[]>;
67
- deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
56
+ // Method-form writes (ctx.db.insertOne/updateMany/deleteMany) keep the erased
57
+ // Table param: the executor-only brand is enforced on the free-function
58
+ // helpers (the reflexive path all production writes take), while method-form
59
+ // on a branded table is covered by the guard-direct-entity-writes AST guard
60
+ // (P5) — branding it here would force every push+write test into casts for
61
+ // zero production benefit (no prod code writes a projection via method-form).
62
+ deleteMany(table: Table, where: WhereObject): Promise<void>;
68
63
  };
69
64
 
70
65
  // @cast-boundary tenant-db-row
@@ -10,7 +10,6 @@ import {
10
10
  validateGdprStoragePersistence,
11
11
  } from "../boot-validator/gdpr-storage";
12
12
  import { defineFeature } from "../define-feature";
13
- import { EXT_USER_DATA } from "../extension-names";
14
13
 
15
14
  const udr = () => defineFeature("user-data-rights", () => {});
16
15
  const fileProvider = (name: string) =>
@@ -95,7 +94,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
95
94
 
96
95
  test("export + delete hooks → no warn", () => {
97
96
  const f = defineFeature("my-feature", (r) => {
98
- r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
97
+ r.useExtension("userData", "myEntity", { export: exportFn, delete: deleteFn });
99
98
  });
100
99
  validateGdprHookCompleteness([f]);
101
100
  expect(warnSpy).not.toHaveBeenCalled();
@@ -103,7 +102,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
103
102
 
104
103
  test("export hook without delete hook → Art.17 warn", () => {
105
104
  const f = defineFeature("my-feature", (r) => {
106
- r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
105
+ r.useExtension("userData", "myEntity", { export: exportFn });
107
106
  });
108
107
  validateGdprHookCompleteness([f]);
109
108
  expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -115,7 +114,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
115
114
 
116
115
  test("delete hook only (no export) → no warn", () => {
117
116
  const f = defineFeature("my-feature", (r) => {
118
- r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
117
+ r.useExtension("userData", "myEntity", { delete: deleteFn });
119
118
  });
120
119
  validateGdprHookCompleteness([f]);
121
120
  expect(warnSpy).not.toHaveBeenCalled();
@@ -131,10 +130,10 @@ describe("validateGdprHookCompleteness (V2)", () => {
131
130
 
132
131
  test("multiple features, one missing delete → one warn per missing hook", () => {
133
132
  const good = defineFeature("good", (r) => {
134
- r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
133
+ r.useExtension("userData", "entityA", { export: exportFn, delete: deleteFn });
135
134
  });
136
135
  const bad = defineFeature("bad", (r) => {
137
- r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
136
+ r.useExtension("userData", "entityB", { export: exportFn });
138
137
  });
139
138
  validateGdprHookCompleteness([good, bad]);
140
139
  expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -106,10 +106,8 @@ describe("boot-validator", () => {
106
106
  expect(() => validateBoot(features)).toThrow(/token.*cannot be both encrypted and sortable/i);
107
107
  });
108
108
 
109
- test("allows encrypted field when a master key keyring is available", () => {
110
- process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = Buffer.from(
111
- "0123456789abcdef0123456789abcdef",
112
- ).toString("base64");
109
+ test("allows encrypted field when ENCRYPTION_KEY is set", () => {
110
+ process.env["ENCRYPTION_KEY"] = "test-key";
113
111
  try {
114
112
  const features = [
115
113
  defineFeature("a", (r) => {
@@ -126,34 +124,12 @@ describe("boot-validator", () => {
126
124
  ];
127
125
  expect(() => validateBoot(features)).not.toThrow();
128
126
  } finally {
129
- delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
130
- }
131
- });
132
-
133
- test("throws at boot when the master key is malformed (not 32 bytes)", () => {
134
- process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = Buffer.from("too-short").toString("base64");
135
- try {
136
- const features = [
137
- defineFeature("a", (r) => {
138
- r.entity(
139
- "secret",
140
- createEntity({
141
- table: "Secrets",
142
- fields: {
143
- apiKey: { type: "text", encrypted: true },
144
- },
145
- }),
146
- );
147
- }),
148
- ];
149
- expect(() => validateBoot(features)).toThrow(/master key/i);
150
- } finally {
151
- delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
127
+ delete process.env["ENCRYPTION_KEY"];
152
128
  }
153
129
  });
154
130
 
155
- test("throws when encrypted fields exist but no master key is available", () => {
156
- delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
131
+ test("throws when encrypted fields exist but ENCRYPTION_KEY not set", () => {
132
+ delete process.env["ENCRYPTION_KEY"];
157
133
  const features = [
158
134
  defineFeature("a", (r) => {
159
135
  r.entity(
@@ -167,16 +143,16 @@ describe("boot-validator", () => {
167
143
  );
168
144
  }),
169
145
  ];
170
- expect(() => validateBoot(features)).toThrow(/master key/i);
146
+ expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
171
147
  });
172
148
 
173
- test("throws when longText encrypted field exists but no master key is available", () => {
149
+ test("throws when longText encrypted field exists but ENCRYPTION_KEY not set", () => {
174
150
  // Drift-pin Sprint-5b-vorab-Audit Issue 1: validateEncryptedFields
175
151
  // hatte `if (field.type !== "text") continue;` und ignorierte
176
152
  // longText-encrypted-fields silently — ENCRYPTION_KEY-check wurde
177
153
  // nie getriggert, encryption silent broken. Jetzt: beide string-
178
154
  // typed fields werden gechecked.
179
- delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
155
+ delete process.env["ENCRYPTION_KEY"];
180
156
  const features = [
181
157
  defineFeature("a", (r) => {
182
158
  r.entity(
@@ -190,7 +166,7 @@ describe("boot-validator", () => {
190
166
  );
191
167
  }),
192
168
  ];
193
- expect(() => validateBoot(features)).toThrow(/master key/i);
169
+ expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
194
170
  });
195
171
 
196
172
  test("rejects encrypted text field that is also filterable", () => {
@@ -256,27 +256,6 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
256
256
  expect(out.navs.find((n) => n.id === "audience-tenant")?.access).toEqual({ openToAll: true });
257
257
  });
258
258
 
259
- test("openToAll collapse holds regardless of key declaration order (516/2)", () => {
260
- // The test above only ever puts the openToAll key first, so
261
- // unionAccessRules's short-circuit (`if ("openToAll" in rule) return`)
262
- // never actually has to walk past a role-restricted rule to prove it —
263
- // `gated` here comes FIRST so a hypothetical short-circuit-on-first-only
264
- // implementation would fail this one even though it passed the other.
265
- const mixedWriteReversed = defineFeature("mixedwritereversed", (r) => {
266
- r.config({
267
- keys: {
268
- gated: createTenantConfig("text", { mask: { title: "mwr.gated" } }), // default admin write
269
- open: createTenantConfig("text", { write: access.all, mask: { title: "mwr.open" } }),
270
- },
271
- });
272
- });
273
- const out = buildConfigFeatureSchema(createRegistry([mixedWriteReversed]));
274
- const screen = out.screens.find((s) => s.id === "mixedwritereversed-tenant");
275
- if (screen?.type !== "configEdit")
276
- throw new Error('expected configEdit screen "mixedwritereversed-tenant"');
277
- expect(screen.access).toEqual({ openToAll: true });
278
- });
279
-
280
259
  test("returns empty (no workspace) when no key opts into the hub via mask", () => {
281
260
  const plain = defineFeature("plain", (r) => {
282
261
  r.config({ keys: { secret: createSystemConfig("text", {}) } });