@friggframework/core 2.0.0-next.41 → 2.0.0-next.42
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/CLAUDE.md +693 -0
- package/README.md +931 -50
- package/application/commands/README.md +421 -0
- package/application/commands/credential-commands.js +224 -0
- package/application/commands/entity-commands.js +315 -0
- package/application/commands/integration-commands.js +160 -0
- package/application/commands/integration-commands.test.js +123 -0
- package/application/commands/user-commands.js +213 -0
- package/application/index.js +69 -0
- package/core/CLAUDE.md +690 -0
- package/core/create-handler.js +0 -6
- package/credential/repositories/credential-repository-factory.js +47 -0
- package/credential/repositories/credential-repository-interface.js +98 -0
- package/credential/repositories/credential-repository-mongo.js +301 -0
- package/credential/repositories/credential-repository-postgres.js +307 -0
- package/credential/repositories/credential-repository.js +307 -0
- package/credential/use-cases/get-credential-for-user.js +21 -0
- package/credential/use-cases/update-authentication-status.js +15 -0
- package/database/config.js +117 -0
- package/database/encryption/README.md +683 -0
- package/database/encryption/encryption-integration.test.js +553 -0
- package/database/encryption/encryption-schema-registry.js +141 -0
- package/database/encryption/encryption-schema-registry.test.js +392 -0
- package/database/encryption/field-encryption-service.js +226 -0
- package/database/encryption/field-encryption-service.test.js +525 -0
- package/database/encryption/logger.js +79 -0
- package/database/encryption/mongo-decryption-fix-verification.test.js +348 -0
- package/database/encryption/postgres-decryption-fix-verification.test.js +371 -0
- package/database/encryption/postgres-relation-decryption.test.js +245 -0
- package/database/encryption/prisma-encryption-extension.js +222 -0
- package/database/encryption/prisma-encryption-extension.test.js +439 -0
- package/database/index.js +25 -12
- package/database/models/readme.md +1 -0
- package/database/prisma.js +162 -0
- package/database/repositories/health-check-repository-factory.js +38 -0
- package/database/repositories/health-check-repository-interface.js +86 -0
- package/database/repositories/health-check-repository-mongodb.js +72 -0
- package/database/repositories/health-check-repository-postgres.js +75 -0
- package/database/repositories/health-check-repository.js +108 -0
- package/database/use-cases/check-database-health-use-case.js +34 -0
- package/database/use-cases/check-encryption-health-use-case.js +82 -0
- package/database/use-cases/test-encryption-use-case.js +252 -0
- package/encrypt/Cryptor.js +20 -152
- package/encrypt/index.js +1 -2
- package/encrypt/test-encrypt.js +0 -2
- package/handlers/app-definition-loader.js +38 -0
- package/handlers/app-handler-helpers.js +0 -3
- package/handlers/auth-flow.integration.test.js +147 -0
- package/handlers/backend-utils.js +25 -45
- package/handlers/integration-event-dispatcher.js +54 -0
- package/handlers/integration-event-dispatcher.test.js +141 -0
- package/handlers/routers/HEALTHCHECK.md +103 -1
- package/handlers/routers/auth.js +3 -14
- package/handlers/routers/health.js +63 -424
- package/handlers/routers/health.test.js +7 -0
- package/handlers/routers/integration-defined-routers.js +8 -5
- package/handlers/routers/user.js +25 -5
- package/handlers/routers/websocket.js +5 -3
- package/handlers/use-cases/check-external-apis-health-use-case.js +81 -0
- package/handlers/use-cases/check-integrations-health-use-case.js +32 -0
- package/handlers/workers/integration-defined-workers.js +6 -3
- package/index.js +45 -22
- package/integrations/index.js +12 -10
- package/integrations/integration-base.js +224 -53
- package/integrations/integration-router.js +386 -178
- package/integrations/options.js +1 -1
- package/integrations/repositories/integration-mapping-repository-factory.js +50 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +106 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +161 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +227 -0
- package/integrations/repositories/integration-mapping-repository.js +156 -0
- package/integrations/repositories/integration-repository-factory.js +44 -0
- package/integrations/repositories/integration-repository-interface.js +115 -0
- package/integrations/repositories/integration-repository-mongo.js +271 -0
- package/integrations/repositories/integration-repository-postgres.js +319 -0
- package/integrations/tests/doubles/dummy-integration-class.js +90 -0
- package/integrations/tests/doubles/test-integration-repository.js +99 -0
- package/integrations/tests/use-cases/create-integration.test.js +131 -0
- package/integrations/tests/use-cases/delete-integration-for-user.test.js +150 -0
- package/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js +92 -0
- package/integrations/tests/use-cases/get-integration-for-user.test.js +150 -0
- package/integrations/tests/use-cases/get-integration-instance.test.js +176 -0
- package/integrations/tests/use-cases/get-integrations-for-user.test.js +176 -0
- package/integrations/tests/use-cases/get-possible-integrations.test.js +188 -0
- package/integrations/tests/use-cases/update-integration-messages.test.js +142 -0
- package/integrations/tests/use-cases/update-integration-status.test.js +103 -0
- package/integrations/tests/use-cases/update-integration.test.js +141 -0
- package/integrations/use-cases/create-integration.js +83 -0
- package/integrations/use-cases/delete-integration-for-user.js +73 -0
- package/integrations/use-cases/find-integration-context-by-external-entity-id.js +72 -0
- package/integrations/use-cases/get-integration-for-user.js +78 -0
- package/integrations/use-cases/get-integration-instance-by-definition.js +67 -0
- package/integrations/use-cases/get-integration-instance.js +83 -0
- package/integrations/use-cases/get-integrations-for-user.js +87 -0
- package/integrations/use-cases/get-possible-integrations.js +27 -0
- package/integrations/use-cases/index.js +11 -0
- package/integrations/use-cases/load-integration-context-full.test.js +329 -0
- package/integrations/use-cases/load-integration-context.js +71 -0
- package/integrations/use-cases/load-integration-context.test.js +114 -0
- package/integrations/use-cases/update-integration-messages.js +44 -0
- package/integrations/use-cases/update-integration-status.js +32 -0
- package/integrations/use-cases/update-integration.js +93 -0
- package/integrations/utils/map-integration-dto.js +36 -0
- package/jest-global-setup-noop.js +3 -0
- package/jest-global-teardown-noop.js +3 -0
- package/{module-plugin → modules}/entity.js +1 -0
- package/{module-plugin → modules}/index.js +0 -8
- package/modules/module-factory.js +56 -0
- package/modules/module-hydration.test.js +205 -0
- package/modules/module.js +221 -0
- package/modules/repositories/module-repository-factory.js +33 -0
- package/modules/repositories/module-repository-interface.js +129 -0
- package/modules/repositories/module-repository-mongo.js +386 -0
- package/modules/repositories/module-repository-postgres.js +437 -0
- package/modules/repositories/module-repository.js +327 -0
- package/{module-plugin → modules}/test/mock-api/api.js +8 -3
- package/{module-plugin → modules}/test/mock-api/definition.js +12 -8
- package/modules/tests/doubles/test-module-factory.js +16 -0
- package/modules/tests/doubles/test-module-repository.js +39 -0
- package/modules/use-cases/get-entities-for-user.js +32 -0
- package/modules/use-cases/get-entity-options-by-id.js +59 -0
- package/modules/use-cases/get-entity-options-by-type.js +34 -0
- package/modules/use-cases/get-module-instance-from-type.js +31 -0
- package/modules/use-cases/get-module.js +56 -0
- package/modules/use-cases/process-authorization-callback.js +121 -0
- package/modules/use-cases/refresh-entity-options.js +59 -0
- package/modules/use-cases/test-module-auth.js +55 -0
- package/modules/utils/map-module-dto.js +18 -0
- package/package.json +14 -6
- package/prisma-mongodb/schema.prisma +321 -0
- package/prisma-postgresql/migrations/20250930193005_init/migration.sql +315 -0
- package/prisma-postgresql/migrations/20251006135218_init/migration.sql +9 -0
- package/prisma-postgresql/migrations/migration_lock.toml +3 -0
- package/prisma-postgresql/schema.prisma +303 -0
- package/syncs/manager.js +468 -443
- package/syncs/repositories/sync-repository-factory.js +38 -0
- package/syncs/repositories/sync-repository-interface.js +109 -0
- package/syncs/repositories/sync-repository-mongo.js +239 -0
- package/syncs/repositories/sync-repository-postgres.js +319 -0
- package/syncs/sync.js +0 -1
- package/token/repositories/token-repository-factory.js +33 -0
- package/token/repositories/token-repository-interface.js +131 -0
- package/token/repositories/token-repository-mongo.js +212 -0
- package/token/repositories/token-repository-postgres.js +257 -0
- package/token/repositories/token-repository.js +219 -0
- package/types/integrations/index.d.ts +2 -6
- package/types/module-plugin/index.d.ts +5 -57
- package/types/syncs/index.d.ts +0 -2
- package/user/repositories/user-repository-factory.js +46 -0
- package/user/repositories/user-repository-interface.js +198 -0
- package/user/repositories/user-repository-mongo.js +250 -0
- package/user/repositories/user-repository-postgres.js +311 -0
- package/user/tests/doubles/test-user-repository.js +72 -0
- package/user/tests/use-cases/create-individual-user.test.js +24 -0
- package/user/tests/use-cases/create-organization-user.test.js +28 -0
- package/user/tests/use-cases/create-token-for-user-id.test.js +19 -0
- package/user/tests/use-cases/get-user-from-bearer-token.test.js +64 -0
- package/user/tests/use-cases/login-user.test.js +140 -0
- package/user/use-cases/create-individual-user.js +61 -0
- package/user/use-cases/create-organization-user.js +47 -0
- package/user/use-cases/create-token-for-user-id.js +30 -0
- package/user/use-cases/get-user-from-bearer-token.js +77 -0
- package/user/use-cases/login-user.js +122 -0
- package/user/user.js +77 -0
- package/websocket/repositories/websocket-connection-repository-factory.js +37 -0
- package/websocket/repositories/websocket-connection-repository-interface.js +106 -0
- package/websocket/repositories/websocket-connection-repository-mongo.js +155 -0
- package/websocket/repositories/websocket-connection-repository-postgres.js +195 -0
- package/websocket/repositories/websocket-connection-repository.js +160 -0
- package/database/models/State.js +0 -9
- package/database/models/Token.js +0 -70
- package/database/mongo.js +0 -171
- package/encrypt/Cryptor.test.js +0 -32
- package/encrypt/encrypt.js +0 -104
- package/encrypt/encrypt.test.js +0 -1069
- package/handlers/routers/middleware/loadUser.js +0 -15
- package/handlers/routers/middleware/requireLoggedInUser.js +0 -12
- package/integrations/create-frigg-backend.js +0 -31
- package/integrations/integration-factory.js +0 -251
- package/integrations/integration-mapping.js +0 -43
- package/integrations/integration-model.js +0 -46
- package/integrations/integration-user.js +0 -144
- package/integrations/test/integration-base.test.js +0 -144
- package/module-plugin/auther.js +0 -393
- package/module-plugin/credential.js +0 -22
- package/module-plugin/entity-manager.js +0 -70
- package/module-plugin/manager.js +0 -169
- package/module-plugin/module-factory.js +0 -61
- package/module-plugin/test/auther.test.js +0 -97
- /package/{module-plugin → modules}/ModuleConstants.js +0 -0
- /package/{module-plugin → modules}/requester/api-key.js +0 -0
- /package/{module-plugin → modules}/requester/basic.js +0 -0
- /package/{module-plugin → modules}/requester/oauth-2.js +0 -0
- /package/{module-plugin → modules}/requester/requester.js +0 -0
- /package/{module-plugin → modules}/requester/requester.test.js +0 -0
- /package/{module-plugin → modules}/test/mock-api/mocks/hubspot.js +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
const {
|
|
2
|
+
CORE_ENCRYPTION_SCHEMA,
|
|
3
|
+
getEncryptedFields,
|
|
4
|
+
hasEncryptedFields,
|
|
5
|
+
getEncryptedModels,
|
|
6
|
+
registerCustomSchema,
|
|
7
|
+
validateCustomSchema,
|
|
8
|
+
resetCustomSchema,
|
|
9
|
+
} = require('./encryption-schema-registry');
|
|
10
|
+
|
|
11
|
+
describe('Encryption Schema Registry', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
// Reset custom schema after each test
|
|
14
|
+
resetCustomSchema();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('CORE_ENCRYPTION_SCHEMA', () => {
|
|
18
|
+
it('should define encrypted fields for Credential model', () => {
|
|
19
|
+
expect(CORE_ENCRYPTION_SCHEMA.Credential).toBeDefined();
|
|
20
|
+
expect(CORE_ENCRYPTION_SCHEMA.Credential.fields).toContain(
|
|
21
|
+
'data.access_token'
|
|
22
|
+
);
|
|
23
|
+
expect(CORE_ENCRYPTION_SCHEMA.Credential.fields).toContain(
|
|
24
|
+
'data.refresh_token'
|
|
25
|
+
);
|
|
26
|
+
expect(CORE_ENCRYPTION_SCHEMA.Credential.fields).toContain(
|
|
27
|
+
'data.id_token'
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should define encrypted fields for IntegrationMapping model', () => {
|
|
32
|
+
expect(CORE_ENCRYPTION_SCHEMA.IntegrationMapping).toBeDefined();
|
|
33
|
+
expect(CORE_ENCRYPTION_SCHEMA.IntegrationMapping.fields).toContain(
|
|
34
|
+
'mapping'
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should define encrypted fields for User model', () => {
|
|
39
|
+
expect(CORE_ENCRYPTION_SCHEMA.User).toBeDefined();
|
|
40
|
+
expect(CORE_ENCRYPTION_SCHEMA.User.fields).toContain('hashword');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should define encrypted fields for Token model', () => {
|
|
44
|
+
expect(CORE_ENCRYPTION_SCHEMA.Token).toBeDefined();
|
|
45
|
+
expect(CORE_ENCRYPTION_SCHEMA.Token.fields).toContain('token');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getEncryptedFields', () => {
|
|
50
|
+
it('should return encrypted fields for Credential model', () => {
|
|
51
|
+
const fields = getEncryptedFields('Credential');
|
|
52
|
+
|
|
53
|
+
expect(fields).toEqual([
|
|
54
|
+
'data.access_token',
|
|
55
|
+
'data.refresh_token',
|
|
56
|
+
'data.id_token',
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return encrypted fields for User model', () => {
|
|
61
|
+
const fields = getEncryptedFields('User');
|
|
62
|
+
|
|
63
|
+
expect(fields).toEqual(['hashword']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return empty array for model without encrypted fields', () => {
|
|
67
|
+
const fields = getEncryptedFields('NonExistentModel');
|
|
68
|
+
|
|
69
|
+
expect(fields).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return empty array for undefined model', () => {
|
|
73
|
+
const fields = getEncryptedFields(undefined);
|
|
74
|
+
|
|
75
|
+
expect(fields).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return empty array for null model', () => {
|
|
79
|
+
const fields = getEncryptedFields(null);
|
|
80
|
+
|
|
81
|
+
expect(fields).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should support nested JSON paths', () => {
|
|
85
|
+
const fields = getEncryptedFields('Credential');
|
|
86
|
+
const nestedFields = fields.filter((f) => f.includes('.'));
|
|
87
|
+
|
|
88
|
+
expect(nestedFields.length).toBeGreaterThan(0);
|
|
89
|
+
expect(nestedFields).toContain('data.access_token');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('hasEncryptedFields', () => {
|
|
94
|
+
it('should return true for models with encrypted fields', () => {
|
|
95
|
+
expect(hasEncryptedFields('Credential')).toBe(true);
|
|
96
|
+
expect(hasEncryptedFields('User')).toBe(true);
|
|
97
|
+
expect(hasEncryptedFields('Token')).toBe(true);
|
|
98
|
+
expect(hasEncryptedFields('IntegrationMapping')).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return false for models without encrypted fields', () => {
|
|
102
|
+
expect(hasEncryptedFields('State')).toBe(false);
|
|
103
|
+
expect(hasEncryptedFields('NonExistentModel')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should return false for undefined model', () => {
|
|
107
|
+
expect(hasEncryptedFields(undefined)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should return false for null model', () => {
|
|
111
|
+
expect(hasEncryptedFields(null)).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('getEncryptedModels', () => {
|
|
116
|
+
it('should return list of all models with encryption', () => {
|
|
117
|
+
const models = getEncryptedModels();
|
|
118
|
+
|
|
119
|
+
expect(models).toContain('Credential');
|
|
120
|
+
expect(models).toContain('IntegrationMapping');
|
|
121
|
+
expect(models).toContain('User');
|
|
122
|
+
expect(models).toContain('Token');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return array with length equal to encrypted models', () => {
|
|
126
|
+
const models = getEncryptedModels();
|
|
127
|
+
|
|
128
|
+
expect(models.length).toBe(
|
|
129
|
+
Object.keys(CORE_ENCRYPTION_SCHEMA).length
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return unique model names', () => {
|
|
134
|
+
const models = getEncryptedModels();
|
|
135
|
+
const uniqueModels = [...new Set(models)];
|
|
136
|
+
|
|
137
|
+
expect(models.length).toBe(uniqueModels.length);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Schema Validation', () => {
|
|
142
|
+
it('should have valid field paths (no leading/trailing dots)', () => {
|
|
143
|
+
const models = getEncryptedModels();
|
|
144
|
+
|
|
145
|
+
models.forEach((modelName) => {
|
|
146
|
+
const fields = getEncryptedFields(modelName);
|
|
147
|
+
|
|
148
|
+
fields.forEach((fieldPath) => {
|
|
149
|
+
expect(fieldPath).not.toMatch(/^\./);
|
|
150
|
+
expect(fieldPath).not.toMatch(/\.$/);
|
|
151
|
+
expect(fieldPath.length).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should not have duplicate field paths within a model', () => {
|
|
157
|
+
const models = getEncryptedModels();
|
|
158
|
+
|
|
159
|
+
models.forEach((modelName) => {
|
|
160
|
+
const fields = getEncryptedFields(modelName);
|
|
161
|
+
const uniqueFields = [...new Set(fields)];
|
|
162
|
+
|
|
163
|
+
expect(fields.length).toBe(uniqueFields.length);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should have at least one field per encrypted model', () => {
|
|
168
|
+
const models = getEncryptedModels();
|
|
169
|
+
|
|
170
|
+
models.forEach((modelName) => {
|
|
171
|
+
const fields = getEncryptedFields(modelName);
|
|
172
|
+
|
|
173
|
+
expect(fields.length).toBeGreaterThan(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('Custom Schema Registration', () => {
|
|
179
|
+
describe('validateCustomSchema', () => {
|
|
180
|
+
it('should validate a valid custom schema', () => {
|
|
181
|
+
const customSchema = {
|
|
182
|
+
MyModel: {
|
|
183
|
+
fields: ['secretField', 'data.apiKey'],
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const result = validateCustomSchema(customSchema);
|
|
188
|
+
|
|
189
|
+
expect(result.valid).toBe(true);
|
|
190
|
+
expect(result.errors).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should reject non-object schema', () => {
|
|
194
|
+
const result = validateCustomSchema('invalid');
|
|
195
|
+
|
|
196
|
+
expect(result.valid).toBe(false);
|
|
197
|
+
expect(result.errors).toContain(
|
|
198
|
+
'Custom schema must be an object'
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should reject model without fields array', () => {
|
|
203
|
+
const customSchema = {
|
|
204
|
+
MyModel: {
|
|
205
|
+
notFields: ['test'],
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const result = validateCustomSchema(customSchema);
|
|
210
|
+
|
|
211
|
+
expect(result.valid).toBe(false);
|
|
212
|
+
expect(result.errors[0]).toContain('must have a "fields" array');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should reject invalid field paths', () => {
|
|
216
|
+
const customSchema = {
|
|
217
|
+
MyModel: {
|
|
218
|
+
fields: ['validField', '', null],
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const result = validateCustomSchema(customSchema);
|
|
223
|
+
|
|
224
|
+
expect(result.valid).toBe(false);
|
|
225
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should prevent overriding core encrypted fields', () => {
|
|
229
|
+
const customSchema = {
|
|
230
|
+
Credential: {
|
|
231
|
+
fields: ['data.access_token'], // Core field
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const result = validateCustomSchema(customSchema);
|
|
236
|
+
|
|
237
|
+
expect(result.valid).toBe(false);
|
|
238
|
+
expect(result.errors[0]).toContain('Cannot override core');
|
|
239
|
+
expect(result.errors[0]).toContain('data.access_token');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should allow adding new fields to core models', () => {
|
|
243
|
+
const customSchema = {
|
|
244
|
+
Credential: {
|
|
245
|
+
fields: ['data.customField'], // New field
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const result = validateCustomSchema(customSchema);
|
|
250
|
+
|
|
251
|
+
expect(result.valid).toBe(true);
|
|
252
|
+
expect(result.errors).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('registerCustomSchema', () => {
|
|
257
|
+
it('should register a valid custom schema', () => {
|
|
258
|
+
const customSchema = {
|
|
259
|
+
MyCustomModel: {
|
|
260
|
+
fields: ['secretData', 'apiKey'],
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
expect(() => registerCustomSchema(customSchema)).not.toThrow();
|
|
265
|
+
|
|
266
|
+
const fields = getEncryptedFields('MyCustomModel');
|
|
267
|
+
expect(fields).toEqual(['secretData', 'apiKey']);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should throw error for invalid schema', () => {
|
|
271
|
+
const invalidSchema = {
|
|
272
|
+
MyModel: {
|
|
273
|
+
fields: ['validField', ''], // Empty string invalid
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
expect(() => registerCustomSchema(invalidSchema)).toThrow(
|
|
278
|
+
'Invalid custom encryption schema'
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should handle empty schema gracefully', () => {
|
|
283
|
+
expect(() => registerCustomSchema({})).not.toThrow();
|
|
284
|
+
expect(() => registerCustomSchema(null)).not.toThrow();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should throw when trying to override core fields', () => {
|
|
288
|
+
const invalidSchema = {
|
|
289
|
+
User: {
|
|
290
|
+
fields: ['hashword'], // Core field
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
expect(() => registerCustomSchema(invalidSchema)).toThrow(
|
|
295
|
+
'Cannot override core'
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('Custom schema merging', () => {
|
|
301
|
+
it('should merge custom fields with core fields', () => {
|
|
302
|
+
const customSchema = {
|
|
303
|
+
Credential: {
|
|
304
|
+
fields: ['data.customToken', 'data.customSecret'],
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
registerCustomSchema(customSchema);
|
|
309
|
+
|
|
310
|
+
const fields = getEncryptedFields('Credential');
|
|
311
|
+
|
|
312
|
+
// Should include both core and custom
|
|
313
|
+
expect(fields).toContain('data.access_token'); // Core
|
|
314
|
+
expect(fields).toContain('data.customToken'); // Custom
|
|
315
|
+
expect(fields).toContain('data.customSecret'); // Custom
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should deduplicate merged fields', () => {
|
|
319
|
+
const customSchema = {
|
|
320
|
+
Credential: {
|
|
321
|
+
fields: ['data.newField'],
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
registerCustomSchema(customSchema);
|
|
326
|
+
|
|
327
|
+
const fields = getEncryptedFields('Credential');
|
|
328
|
+
const uniqueFields = [...new Set(fields)];
|
|
329
|
+
|
|
330
|
+
expect(fields.length).toBe(uniqueFields.length);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should include custom models in getEncryptedModels', () => {
|
|
334
|
+
const customSchema = {
|
|
335
|
+
MyCustomModel: {
|
|
336
|
+
fields: ['secret'],
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
registerCustomSchema(customSchema);
|
|
341
|
+
|
|
342
|
+
const models = getEncryptedModels();
|
|
343
|
+
|
|
344
|
+
expect(models).toContain('MyCustomModel');
|
|
345
|
+
expect(models).toContain('Credential'); // Core model still there
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should report custom models have encrypted fields', () => {
|
|
349
|
+
const customSchema = {
|
|
350
|
+
MyCustomModel: {
|
|
351
|
+
fields: ['secret'],
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
registerCustomSchema(customSchema);
|
|
356
|
+
|
|
357
|
+
expect(hasEncryptedFields('MyCustomModel')).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('resetCustomSchema', () => {
|
|
362
|
+
it('should clear custom schema', () => {
|
|
363
|
+
const customSchema = {
|
|
364
|
+
MyModel: {
|
|
365
|
+
fields: ['secret'],
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
registerCustomSchema(customSchema);
|
|
370
|
+
expect(hasEncryptedFields('MyModel')).toBe(true);
|
|
371
|
+
|
|
372
|
+
resetCustomSchema();
|
|
373
|
+
expect(hasEncryptedFields('MyModel')).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should not affect core schema', () => {
|
|
377
|
+
const customSchema = {
|
|
378
|
+
MyModel: {
|
|
379
|
+
fields: ['secret'],
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
registerCustomSchema(customSchema);
|
|
384
|
+
resetCustomSchema();
|
|
385
|
+
|
|
386
|
+
// Core models still encrypted
|
|
387
|
+
expect(hasEncryptedFields('Credential')).toBe(true);
|
|
388
|
+
expect(hasEncryptedFields('User')).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field Encryption Service
|
|
3
|
+
*
|
|
4
|
+
* Infrastructure layer service that orchestrates field-level encryption/decryption.
|
|
5
|
+
* Handles nested JSON paths (e.g., 'data.access_token') and bulk operations.
|
|
6
|
+
*/
|
|
7
|
+
class FieldEncryptionService {
|
|
8
|
+
constructor({ cryptor, schema }) {
|
|
9
|
+
if (!cryptor) {
|
|
10
|
+
throw new Error('Cryptor instance required');
|
|
11
|
+
}
|
|
12
|
+
if (!schema || typeof schema.getEncryptedFields !== 'function') {
|
|
13
|
+
throw new Error('Schema with getEncryptedFields method required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
this.cryptor = cryptor;
|
|
17
|
+
this.schema = schema;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async encryptFields(modelName, document) {
|
|
21
|
+
if (!document || typeof document !== 'object') {
|
|
22
|
+
return document;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fields = this.schema.getEncryptedFields(modelName);
|
|
26
|
+
if (fields.length === 0) {
|
|
27
|
+
return document;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const encrypted = this._deepClone(document);
|
|
31
|
+
|
|
32
|
+
// Parallelize encryption of multiple fields
|
|
33
|
+
const encryptionPromises = fields.map(async (fieldPath) => {
|
|
34
|
+
const value = this._getNestedValue(encrypted, fieldPath);
|
|
35
|
+
|
|
36
|
+
if (this._shouldEncrypt(value)) {
|
|
37
|
+
const serializedValue = this._serializeForEncryption(value);
|
|
38
|
+
const encryptedValue = await this.cryptor.encrypt(serializedValue);
|
|
39
|
+
return { fieldPath, encryptedValue };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const results = await Promise.all(encryptionPromises);
|
|
45
|
+
|
|
46
|
+
// Apply encrypted values
|
|
47
|
+
for (const result of results) {
|
|
48
|
+
if (result) {
|
|
49
|
+
this._setNestedValue(encrypted, result.fieldPath, result.encryptedValue);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return encrypted;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async decryptFields(modelName, document) {
|
|
57
|
+
if (!document || typeof document !== 'object') {
|
|
58
|
+
return document;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fields = this.schema.getEncryptedFields(modelName);
|
|
62
|
+
if (fields.length === 0) {
|
|
63
|
+
return document;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const decrypted = this._deepClone(document);
|
|
67
|
+
|
|
68
|
+
// Parallelize decryption of multiple fields
|
|
69
|
+
const decryptionPromises = fields.map(async (fieldPath) => {
|
|
70
|
+
const value = this._getNestedValue(decrypted, fieldPath);
|
|
71
|
+
|
|
72
|
+
if (this._isEncrypted(value)) {
|
|
73
|
+
const decryptedValue = await this.cryptor.decrypt(value);
|
|
74
|
+
const deserializedValue = this._deserializeAfterDecryption(decryptedValue);
|
|
75
|
+
return { fieldPath, decryptedValue: deserializedValue };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const results = await Promise.all(decryptionPromises);
|
|
81
|
+
|
|
82
|
+
// Apply decrypted values
|
|
83
|
+
for (const result of results) {
|
|
84
|
+
if (result) {
|
|
85
|
+
this._setNestedValue(decrypted, result.fieldPath, result.decryptedValue);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return decrypted;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async encryptFieldsInBulk(modelName, documents) {
|
|
93
|
+
if (!Array.isArray(documents)) {
|
|
94
|
+
return documents;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return Promise.all(
|
|
98
|
+
documents.map((doc) => this.encryptFields(modelName, doc))
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async decryptFieldsInBulk(modelName, documents) {
|
|
103
|
+
if (!Array.isArray(documents)) {
|
|
104
|
+
return documents;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Promise.all(
|
|
108
|
+
documents.map((doc) => this.decryptFields(modelName, doc))
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_shouldEncrypt(value) {
|
|
113
|
+
return (
|
|
114
|
+
value !== null &&
|
|
115
|
+
value !== undefined &&
|
|
116
|
+
value !== '' &&
|
|
117
|
+
!this._isEncrypted(value)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_isEncrypted(value) {
|
|
122
|
+
if (typeof value !== 'string') {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const parts = value.split(':');
|
|
127
|
+
return parts.length >= 4;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_getNestedValue(obj, path) {
|
|
131
|
+
if (!obj || !path) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return path.split('.').reduce((current, key) => {
|
|
136
|
+
return current?.[key];
|
|
137
|
+
}, obj);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_setNestedValue(obj, path, value) {
|
|
141
|
+
if (!obj || !path) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const keys = path.split('.');
|
|
146
|
+
const lastKey = keys.pop();
|
|
147
|
+
|
|
148
|
+
const target = keys.reduce((current, key) => {
|
|
149
|
+
if (!current[key] || typeof current[key] !== 'object') {
|
|
150
|
+
current[key] = {};
|
|
151
|
+
}
|
|
152
|
+
return current[key];
|
|
153
|
+
}, obj);
|
|
154
|
+
|
|
155
|
+
target[lastKey] = value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_deepClone(obj) {
|
|
159
|
+
// Use structuredClone (Node.js 17+) for better performance
|
|
160
|
+
// Falls back to custom implementation for older Node versions
|
|
161
|
+
if (typeof structuredClone !== 'undefined') {
|
|
162
|
+
try {
|
|
163
|
+
return structuredClone(obj);
|
|
164
|
+
} catch {
|
|
165
|
+
// Fall through to custom implementation
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Custom fallback for older environments
|
|
170
|
+
if (obj === null || typeof obj !== 'object') {
|
|
171
|
+
return obj;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (obj instanceof Date) {
|
|
175
|
+
return new Date(obj.getTime());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (Array.isArray(obj)) {
|
|
179
|
+
return obj.map((item) => this._deepClone(item));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const cloned = {};
|
|
183
|
+
for (const key in obj) {
|
|
184
|
+
if (obj.hasOwnProperty(key)) {
|
|
185
|
+
cloned[key] = this._deepClone(obj[key]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return cloned;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Serialize a value for encryption
|
|
194
|
+
* Objects/arrays are JSON stringified, primitives are converted to strings
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
_serializeForEncryption(value) {
|
|
198
|
+
if (typeof value === 'object' && value !== null) {
|
|
199
|
+
// JSON.stringify for objects and arrays
|
|
200
|
+
return JSON.stringify(value);
|
|
201
|
+
}
|
|
202
|
+
// For primitives (string, number, boolean), convert to string
|
|
203
|
+
return String(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Deserialize a value after decryption
|
|
208
|
+
* Attempts to parse as JSON, returns string if parsing fails
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
_deserializeAfterDecryption(value) {
|
|
212
|
+
if (typeof value !== 'string') {
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Try to parse as JSON
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(value);
|
|
219
|
+
} catch {
|
|
220
|
+
// Not valid JSON, return as-is (likely was a plain string field)
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { FieldEncryptionService };
|