@cosmicdrift/kumiko-framework 0.114.0 → 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.
- package/package.json +2 -2
- package/src/__tests__/schema-cli.integration.test.ts +0 -29
- package/src/api/__tests__/batch.integration.test.ts +1 -4
- package/src/api/__tests__/request-id-middleware.test.ts +0 -51
- package/src/api/auth-middleware.ts +1 -65
- package/src/api/auth-routes.ts +0 -11
- package/src/api/index.ts +1 -3
- package/src/api/jwt.ts +4 -1
- package/src/api/request-context.ts +0 -3
- package/src/api/request-id-middleware.ts +0 -2
- package/src/api/routes.ts +1 -26
- package/src/api/server.ts +1 -29
- package/src/bun-db/__tests__/write-brand.test.ts +0 -21
- package/src/bun-db/query.ts +0 -17
- package/src/db/__tests__/config-seed.integration.test.ts +5 -13
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
- package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
- package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
- package/src/db/config-seed.ts +9 -9
- package/src/db/encryption.ts +0 -7
- package/src/db/entity-field-encryption.ts +24 -81
- package/src/db/event-store-executor.ts +24 -37
- package/src/db/index.ts +0 -3
- package/src/db/query.ts +0 -1
- package/src/db/tenant-db.ts +9 -14
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
- package/src/engine/__tests__/boot-validator.test.ts +9 -33
- package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
- package/src/engine/__tests__/registry.test.ts +0 -28
- package/src/engine/__tests__/screen.test.ts +0 -40
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
- package/src/engine/boot-validator/gdpr-storage.ts +1 -2
- package/src/engine/boot-validator/index.ts +2 -7
- package/src/engine/boot-validator/screens-nav.ts +0 -21
- package/src/engine/define-feature.ts +0 -17
- package/src/engine/define-handler.ts +2 -16
- package/src/engine/extensions/user-data.ts +1 -12
- package/src/engine/index.ts +2 -5
- package/src/engine/registry.ts +21 -44
- package/src/engine/soft-delete-cleanup.ts +2 -6
- package/src/engine/types/feature.ts +21 -29
- package/src/engine/types/fields.ts +1 -3
- package/src/engine/types/handlers.ts +2 -16
- package/src/engine/types/index.ts +0 -2
- package/src/engine/types/projection.ts +0 -22
- package/src/engine/types/screen.ts +0 -29
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/index.ts +0 -8
- package/src/files/provider-resolver.ts +15 -28
- package/src/i18n/required-surface-keys.ts +0 -12
- package/src/observability/__tests__/observability.integration.test.ts +1 -4
- package/src/pipeline/dispatcher.ts +6 -2
- package/src/pipeline/msp-rebuild.ts +3 -36
- package/src/pipeline/projection-rebuild.ts +3 -61
- package/src/schema-cli.ts +15 -24
- package/src/secrets/__tests__/contains-secret.test.ts +0 -34
- package/src/secrets/dek-cache.ts +5 -26
- package/src/secrets/envelope.ts +3 -5
- package/src/secrets/index.ts +1 -13
- package/src/secrets/types.ts +3 -18
- package/src/testing/e2e-generator.ts +0 -3
- package/src/testing/index.ts +0 -2
- package/src/testing/mutable-master-key-provider.ts +3 -29
- package/src/time/__tests__/tz-dateline.test.ts +10 -8
- package/src/ui-types/index.ts +0 -1
- package/src/api/__tests__/pat-scope.test.ts +0 -36
- package/src/api/pat-scope.ts +0 -14
- package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
- package/src/event-store/rebuild-dead-letter.ts +0 -111
- package/src/files/__tests__/provider-resolver.test.ts +0 -70
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
- package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
- package/src/secrets/envelope-cipher.ts +0 -81
- 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,
|
|
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
|
-
|
|
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
|
|
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";
|
|
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
|
-
|
|
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
|
|
52
|
-
// column the resolver later tries to decrypt — fail
|
|
53
|
-
// on first read in prod.
|
|
54
|
-
if (keyDef.encrypted && !
|
|
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
|
|
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 &&
|
|
81
|
-
value =
|
|
80
|
+
if (keyDef.encrypted && encryption) {
|
|
81
|
+
value = encryption.encrypt(value);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const payload: Record<string, unknown> = {
|
package/src/db/encryption.ts
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
26
|
+
return encryption.encrypt(value);
|
|
42
27
|
}
|
|
43
28
|
|
|
44
|
-
export
|
|
29
|
+
export function encryptEntityFieldValues(
|
|
45
30
|
row: Record<string, unknown>,
|
|
46
31
|
encryptedFields: ReadonlySet<string>,
|
|
47
|
-
|
|
32
|
+
encryption: EncryptionProvider,
|
|
48
33
|
opts?: { onlyKeys?: Iterable<string> },
|
|
49
|
-
):
|
|
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] =
|
|
43
|
+
out[name] = encryptFieldValue(name, value, encryption);
|
|
60
44
|
}
|
|
61
45
|
return out;
|
|
62
46
|
}
|
|
63
47
|
|
|
64
|
-
export
|
|
48
|
+
export function decryptEntityFieldValues(
|
|
65
49
|
row: Record<string, unknown>,
|
|
66
50
|
encryptedFields: ReadonlySet<string>,
|
|
67
|
-
|
|
68
|
-
):
|
|
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] =
|
|
59
|
+
out[name] = encryption.decrypt(value);
|
|
77
60
|
}
|
|
78
61
|
return out;
|
|
79
62
|
}
|
|
80
63
|
|
|
81
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
encryption?:
|
|
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
|
|
310
|
+
function encryptionProvider(): EncryptionProvider {
|
|
311
311
|
if (options.encryption) return options.encryption;
|
|
312
312
|
return resolveEntityFieldEncryption();
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
|
|
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
|
-
):
|
|
318
|
+
): Record<string, unknown> {
|
|
323
319
|
if (!hasEncryptedFields) return row;
|
|
324
|
-
return encryptEntityFieldValues(row, encryptedFields,
|
|
320
|
+
return encryptEntityFieldValues(row, encryptedFields, encryptionProvider(), {
|
|
325
321
|
...(onlyKeys !== undefined && { onlyKeys }),
|
|
326
322
|
});
|
|
327
323
|
}
|
|
328
324
|
|
|
329
|
-
|
|
325
|
+
function decryptForRead(row: Record<string, unknown>): Record<string, unknown> {
|
|
330
326
|
if (!hasEncryptedFields) return row;
|
|
331
|
-
return decryptEntityFieldValues(row, encryptedFields,
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
1092
|
-
|
|
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(
|
|
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 =
|
|
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
package/src/db/tenant-db.ts
CHANGED
|
@@ -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:
|
|
48
|
+
table: Table,
|
|
60
49
|
values: Record<string, unknown>,
|
|
61
50
|
): Promise<T | undefined>;
|
|
62
51
|
updateMany<T = Record<string, unknown>>(
|
|
63
|
-
table:
|
|
52
|
+
table: Table,
|
|
64
53
|
set: Record<string, unknown>,
|
|
65
54
|
where: WhereObject,
|
|
66
55
|
): Promise<readonly T[]>;
|
|
67
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
133
|
+
r.useExtension("userData", "entityA", { export: exportFn, delete: deleteFn });
|
|
135
134
|
});
|
|
136
135
|
const bad = defineFeature("bad", (r) => {
|
|
137
|
-
r.useExtension(
|
|
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
|
|
110
|
-
process.env["
|
|
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["
|
|
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
|
|
156
|
-
delete process.env["
|
|
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(/
|
|
146
|
+
expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
|
|
171
147
|
});
|
|
172
148
|
|
|
173
|
-
test("throws when longText encrypted field exists but
|
|
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["
|
|
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(/
|
|
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", {}) } });
|