@friggframework/core 2.0.0--canary.654.ebad223.0 → 2.0.0--canary.651.dadf4d6.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 (68) hide show
  1. package/.eslintrc.json +1 -18
  2. package/CLAUDE.md +62 -14
  3. package/README.md +22 -19
  4. package/core/CLAUDE.md +12 -25
  5. package/core/Worker.js +37 -31
  6. package/core/create-handler.js +197 -96
  7. package/core/index.js +1 -8
  8. package/core/secrets-to-env.js +6 -7
  9. package/database/config.js +7 -0
  10. package/database/documentdb-encryption-service.js +6 -2
  11. package/database/documentdb-utils.js +2 -1
  12. package/database/encryption/README.md +21 -5
  13. package/database/encryption/encryption-schema-registry.js +0 -3
  14. package/database/encryption/integration-mapping-encryption.js +97 -0
  15. package/database/encryption/prisma-encryption-extension.js +20 -9
  16. package/errors/fetch-error.js +65 -48
  17. package/handlers/app-definition-loader.js +2 -53
  18. package/handlers/app-handler-helpers.js +4 -18
  19. package/handlers/backend-utils.js +5 -0
  20. package/handlers/database-migration-handler.js +1 -10
  21. package/handlers/routers/websocket.js +6 -26
  22. package/handlers/workers/db-migration.js +1 -12
  23. package/handlers/workers/dlq-processor.js +38 -64
  24. package/index.js +1 -19
  25. package/integrations/integration-base.js +38 -56
  26. package/integrations/integration-router.js +20 -21
  27. package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
  28. package/integrations/repositories/integration-mapping-query.js +193 -0
  29. package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
  30. package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
  31. package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
  32. package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
  33. package/integrations/use-cases/delete-integration-for-user.js +13 -24
  34. package/jest.config.js +0 -2
  35. package/logs/.eslintrc.json +1 -26
  36. package/logs/index.js +3 -14
  37. package/logs/logger.js +42 -132
  38. package/modules/module.js +19 -32
  39. package/modules/requester/oauth-2.js +22 -28
  40. package/modules/requester/requester.js +6 -69
  41. package/package.json +6 -5
  42. package/queues/queuer-util.js +15 -1
  43. package/telemetry/bind-telemetry-context.js +0 -4
  44. package/telemetry/instrument-handler.js +2 -19
  45. package/telemetry/no-op-telemetry.js +0 -4
  46. package/telemetry/otel-telemetry.js +6 -25
  47. package/telemetry/telemetry-context.js +8 -19
  48. package/telemetry/telemetry-runtime.js +0 -17
  49. package/telemetry/telemetry-service.js +0 -1
  50. package/types/errors/index.d.ts +10 -24
  51. package/types/logs/index.d.ts +2 -61
  52. package/user/repositories/user-repository-documentdb.js +40 -8
  53. package/core/invocation-scope.js +0 -224
  54. package/logs/__fixtures__/events.js +0 -82
  55. package/logs/__fixtures__/matchers.js +0 -45
  56. package/logs/__fixtures__/secrets.js +0 -22
  57. package/logs/__fixtures__/vectors.js +0 -329
  58. package/logs/__fixtures__/with-env.js +0 -45
  59. package/logs/context.js +0 -92
  60. package/logs/debug-shims.js +0 -46
  61. package/logs/jest-logger-setup.js +0 -25
  62. package/logs/levels.js +0 -71
  63. package/logs/logger-runtime.js +0 -142
  64. package/logs/record.js +0 -231
  65. package/logs/redact.js +0 -333
  66. package/logs/serialize.js +0 -322
  67. package/logs/sinks.js +0 -99
  68. package/logs/summarize-event.js +0 -162
@@ -2,16 +2,7 @@
2
2
  // REMOVING FOR NOW UNTIL WE ADD WEBPACK BACK IN
3
3
  // require('source-map-support').install();
4
4
 
5
- const { getLogger, toSanitizedSurrogate } = require('../logs');
6
- const {
7
- summarizeLambdaEvent,
8
- toScopeInvocation,
9
- toRequestInvocation,
10
- } = require('../logs/summarize-event');
11
- const {
12
- runInvocationScope,
13
- DEFAULT_FLUSH_TIMEOUT_MS,
14
- } = require('./invocation-scope');
5
+ const { initDebugLog, flushDebugLog } = require('../logs');
15
6
  const { secretsToEnv } = require('./secrets-to-env');
16
7
  const { parametersToEnv } = require('./parameters-to-env');
17
8
  const {
@@ -20,7 +11,123 @@ const {
20
11
  getPluginTelemetrySubscribers,
21
12
  } = require('../telemetry/telemetry-runtime');
22
13
 
23
- const log = getLogger('frigg.handler');
14
+ // Bounds the tail latency telemetry adds to every warm invocation. Kept low so
15
+ // an unreachable OTLP endpoint (e.g. a VPC Lambda with no NAT/egress) costs at
16
+ // most this, not multiple seconds. Override with OTEL_FLUSH_TIMEOUT_MS.
17
+ const DEFAULT_FLUSH_TIMEOUT_MS =
18
+ Number(process.env.OTEL_FLUSH_TIMEOUT_MS) || 500;
19
+
20
+ /**
21
+ * Fold the invocation's buffered usage counters into the durable store, then
22
+ * clear the buffer. On an SQS redelivery (ApproximateReceiveCount > 1) we
23
+ * DISCARD rather than flush — the prior delivery already counted, and the usage
24
+ * accuracy contract is "approximate, skip obvious redeliveries". Fully guarded.
25
+ */
26
+ async function flushUsageRollup(subscriber, eventSummary, shouldUseDatabase) {
27
+ if (!subscriber) return;
28
+ try {
29
+ // Persisting usage requires a DB connection. DB-free handlers (e.g. the
30
+ // webhook-receipt route) never called connectPrisma, so drop the buffer
31
+ // instead of issuing a connectionless Prisma write.
32
+ if (!shouldUseDatabase) {
33
+ subscriber.discard();
34
+ return;
35
+ }
36
+ // Discard only when EVERY record in the batch is a redelivery. The buffer
37
+ // is invocation-scoped (not per-message), so discarding on *any*
38
+ // redelivery would drop the fresh records' counts too (silent
39
+ // under-count). For a mixed batch we flush: preserving fresh counts and
40
+ // at worst re-counting the one redelivered record is strictly better than
41
+ // losing fresh data for an approximate store. (Integration queue workers
42
+ // are batchSize:1 today, so a batch is all-or-nothing; this keeps it
43
+ // correct if batchSize is ever raised.)
44
+ const records = Array.isArray(eventSummary?.records)
45
+ ? eventSummary.records
46
+ : [];
47
+ const allRedelivered =
48
+ records.length > 0 &&
49
+ records.every((r) => Number(r.receiveCount) > 1);
50
+ if (allRedelivered) {
51
+ subscriber.discard();
52
+ } else {
53
+ await subscriber.flush();
54
+ }
55
+ } catch (_) {
56
+ // Usage rollup must never break the handler.
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Flush telemetry before the Lambda container freezes. Because
62
+ * `callbackWaitsForEmptyEventLoop=false` (below) stops the event loop the moment
63
+ * the handler returns, OTel's timer-driven batch processors would never fire —
64
+ * so spans/metrics must be flushed synchronously here. Bounded by a timeout so a
65
+ * stalled exporter can never block the response, and fully guarded so a flush
66
+ * failure never breaks the handler.
67
+ */
68
+ async function flushTelemetry(telemetry, timeoutMs) {
69
+ try {
70
+ if (
71
+ !telemetry ||
72
+ typeof telemetry.isEnabled !== 'function' ||
73
+ !telemetry.isEnabled()
74
+ ) {
75
+ return;
76
+ }
77
+ let timer;
78
+ const deadline = new Promise((resolve) => {
79
+ timer = setTimeout(resolve, timeoutMs);
80
+ });
81
+ try {
82
+ await Promise.race([
83
+ Promise.resolve(telemetry.forceFlush()),
84
+ deadline,
85
+ ]);
86
+ } finally {
87
+ clearTimeout(timer);
88
+ }
89
+ } catch (_) {
90
+ // Telemetry flush must never break the handler.
91
+ }
92
+ }
93
+
94
+ // Best-effort extraction of correlation identifiers from a Lambda event.
95
+ // For SQS: pulls messageIds + parsed event/processId/integrationId from each
96
+ // record body. For HTTP: pulls method+path. Never throws.
97
+ const summarizeLambdaEvent = (event) => {
98
+ if (!event) return {};
99
+ if (Array.isArray(event.Records)) {
100
+ return {
101
+ source: 'sqs',
102
+ records: event.Records.map((r) => {
103
+ let parsed = {};
104
+ try {
105
+ const body = JSON.parse(r.body);
106
+ parsed = {
107
+ event: body?.event,
108
+ processId: body?.data?.processId,
109
+ integrationId: body?.data?.integrationId,
110
+ };
111
+ } catch {
112
+ // ignore unparseable bodies
113
+ }
114
+ return {
115
+ messageId: r.messageId,
116
+ receiveCount: r.attributes?.ApproximateReceiveCount,
117
+ ...parsed,
118
+ };
119
+ }),
120
+ };
121
+ }
122
+ if (event.httpMethod || event.requestContext?.http) {
123
+ return {
124
+ source: 'http',
125
+ method: event.httpMethod || event.requestContext?.http?.method,
126
+ path: event.path || event.rawPath,
127
+ };
128
+ }
129
+ return { source: 'other' };
130
+ };
24
131
 
25
132
  const createHandler = (optionByName = {}) => {
26
133
  const {
@@ -50,101 +157,95 @@ const createHandler = (optionByName = {}) => {
50
157
  // no-op after the first invocation.
51
158
  getPluginTelemetrySubscribers();
52
159
 
53
- const scopeFields = {
54
- requestId: context?.awsRequestId,
55
- handlerName: eventName,
56
- method: eventSummary.method,
57
- route: eventSummary.route,
58
- routeKey: eventSummary.routeKey,
59
- invocation: toScopeInvocation(eventSummary),
60
- };
61
- // The full redacted summary goes only on the entry and failure records.
62
- const requestInvocation = toRequestInvocation(eventSummary);
160
+ try {
161
+ console.info(`[createHandler] ${eventName}: handler entry`, {
162
+ eventName,
163
+ awsRequestId: context?.awsRequestId,
164
+ ...eventSummary,
165
+ });
63
166
 
64
- return runInvocationScope(
65
- scopeFields,
66
- async () => {
67
- try {
68
- log.info('Handler invoked', {
69
- eventName: 'frigg.handler.invoked',
70
- invocation: requestInvocation,
71
- });
167
+ initDebugLog(eventName, event);
72
168
 
73
- // If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
74
- await secretsToEnv();
169
+ const requestMethod = event.httpMethod;
170
+ const requestPath = event.path;
171
+ if (requestMethod && requestPath) {
172
+ console.info(`${requestMethod} ${requestPath}`);
173
+ }
75
174
 
76
- // If enabled (i.e. if SSM_PARAMETER_PREFIX and FRIGG_SSM_OFFLOADED_KEYS are set) fetch offloaded params from SSM Parameter Store into process.env.
77
- await parametersToEnv();
175
+ // If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
176
+ await secretsToEnv();
78
177
 
79
- // Lazy-required so DB-free handlers never load the Prisma client.
80
- if (shouldUseDatabase) {
81
- const { connectPrisma } = require('../database/prisma');
82
- await connectPrisma();
83
- }
178
+ // If enabled (i.e. if SSM_PARAMETER_PREFIX and FRIGG_SSM_OFFLOADED_KEYS are set) fetch offloaded params from SSM Parameter Store into process.env.
179
+ await parametersToEnv();
84
180
 
85
- // Helps reuse the database connection. Lowers response times.
86
- context.callbackWaitsForEmptyEventLoop = false;
87
-
88
- // Run the Lambda
89
- return await method(event, context);
90
- } catch (error) {
91
- // Allow client-safe errors to pass through with their actual message
92
- if (isUserFacingResponse && error.isClientSafe === true) {
93
- const statusCode = error.statusCode || 400;
94
- log.warn('Request rejected', {
95
- eventName: 'frigg.handler.rejected',
96
- statusCode,
97
- error,
98
- });
99
- return {
100
- statusCode,
101
- body: JSON.stringify({
102
- error: error.message,
103
- }),
104
- };
105
- }
181
+ // Lazy-required so DB-free handlers never load the Prisma client.
182
+ if (shouldUseDatabase) {
183
+ const { connectPrisma } = require('../database/prisma');
184
+ await connectPrisma();
185
+ }
106
186
 
107
- // Server-to-server: halt errors succeed and won't be
108
- // retried, so the halt itself is the failure record.
109
- if (!isUserFacingResponse && error.isHaltError === true) {
110
- log.error('Handler halted', {
111
- eventName: 'frigg.handler.halted',
112
- invocation: requestInvocation,
113
- error,
114
- });
115
- return;
116
- }
187
+ // Helps reuse the database connection. Lowers response times.
188
+ context.callbackWaitsForEmptyEventLoop = false;
189
+
190
+ // Run the Lambda
191
+ return await method(event, context);
192
+ } catch (error) {
193
+ flushDebugLog(error);
194
+
195
+ // Don't leak implementation details to end users.
196
+ if (isUserFacingResponse) {
197
+ // Allow client-safe errors to pass through with their actual message
198
+ if (error.isClientSafe === true) {
199
+ const statusCode = error.statusCode || 400;
200
+ return {
201
+ statusCode,
202
+ body: JSON.stringify({
203
+ error: error.message,
204
+ }),
205
+ };
206
+ }
207
+
208
+ // Hide other errors with generic message
209
+ return {
210
+ statusCode: 500,
211
+ body: JSON.stringify({
212
+ error: 'An Internal Error Occurred',
213
+ }),
214
+ };
215
+ }
117
216
 
118
- log.error('Handler failed', {
119
- eventName: 'frigg.handler.failed',
120
- invocation: requestInvocation,
121
- error,
122
- });
123
-
124
- // Don't leak implementation details to end users.
125
- if (isUserFacingResponse) {
126
- return {
127
- statusCode: 500,
128
- body: JSON.stringify({
129
- error: 'An Internal Error Occurred',
130
- }),
131
- };
217
+ // Handle server-to-server responses.
218
+
219
+ // Halt errors are logged but suceed and won't be retried.
220
+ // Log explicitly — silent suppression here previously made stuck
221
+ // messages invisible to observability tooling. Include
222
+ // eventSummary so operators can correlate across concurrent
223
+ // invocations (processId / messageIds / HTTP path).
224
+ if (error.isHaltError === true) {
225
+ console.warn(
226
+ `[createHandler] ${eventName}: halt error suppressed (no retry)`,
227
+ {
228
+ eventName,
229
+ errorName: error.name,
230
+ errorMessage: error.message,
231
+ statusCode: error.statusCode,
232
+ ...eventSummary,
132
233
  }
234
+ );
235
+ return;
236
+ }
133
237
 
134
- // The Lambda runtime writes a rethrown error itself, so
135
- // rethrow a sanitized copy (ADR-048 §6 item 11).
136
- throw toSanitizedSurrogate(error);
137
- }
138
- },
139
- {
140
- telemetry: activeTelemetry,
141
- usageRollup: activeUsageRollup,
238
+ // Here we can just rethrow and let AWS build the response.
239
+ throw error;
240
+ } finally {
241
+ // Flush telemetry + usage before the container freezes.
242
+ await flushTelemetry(activeTelemetry, flushTimeoutMs);
243
+ await flushUsageRollup(
244
+ activeUsageRollup,
142
245
  eventSummary,
143
- shouldUseDatabase,
144
- flushTimeoutMs,
145
- context,
146
- }
147
- );
246
+ shouldUseDatabase
247
+ );
248
+ }
148
249
  };
149
250
  };
150
251
 
package/core/index.js CHANGED
@@ -2,12 +2,5 @@ const { Delegate } = require('./Delegate');
2
2
  const { Worker } = require('./Worker');
3
3
  const { loadInstalledModules } = require('./load-installed-modules');
4
4
  const { createHandler } = require('./create-handler');
5
- const { runInvocationScope } = require('./invocation-scope');
6
5
 
7
- module.exports = {
8
- Delegate,
9
- Worker,
10
- loadInstalledModules,
11
- createHandler,
12
- runInvocationScope,
13
- };
6
+ module.exports = { Delegate, Worker, loadInstalledModules, createHandler };
@@ -1,9 +1,5 @@
1
- const { getLogger } = require('../logs');
2
-
3
- const log = getLogger('frigg.core.secrets');
4
-
5
1
  const getSecretValue = async () => {
6
- log.debug('Fetching secrets', { eventName: 'frigg.core.secrets.fetching' });
2
+ console.log('Fetching secrets...');
7
3
 
8
4
  const httpPort = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || 2773;
9
5
  const url = `http://localhost:${httpPort}/secretsmanager/get?secretId=${encodeURIComponent(
@@ -19,7 +15,10 @@ const getSecretValue = async () => {
19
15
  const response = await fetch(url, options);
20
16
 
21
17
  if (!response.ok) {
22
- throw new Error(`Secrets fetch failed with ${response.status}`);
18
+ const json = await response.json().catch((err) => err.message);
19
+ console.error('Invalid response - response:', JSON.stringify(response));
20
+ console.error('Invalid response - json:', json);
21
+ throw new Error(`Invalid ${response.status} response`);
23
22
  }
24
23
 
25
24
  const result = await response.json();
@@ -45,7 +44,7 @@ const secretsToEnv = async () => {
45
44
  if (!process.env.SECRET_ARN) {
46
45
  return;
47
46
  }
48
- log.debug('Secrets to env', { eventName: 'frigg.core.secrets.to_env' });
47
+ console.log('Secrets to env');
49
48
 
50
49
  try {
51
50
  const secrets = await getSecretValue();
@@ -125,9 +125,16 @@ let cachedDbType = null;
125
125
  */
126
126
  const PRISMA_LOG_LEVEL = process.env.PRISMA_LOG_LEVEL || 'error,warn';
127
127
 
128
+ /**
129
+ * Enable Prisma query logging for performance monitoring
130
+ * @type {boolean}
131
+ */
132
+ const PRISMA_QUERY_LOGGING = process.env.PRISMA_QUERY_LOGGING === 'true';
133
+
128
134
  module.exports = {
129
135
  getDatabaseType, // Export for testing and direct use
130
136
  PRISMA_LOG_LEVEL,
137
+ PRISMA_QUERY_LOGGING,
131
138
  };
132
139
 
133
140
  /**
@@ -1,5 +1,9 @@
1
1
  const { Cryptor } = require('../encrypt/Cryptor');
2
- const { getEncryptedFields, loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
2
+ const {
3
+ getEncryptedFields,
4
+ getFieldsToEncryptOnWrite,
5
+ loadCustomEncryptionSchema,
6
+ } = require('./encryption/encryption-schema-registry');
3
7
 
4
8
  /**
5
9
  * Encryption service specifically for DocumentDB repositories
@@ -107,7 +111,7 @@ class DocumentDBEncryptionService {
107
111
  }
108
112
 
109
113
  // Get encrypted fields from registry
110
- const encryptedFieldsConfig = getEncryptedFields(modelName);
114
+ const encryptedFieldsConfig = getFieldsToEncryptOnWrite(modelName);
111
115
  if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
112
116
  return document;
113
117
  }
@@ -166,11 +166,12 @@ async function findManyDrained(client, collection, filter = {}, options = {}) {
166
166
  return drainCursor(client, collection, first);
167
167
  }
168
168
 
169
- async function aggregateDrained(client, collection, pipeline) {
169
+ async function aggregateDrained(client, collection, pipeline, options = {}) {
170
170
  const first = await client.$runCommandRaw({
171
171
  aggregate: collection,
172
172
  pipeline,
173
173
  cursor: { batchSize: DRAIN_BATCH_SIZE },
174
+ ...options,
174
175
  });
175
176
  return drainCursor(client, collection, first);
176
177
  }
@@ -680,11 +680,7 @@ The encryption system uses **fail-fast error handling**:
680
680
 
681
681
  ### Logging Configuration
682
682
 
683
- `FRIGG_LOG_LEVEL` is the one Frigg log level. The level rules, the record
684
- fields and the redaction rules are in the
685
- [Logging guide](../../../../docs/guides/LOGGING.md). The encryption logger
686
- still writes text lines through `console` with the same level names until it
687
- moves to the structured logger (ADR-048, incremental adoption).
683
+ Configure log verbosity with `FRIGG_LOG_LEVEL`:
688
684
 
689
685
  ```bash
690
686
  # Production (minimal logging)
@@ -818,6 +814,26 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
818
814
  ❌ Query on encrypted fields (not supported)
819
815
  ❌ Manually decrypt data (use extension)
820
816
 
817
+ The integration mapping repositories' `queryMappings()` (PostgreSQL, MongoDB
818
+ and DocumentDB) filters and sorts inside the `mapping` JSON, so it refuses to
819
+ run while field-level encryption still encrypts `IntegrationMapping.mapping` on
820
+ write. Opt the field out in the app definition, together with any nested
821
+ `mapping.*` path that `encryption.schema` encrypts; the Prisma extension and
822
+ `DocumentDBEncryptionService` both honor the opt-out:
823
+
824
+ ```javascript
825
+ encryption: {
826
+ disable: { IntegrationMapping: ['mapping'] },
827
+ }
828
+ ```
829
+
830
+ The opt-out applies to writes only. Rows written encrypted before it stay
831
+ readable, but `queryMappings()` does not match them until they are written
832
+ again. It returns the rows it matches decrypted, like
833
+ `findMappingsByIntegration()`: a nested `mapping.*` path written encrypted
834
+ before its opt-out reads plain, though conditions and ordering on that path
835
+ still see the ciphertext until the row is written again.
836
+
821
837
  ## Future Enhancements
822
838
 
823
839
  ### Planned
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  const { logger } = require('./logger');
12
- const { addDeniedKeys } = require('../../logs/redact');
13
12
 
14
13
  /**
15
14
  * Core encryption schema (immutable - cannot be overridden by custom schemas)
@@ -121,7 +120,6 @@ function registerCustomSchema(schema) {
121
120
  }
122
121
 
123
122
  customSchema = { ...schema };
124
- addDeniedKeys(Object.values(schema).flatMap((config) => config.fields));
125
123
  logger.info(
126
124
  `Registered custom encryption schema for models: ${Object.keys(customSchema).join(', ')}`
127
125
  );
@@ -151,7 +149,6 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
151
149
  }
152
150
  }
153
151
 
154
- addDeniedKeys(fields);
155
152
  return [...new Set(fields)];
156
153
  }
157
154
 
@@ -0,0 +1,97 @@
1
+ const { getEncryptionConfig } = require('../prisma');
2
+ const { Cryptor } = require('../../encrypt/Cryptor');
3
+ const {
4
+ getFieldsToDecryptOnRead,
5
+ getFieldsToEncryptOnWrite,
6
+ loadCustomEncryptionSchema,
7
+ } = require('./encryption-schema-registry');
8
+ const {
9
+ createFieldEncryptionService,
10
+ } = require('./prisma-encryption-extension');
11
+
12
+ let plainMappingEncryption = null;
13
+
14
+ /**
15
+ * The `IntegrationMapping` fields, `mapping` itself or a nested `mapping.*`
16
+ * path, that field-level encryption encrypts on write. An empty result is
17
+ * kept for the life of the process.
18
+ *
19
+ * @returns {string[]} Empty when every mapping path is written as plain JSON
20
+ */
21
+ function getMappingFieldsEncryptedOnWrite() {
22
+ return mappingEncryption().encryptedOnWrite;
23
+ }
24
+
25
+ /**
26
+ * @throws {Error} When field-level encryption still encrypts `mapping`, or a
27
+ * nested `mapping.*` path, on write, naming the opt-out that lifts it
28
+ */
29
+ function assertMappingWrittenUnencrypted() {
30
+ const fields = getMappingFieldsEncryptedOnWrite();
31
+ if (fields.length === 0) return;
32
+
33
+ const encrypted = fields
34
+ .map((field) => `IntegrationMapping.${field}`)
35
+ .join(', ');
36
+ const optOut = fields.map((field) => `'${field}'`).join(', ');
37
+ throw new Error(
38
+ `queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Decrypts rows that `queryMappings` read around the Prisma encryption
44
+ * extension, the way reads through the extension do: every
45
+ * `IntegrationMapping` field the schema lists, opted-out paths included, so a
46
+ * path written encrypted before its opt-out comes back plain.
47
+ *
48
+ * @param {Object[]} rows - Rows whose `mapping` is a JSON object
49
+ * @returns {Promise<Object[]>} `rows` itself when encryption is off or the
50
+ * schema lists no field besides `mapping`
51
+ */
52
+ async function decryptQueriedMappings(rows) {
53
+ const { decryptor } = mappingEncryption();
54
+ if (!decryptor) return rows;
55
+ return decryptor.decryptFieldsInBulk('IntegrationMapping', rows);
56
+ }
57
+
58
+ function mappingEncryption() {
59
+ if (plainMappingEncryption) return plainMappingEncryption;
60
+
61
+ const encryption = currentMappingEncryption();
62
+ if (encryption.encryptedOnWrite.length === 0) {
63
+ plainMappingEncryption = encryption;
64
+ }
65
+ return encryption;
66
+ }
67
+
68
+ function currentMappingEncryption() {
69
+ const config = getEncryptionConfig();
70
+ if (!config.enabled) return { encryptedOnWrite: [], decryptor: null };
71
+
72
+ loadCustomEncryptionSchema();
73
+ const encryptedOnWrite = getFieldsToEncryptOnWrite(
74
+ 'IntegrationMapping'
75
+ ).filter((field) => field === 'mapping' || field.startsWith('mapping.'));
76
+ const decryptsBesidesMapping = getFieldsToDecryptOnRead(
77
+ 'IntegrationMapping'
78
+ ).some((field) => field !== 'mapping');
79
+ const decryptor = decryptsBesidesMapping
80
+ ? createFieldEncryptionService(
81
+ new Cryptor({ shouldUseAws: config.method === 'kms' })
82
+ )
83
+ : null;
84
+ return { encryptedOnWrite, decryptor };
85
+ }
86
+
87
+ /** Test helper: forget a kept result. */
88
+ function resetMappingEncryptionCheck() {
89
+ plainMappingEncryption = null;
90
+ }
91
+
92
+ module.exports = {
93
+ assertMappingWrittenUnencrypted,
94
+ decryptQueriedMappings,
95
+ getMappingFieldsEncryptedOnWrite,
96
+ resetMappingEncryptionCheck,
97
+ };
@@ -10,6 +10,24 @@ const {
10
10
  } = require('./encryption-schema-registry');
11
11
  const { FieldEncryptionService } = require('./field-encryption-service');
12
12
 
13
+ /**
14
+ * The FieldEncryptionService the extension runs, with field paths from the
15
+ * encryption schema registry.
16
+ *
17
+ * @param {import('../../encrypt/Cryptor').Cryptor} cryptor
18
+ * @returns {FieldEncryptionService}
19
+ */
20
+ function createFieldEncryptionService(cryptor) {
21
+ return new FieldEncryptionService({
22
+ cryptor,
23
+ schema: {
24
+ getEncryptedFields,
25
+ getFieldsToEncryptOnWrite,
26
+ getFieldsToDecryptOnRead,
27
+ },
28
+ });
29
+ }
30
+
13
31
  function createEncryptionExtension({ cryptor, enabled = true }) {
14
32
  if (!enabled) {
15
33
  return (client) => client;
@@ -21,14 +39,7 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
21
39
  );
22
40
  }
23
41
 
24
- const encryptionService = new FieldEncryptionService({
25
- cryptor,
26
- schema: {
27
- getEncryptedFields,
28
- getFieldsToEncryptOnWrite,
29
- getFieldsToDecryptOnRead,
30
- },
31
- });
42
+ const encryptionService = createFieldEncryptionService(cryptor);
32
43
 
33
44
  return {
34
45
  name: 'frigg-field-encryption',
@@ -227,4 +238,4 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
227
238
  };
228
239
  }
229
240
 
230
- module.exports = { createEncryptionExtension };
241
+ module.exports = { createEncryptionExtension, createFieldEncryptionService };