@lazyapps/encryption 0.0.0-init.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.
@@ -0,0 +1,90 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+
3
+ export const createForgetSubjectEndpoints = (encryption) => (context, app) => {
4
+ const log = getLogger('Encryption/Endpoints', 'INIT');
5
+
6
+ app.post('/api/forget-subject', (req, res) => {
7
+ const reqTimestamp = Date.now();
8
+ const correlationId = req.body.correlationId;
9
+ const reqLog = getLogger('Encryption/ForgetSubject', correlationId);
10
+
11
+ const subjectId = req.body.subjectId;
12
+ if (!subjectId) {
13
+ res.status(400).json({ error: 'Missing subjectId' });
14
+ return;
15
+ }
16
+
17
+ const aggregate = context.aggregates && context.aggregates.subjectLifecycle;
18
+ if (!aggregate) {
19
+ reqLog.error('subjectLifecycle aggregate not registered');
20
+ res.status(500).json({ error: 'subjectLifecycle aggregate not found' });
21
+ return;
22
+ }
23
+
24
+ const commandHandler =
25
+ aggregate.commands && aggregate.commands.FORGET_SUBJECT;
26
+ if (!commandHandler) {
27
+ reqLog.error('FORGET_SUBJECT command not found on subjectLifecycle');
28
+ res.status(500).json({ error: 'FORGET_SUBJECT command not found' });
29
+ return;
30
+ }
31
+
32
+ const payload = {
33
+ subjectId,
34
+ subjectType: req.body.subjectType || 'unknown',
35
+ reason: req.body.reason || 'Right to be forgotten',
36
+ requestedBy:
37
+ req.body.requestedBy || (req.auth && req.auth.sub) || 'system',
38
+ };
39
+
40
+ context
41
+ .handleCommand(
42
+ context.aggregateStore,
43
+ context.eventStore,
44
+ context.eventBus,
45
+ 'FORGET_SUBJECT',
46
+ 'subjectLifecycle',
47
+ subjectId,
48
+ payload,
49
+ commandHandler,
50
+ req.auth,
51
+ reqTimestamp,
52
+ correlationId,
53
+ )
54
+ .then(() => {
55
+ res.json({ status: 'forgotten', subjectId });
56
+ })
57
+ .catch((err) => {
58
+ if (err.name === 'ValidationError') {
59
+ reqLog.error(`Validation error: ${err.message}`);
60
+ res.status(400).json({ error: err.message });
61
+ } else {
62
+ reqLog.error(`Error forgetting subject ${subjectId}: ${err}`);
63
+ res.status(500).json({ error: err.message });
64
+ }
65
+ });
66
+ });
67
+
68
+ app.post('/api/admin/rotate-context-key', (req, res) => {
69
+ const correlationId = req.body.correlationId;
70
+ const reqLog = getLogger('Encryption/RotateKey', correlationId);
71
+
72
+ const contextName = req.body.contextName;
73
+ if (!contextName) {
74
+ res.status(400).json({ error: 'Missing contextName' });
75
+ return;
76
+ }
77
+
78
+ encryption
79
+ .then((enc) => enc.rotateContextKey(contextName))
80
+ .then(() => {
81
+ res.json({ status: 'rotated', context: contextName });
82
+ })
83
+ .catch((err) => {
84
+ reqLog.error(`Error rotating key for context ${contextName}: ${err}`);
85
+ res.status(500).json({ error: err.message });
86
+ });
87
+ });
88
+
89
+ log.info('Forget-subject and rotate-context-key endpoints installed');
90
+ };
package/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { createEncryption } from './encryption.js';
2
+ export { defineEncryptionSchema } from './schema.js';
3
+ export { inMemoryKeyStore } from './keystores/inmemory.js';
4
+ export { mongoKeyStore } from './keystores/mongo.js';
5
+ export { vaultKeyStore, appRole } from './keystores/vault.js';
6
+ export { subjectLifecycleAggregate } from './subjectLifecycle.js';
7
+ export { createForgetSubjectEndpoints } from './forgetSubjectEndpoints.js';
8
+ export { getNestedValue, setNestedValue } from './pathUtils.js';
package/keyCache.js ADDED
@@ -0,0 +1,52 @@
1
+ export const createKeyCache = (
2
+ keyStore,
3
+ { maxSize = 10000, ttlMs = 300000 },
4
+ ) => {
5
+ const cache = new Map();
6
+
7
+ const evictExpired = () => {
8
+ const now = Date.now();
9
+ for (const [key, entry] of cache) {
10
+ if (entry.expiresAt < now) cache.delete(key);
11
+ }
12
+ };
13
+
14
+ const evictLRU = () => {
15
+ if (cache.size <= maxSize) return;
16
+ const firstKey = cache.keys().next().value;
17
+ cache.delete(firstKey);
18
+ };
19
+
20
+ return {
21
+ ...keyStore,
22
+
23
+ getDEK: (subjectId, contextName, version) => {
24
+ const cacheKey = `${subjectId}:${contextName}:${version || 'latest'}`;
25
+ const cached = cache.get(cacheKey);
26
+ if (cached && cached.expiresAt > Date.now()) {
27
+ cache.delete(cacheKey);
28
+ cache.set(cacheKey, cached);
29
+ return Promise.resolve(cached.value);
30
+ }
31
+
32
+ return keyStore.getDEK(subjectId, contextName, version).then((dek) => {
33
+ if (dek) {
34
+ evictExpired();
35
+ cache.set(cacheKey, {
36
+ value: dek,
37
+ expiresAt: Date.now() + ttlMs,
38
+ });
39
+ evictLRU();
40
+ }
41
+ return dek;
42
+ });
43
+ },
44
+
45
+ deleteKeysForSubject: (subjectId) => {
46
+ for (const key of cache.keys()) {
47
+ if (key.startsWith(`${subjectId}:`)) cache.delete(key);
48
+ }
49
+ return keyStore.deleteKeysForSubject(subjectId);
50
+ },
51
+ };
52
+ };
@@ -0,0 +1,93 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+
3
+ const ALGORITHM = 'aes-256-gcm';
4
+ const IV_LENGTH = 12;
5
+
6
+ export const inMemoryKeyStore = (initialKEKs = {}) => ({
7
+ initialize: () => {
8
+ const keks = new Map(
9
+ Object.entries(initialKEKs).map(([k, v]) => [
10
+ k,
11
+ Buffer.isBuffer(v) ? v : Buffer.from(v, 'base64'),
12
+ ]),
13
+ );
14
+ const deks = new Map();
15
+
16
+ const wrapLocal = (kek, plainDEK) => {
17
+ const iv = randomBytes(IV_LENGTH);
18
+ const cipher = createCipheriv(ALGORITHM, kek, iv);
19
+ const encrypted = Buffer.concat([
20
+ cipher.update(plainDEK),
21
+ cipher.final(),
22
+ ]);
23
+ const tag = cipher.getAuthTag();
24
+ return {
25
+ iv: iv.toString('base64'),
26
+ data: encrypted.toString('base64'),
27
+ tag: tag.toString('base64'),
28
+ };
29
+ };
30
+
31
+ const unwrapLocal = (kek, wrapped) => {
32
+ const decipher = createDecipheriv(
33
+ ALGORITHM,
34
+ kek,
35
+ Buffer.from(wrapped.iv, 'base64'),
36
+ );
37
+ decipher.setAuthTag(Buffer.from(wrapped.tag, 'base64'));
38
+ return Buffer.concat([
39
+ decipher.update(Buffer.from(wrapped.data, 'base64')),
40
+ decipher.final(),
41
+ ]);
42
+ };
43
+
44
+ return Promise.resolve({
45
+ wrapDEK: (contextName, plainDEK) =>
46
+ keks.has(contextName)
47
+ ? Promise.resolve(wrapLocal(keks.get(contextName), plainDEK))
48
+ : Promise.reject(
49
+ Object.assign(new Error(`KEK not found: ${contextName}`), {
50
+ code: 'KEK_NOT_FOUND',
51
+ }),
52
+ ),
53
+
54
+ unwrapDEK: (contextName, wrappedDEK) =>
55
+ keks.has(contextName)
56
+ ? Promise.resolve(unwrapLocal(keks.get(contextName), wrappedDEK))
57
+ : Promise.reject(
58
+ Object.assign(new Error(`KEK not found: ${contextName}`), {
59
+ code: 'KEK_NOT_FOUND',
60
+ }),
61
+ ),
62
+
63
+ getDEK: (subjectId, contextName) => {
64
+ const key = `${subjectId}:${contextName}`;
65
+ return Promise.resolve(deks.get(key) || null);
66
+ },
67
+
68
+ storeDEK: (subjectId, contextName, dekInfo) => {
69
+ deks.set(`${subjectId}:${contextName}`, {
70
+ wrappedKey: dekInfo.wrappedKey,
71
+ version: dekInfo.version,
72
+ });
73
+ return Promise.resolve();
74
+ },
75
+
76
+ getAllDEKsForContext: (contextName) =>
77
+ Promise.resolve(
78
+ Array.from(deks.entries())
79
+ .filter(([k]) => k.endsWith(`:${contextName}`))
80
+ .map(([k, v]) => ({ subjectId: k.split(':')[0], ...v })),
81
+ ),
82
+
83
+ deleteKeysForSubject: (subjectId) => {
84
+ for (const key of deks.keys()) {
85
+ if (key.startsWith(`${subjectId}:`)) deks.delete(key);
86
+ }
87
+ return Promise.resolve();
88
+ },
89
+
90
+ close: () => Promise.resolve(),
91
+ });
92
+ },
93
+ });
@@ -0,0 +1,125 @@
1
+ import { MongoClient } from 'mongodb';
2
+ import {
3
+ createCipheriv,
4
+ createDecipheriv,
5
+ createHmac,
6
+ randomBytes,
7
+ } from 'node:crypto';
8
+ import { getLogger } from '@lazyapps/logger';
9
+
10
+ const ALGORITHM = 'aes-256-gcm';
11
+ const IV_LENGTH = 12;
12
+
13
+ const deriveKEK = (rootSecret, contextName) => {
14
+ const hmac = createHmac('sha256', rootSecret);
15
+ hmac.update(contextName);
16
+ return hmac.digest();
17
+ };
18
+
19
+ const wrapLocal = (kek, plainDEK) => {
20
+ const iv = randomBytes(IV_LENGTH);
21
+ const cipher = createCipheriv(ALGORITHM, kek, iv);
22
+ const encrypted = Buffer.concat([cipher.update(plainDEK), cipher.final()]);
23
+ const tag = cipher.getAuthTag();
24
+ return {
25
+ iv: iv.toString('base64'),
26
+ data: encrypted.toString('base64'),
27
+ tag: tag.toString('base64'),
28
+ };
29
+ };
30
+
31
+ const unwrapLocal = (kek, wrapped) => {
32
+ const decipher = createDecipheriv(
33
+ ALGORITHM,
34
+ kek,
35
+ Buffer.from(wrapped.iv, 'base64'),
36
+ );
37
+ decipher.setAuthTag(Buffer.from(wrapped.tag, 'base64'));
38
+ return Buffer.concat([
39
+ decipher.update(Buffer.from(wrapped.data, 'base64')),
40
+ decipher.final(),
41
+ ]);
42
+ };
43
+
44
+ export const mongoKeyStore = ({
45
+ url,
46
+ rootSecret,
47
+ database = 'encryption-keys',
48
+ dekCollection = 'deks',
49
+ }) => ({
50
+ initialize: () => {
51
+ const log = getLogger('Encryption/KS', 'INIT');
52
+ const secret = Buffer.isBuffer(rootSecret)
53
+ ? rootSecret
54
+ : Buffer.from(rootSecret, 'base64');
55
+
56
+ return MongoClient.connect(url).then((client) => {
57
+ const db = client.db(database);
58
+ log.info(`Key store connected to ${database}`);
59
+ return {
60
+ wrapDEK: (contextName, plainDEK) =>
61
+ Promise.resolve(wrapLocal(deriveKEK(secret, contextName), plainDEK)),
62
+
63
+ unwrapDEK: (contextName, wrappedDEK) =>
64
+ Promise.resolve(
65
+ unwrapLocal(deriveKEK(secret, contextName), wrappedDEK),
66
+ ),
67
+
68
+ getDEK: (subjectId, contextName, version) => {
69
+ const filter = { subjectId, context: contextName };
70
+ if (version) filter.version = version;
71
+ return db
72
+ .collection(dekCollection)
73
+ .findOne(filter, { sort: { version: -1 } })
74
+ .then((doc) =>
75
+ doc
76
+ ? {
77
+ wrappedKey: { iv: doc.iv, data: doc.data, tag: doc.tag },
78
+ version: doc.version,
79
+ }
80
+ : null,
81
+ );
82
+ },
83
+
84
+ storeDEK: (subjectId, contextName, dekInfo) =>
85
+ db.collection(dekCollection).insertOne({
86
+ subjectId,
87
+ context: contextName,
88
+ version: dekInfo.version,
89
+ iv: dekInfo.wrappedKey.iv,
90
+ data: dekInfo.wrappedKey.data,
91
+ tag: dekInfo.wrappedKey.tag,
92
+ createdAt: Date.now(),
93
+ }),
94
+
95
+ getAllDEKsForContext: (contextName) =>
96
+ db
97
+ .collection(dekCollection)
98
+ .find({ context: contextName })
99
+ .toArray()
100
+ .then((docs) =>
101
+ docs.map((d) => ({
102
+ subjectId: d.subjectId,
103
+ wrappedKey: { iv: d.iv, data: d.data, tag: d.tag },
104
+ version: d.version,
105
+ })),
106
+ ),
107
+
108
+ deleteKeysForSubject: (subjectId) => {
109
+ const ksLog = getLogger('Encryption/KS', 'ADMIN');
110
+ ksLog.info(`Deleting all keys for subject: ${subjectId}`);
111
+ return db
112
+ .collection(dekCollection)
113
+ .deleteMany({ subjectId })
114
+ .then((result) => {
115
+ ksLog.info(
116
+ `Deleted ${result.deletedCount} keys for ${subjectId}`,
117
+ );
118
+ });
119
+ },
120
+
121
+ close: () => client.close(),
122
+ };
123
+ });
124
+ },
125
+ });
@@ -0,0 +1,144 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+
3
+ const vaultRequest = (vaultUrl, token) => (method, path, body) =>
4
+ fetch(`${vaultUrl}/v1/${path}`, {
5
+ method,
6
+ headers: {
7
+ 'X-Vault-Token': token,
8
+ 'Content-Type': 'application/json',
9
+ },
10
+ body: body ? JSON.stringify(body) : undefined,
11
+ }).then((res) =>
12
+ res.ok
13
+ ? res.json()
14
+ : res.json().then((err) => {
15
+ const error = new Error(
16
+ `Vault ${method} ${path}: ${err.errors?.[0] || res.statusText}`,
17
+ );
18
+ error.status = res.status;
19
+ throw error;
20
+ }),
21
+ );
22
+
23
+ const authenticateAppRole = (vaultUrl, roleId, secretId) =>
24
+ fetch(`${vaultUrl}/v1/auth/approle/login`, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
28
+ })
29
+ .then((res) => res.json())
30
+ .then((data) => data.auth.client_token);
31
+
32
+ const initDekInMemory = () => {
33
+ const deks = new Map();
34
+ return Promise.resolve({
35
+ getDEK: (subjectId, contextName) => {
36
+ const key = `${subjectId}:${contextName}`;
37
+ return Promise.resolve(deks.get(key) || null);
38
+ },
39
+ storeDEK: (subjectId, contextName, dekInfo) => {
40
+ deks.set(`${subjectId}:${contextName}`, {
41
+ wrappedKey: dekInfo.wrappedKey,
42
+ version: dekInfo.version,
43
+ });
44
+ return Promise.resolve();
45
+ },
46
+ getAllDEKsForContext: (contextName) =>
47
+ Promise.resolve(
48
+ Array.from(deks.entries())
49
+ .filter(([k]) => k.endsWith(`:${contextName}`))
50
+ .map(([k, v]) => ({ subjectId: k.split(':')[0], ...v })),
51
+ ),
52
+ deleteKeysForSubject: (subjectId) => {
53
+ for (const key of deks.keys()) {
54
+ if (key.startsWith(`${subjectId}:`)) deks.delete(key);
55
+ }
56
+ return Promise.resolve();
57
+ },
58
+ close: () => Promise.resolve(),
59
+ });
60
+ };
61
+
62
+ const initDekMongo = ({ url, database, collection }) =>
63
+ import('mongodb').then(({ MongoClient }) =>
64
+ MongoClient.connect(url).then((client) => {
65
+ const db = client.db(database);
66
+ const coll = db.collection(collection);
67
+ return {
68
+ getDEK: (subjectId, contextName) =>
69
+ coll
70
+ .findOne(
71
+ { subjectId, context: contextName },
72
+ { sort: { version: -1 } },
73
+ )
74
+ .then((doc) =>
75
+ doc ? { wrappedKey: doc.wrappedKey, version: doc.version } : null,
76
+ ),
77
+ storeDEK: (subjectId, contextName, dekInfo) =>
78
+ coll.insertOne({
79
+ subjectId,
80
+ context: contextName,
81
+ wrappedKey: dekInfo.wrappedKey,
82
+ version: dekInfo.version,
83
+ createdAt: Date.now(),
84
+ }),
85
+ getAllDEKsForContext: (contextName) =>
86
+ coll
87
+ .find({ context: contextName })
88
+ .toArray()
89
+ .then((docs) =>
90
+ docs.map((d) => ({
91
+ subjectId: d.subjectId,
92
+ wrappedKey: d.wrappedKey,
93
+ version: d.version,
94
+ })),
95
+ ),
96
+ deleteKeysForSubject: (subjectId) =>
97
+ coll.deleteMany({ subjectId }).then(() => {}),
98
+ close: () => client.close(),
99
+ };
100
+ }),
101
+ );
102
+
103
+ export const appRole = ({ roleId, secretId }) => ({ roleId, secretId });
104
+
105
+ export const vaultKeyStore = ({ vaultUrl, token, authMethod, dekBackend }) => ({
106
+ initialize: () => {
107
+ const log = getLogger('Encryption/Vault', 'INIT');
108
+
109
+ const getToken = token
110
+ ? Promise.resolve(token)
111
+ : authenticateAppRole(vaultUrl, authMethod.roleId, authMethod.secretId);
112
+
113
+ return getToken.then((vaultToken) => {
114
+ const request = vaultRequest(vaultUrl, vaultToken);
115
+ log.info(`Vault key store connected to ${vaultUrl}`);
116
+
117
+ const dekStore = dekBackend
118
+ ? initDekMongo(dekBackend)
119
+ : initDekInMemory();
120
+
121
+ return dekStore.then((deks) => ({
122
+ wrapDEK: (contextName, plainDEK) =>
123
+ request('POST', `transit/encrypt/${contextName}`, {
124
+ plaintext: plainDEK.toString('base64'),
125
+ }).then((res) => res.data.ciphertext),
126
+
127
+ unwrapDEK: (contextName, wrappedDEK) =>
128
+ request('POST', `transit/decrypt/${contextName}`, {
129
+ ciphertext: wrappedDEK,
130
+ }).then((res) => Buffer.from(res.data.plaintext, 'base64')),
131
+
132
+ getDEK: deks.getDEK,
133
+ storeDEK: deks.storeDEK,
134
+ getAllDEKsForContext: deks.getAllDEKsForContext,
135
+ deleteKeysForSubject: deks.deleteKeysForSubject,
136
+
137
+ rotateKEK: (contextName) =>
138
+ request('POST', `transit/keys/${contextName}/rotate`),
139
+
140
+ close: deks.close || (() => Promise.resolve()),
141
+ }));
142
+ });
143
+ },
144
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@lazyapps/encryption",
3
+ "version": "0.0.0-init.0",
4
+ "description": "Field-level PII encryption with crypto-shredding for LazyApps event-sourcing",
5
+ "main": "index.js",
6
+ "files": [
7
+ "*.js",
8
+ "keystores/"
9
+ ],
10
+ "scripts": {
11
+ "test": "vitest"
12
+ },
13
+ "keywords": [
14
+ "event-sourcing",
15
+ "cqrs",
16
+ "lazyapps",
17
+ "encryption",
18
+ "pii",
19
+ "crypto-shredding",
20
+ "gdpr"
21
+ ],
22
+ "author": "Oliver Sturm",
23
+ "license": "ISC",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/oliversturm/lazyapps-libs.git",
27
+ "directory": "packages/encryption"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/oliversturm/lazyapps-libs/issues"
31
+ },
32
+ "homepage": "https://github.com/oliversturm/lazyapps-libs/tree/main/packages/encryption#readme",
33
+ "engines": {
34
+ "node": ">=18.20.3 || >=20.18.0"
35
+ },
36
+ "dependencies": {
37
+ "@lazyapps/logger": "workspace:^"
38
+ },
39
+ "peerDependencies": {
40
+ "mongodb": "^6.0.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "mongodb": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "eslint": "^8.46.0",
49
+ "vitest": "^4.0.18"
50
+ },
51
+ "type": "module"
52
+ }
package/pathUtils.js ADDED
@@ -0,0 +1,13 @@
1
+ export const getNestedValue = (obj, path) =>
2
+ path.split('.').reduce((current, key) => current && current[key], obj);
3
+
4
+ export const setNestedValue = (obj, path, value) => {
5
+ const keys = path.split('.');
6
+ const last = keys.pop();
7
+ const target = keys.reduce((current, key) => {
8
+ if (!current[key]) current[key] = {};
9
+ return current[key];
10
+ }, obj);
11
+ target[last] = value;
12
+ return obj;
13
+ };
@@ -0,0 +1,55 @@
1
+ import { decryptValue } from './fieldEncryption.js';
2
+ import { getLogger } from '@lazyapps/logger';
3
+
4
+ export const createQueryDecryptor = (
5
+ readModelEncryption,
6
+ envelope,
7
+ fallbackValue,
8
+ contexts,
9
+ ) => {
10
+ const log = getLogger('Encryption/Query', 'INIT');
11
+
12
+ log.info('Query decryptor initialized');
13
+
14
+ return {
15
+ decrypt: (doc, { roles, identity, subjectField }) => {
16
+ if (!doc) return Promise.resolve(doc);
17
+
18
+ const isSelf =
19
+ identity && doc[subjectField] && identity === doc[subjectField];
20
+ const effectiveRoles = isSelf ? [...roles, 'self'] : [...roles];
21
+
22
+ const encryptedFields = Object.entries(doc).filter(
23
+ ([, value]) => value && value.__encrypted,
24
+ );
25
+
26
+ if (encryptedFields.length === 0) return Promise.resolve(doc);
27
+
28
+ return encryptedFields.reduce(
29
+ (promise, [fieldName, fieldValue]) =>
30
+ promise.then((d) => {
31
+ const contextConfig = contexts[fieldValue.ctx];
32
+ const isAuthorized =
33
+ contextConfig &&
34
+ contextConfig.roles.some((r) => effectiveRoles.includes(r));
35
+
36
+ if (!isAuthorized) {
37
+ return { ...d, [fieldName]: '[restricted]' };
38
+ }
39
+
40
+ return envelope
41
+ .getDEK(fieldValue.kid, fieldValue.ctx, fieldValue.kv)
42
+ .then((dek) => ({
43
+ ...d,
44
+ [fieldName]: decryptValue(dek.key, fieldValue),
45
+ }))
46
+ .catch(() => ({
47
+ ...d,
48
+ [fieldName]: fallbackValue,
49
+ }));
50
+ }),
51
+ Promise.resolve({ ...doc }),
52
+ );
53
+ },
54
+ };
55
+ };
package/schema.js ADDED
@@ -0,0 +1,26 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+
3
+ export const defineEncryptionSchema = (schemaDef) => {
4
+ const log = getLogger('Encryption/Schema', 'INIT');
5
+
6
+ for (const [eventType, fields] of Object.entries(schemaDef)) {
7
+ for (const [fieldPath, config] of Object.entries(fields)) {
8
+ if (!config.context) {
9
+ throw new Error(
10
+ `Encryption schema: field ${fieldPath} in ${eventType} missing 'context'`,
11
+ );
12
+ }
13
+ if (!config.subjectField) {
14
+ throw new Error(
15
+ `Encryption schema: field ${fieldPath} in ${eventType} missing 'subjectField'`,
16
+ );
17
+ }
18
+ }
19
+ }
20
+
21
+ log.debug(
22
+ `Encryption schema defined for ${Object.keys(schemaDef).length} event types`,
23
+ );
24
+
25
+ return schemaDef;
26
+ };