@friggframework/core 2.0.0--canary.686.7f3bc1a.0 → 2.0.0--canary.680.6bacd26.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 (156) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +0 -28
  3. package/README.md +0 -105
  4. package/application/commands/integration-commands.js +2 -2
  5. package/application/commands/scheduler-commands.js +7 -11
  6. package/application/commands/user-commands.js +5 -17
  7. package/core/Worker.js +7 -31
  8. package/core/create-handler.js +0 -9
  9. package/credential/use-cases/credential-operations-for-user.js +25 -110
  10. package/credential/use-cases/reauthorize-credential-for-user.js +8 -15
  11. package/database/config.js +24 -48
  12. package/database/documentdb-utils.js +1 -6
  13. package/database/encryption/README.md +2 -0
  14. package/database/encryption/encryption-config.js +31 -10
  15. package/database/encryption/encryption-schema-registry.js +47 -99
  16. package/database/encryption/field-encryption-service.js +69 -5
  17. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  18. package/database/prisma.js +2 -4
  19. package/generated/prisma-mongodb/edge.js +4 -27
  20. package/generated/prisma-mongodb/index-browser.js +1 -24
  21. package/generated/prisma-mongodb/index.d.ts +48 -3228
  22. package/generated/prisma-mongodb/index.js +4 -27
  23. package/generated/prisma-mongodb/package.json +1 -1
  24. package/generated/prisma-mongodb/schema.prisma +0 -65
  25. package/generated/prisma-mongodb/wasm.js +4 -27
  26. package/generated/prisma-postgresql/edge.js +4 -27
  27. package/generated/prisma-postgresql/index-browser.js +1 -24
  28. package/generated/prisma-postgresql/index.d.ts +51 -3503
  29. package/generated/prisma-postgresql/index.js +4 -27
  30. package/generated/prisma-postgresql/package.json +1 -1
  31. package/generated/prisma-postgresql/schema.prisma +0 -61
  32. package/generated/prisma-postgresql/wasm.js +4 -27
  33. package/handlers/app-definition-loader.js +12 -103
  34. package/handlers/routers/auth.js +12 -40
  35. package/handlers/routers/health.js +2 -4
  36. package/handlers/routers/integration-defined-routers.js +92 -144
  37. package/handlers/routers/integration-webhook-routers.js +64 -102
  38. package/index.js +1 -63
  39. package/infrastructure/scheduler/index.js +6 -19
  40. package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
  41. package/integrations/index.js +0 -5
  42. package/integrations/integration-router.js +73 -115
  43. package/integrations/repositories/integration-repository-documentdb.js +3 -35
  44. package/integrations/repositories/integration-repository-interface.js +1 -17
  45. package/integrations/repositories/integration-repository-mongo.js +76 -59
  46. package/integrations/repositories/integration-repository-postgres.js +76 -62
  47. package/integrations/tests/doubles/test-integration-repository.js +1 -12
  48. package/integrations/use-cases/create-integration.js +20 -55
  49. package/integrations/use-cases/get-integrations-for-user.js +40 -80
  50. package/integrations/use-cases/index.js +1 -7
  51. package/integrations/use-cases/integration-operations-for-user.js +24 -55
  52. package/integrations/utils/map-integration-dto.js +1 -44
  53. package/management-api/management-api-config.js +2 -71
  54. package/management-api/meta-router.js +2 -21
  55. package/management-api/openapi/components.js +0 -40
  56. package/management-api/openapi/v2-operations.js +4 -16
  57. package/management-api/route-registry.js +6 -123
  58. package/management-api/v2/dependencies.js +15 -40
  59. package/management-api/v2/dto.js +0 -20
  60. package/management-api/v2/handlers/authorization.js +0 -1
  61. package/management-api/v2/handlers/credentials.js +2 -6
  62. package/management-api/v2/handlers/entities.js +7 -10
  63. package/management-api/v2/handlers/integrations.js +3 -6
  64. package/management-api/v2/request.js +1 -32
  65. package/modules/index.js +0 -8
  66. package/modules/module-factory.js +0 -12
  67. package/modules/module.js +2 -53
  68. package/modules/repositories/module-repository-documentdb.js +6 -51
  69. package/modules/repositories/module-repository-interface.js +2 -19
  70. package/modules/repositories/module-repository-mongo.js +74 -67
  71. package/modules/repositories/module-repository-postgres.js +74 -66
  72. package/modules/repositories/module-repository.js +1 -2
  73. package/modules/use-cases/entity-operations-for-user.js +13 -50
  74. package/modules/use-cases/entity-types.js +7 -31
  75. package/modules/use-cases/execute-entity-proxy-request.js +3 -50
  76. package/modules/use-cases/get-authorization-requirements.js +2 -2
  77. package/modules/use-cases/get-entities-for-user.js +13 -26
  78. package/modules/use-cases/get-entity-module-for-user.js +7 -34
  79. package/modules/use-cases/get-entity-options-by-id.js +4 -10
  80. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  81. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  82. package/modules/use-cases/get-module.js +4 -10
  83. package/modules/use-cases/multi-step-authorization.js +13 -40
  84. package/modules/use-cases/process-authorization-callback.js +19 -53
  85. package/modules/use-cases/refresh-entity-options.js +4 -10
  86. package/modules/use-cases/test-module-auth.js +4 -10
  87. package/package.json +5 -6
  88. package/prisma-mongodb/schema.prisma +0 -65
  89. package/prisma-postgresql/schema.prisma +0 -61
  90. package/queues/index.js +0 -25
  91. package/types/index.d.ts +0 -1
  92. package/types/modules/index.d.ts +0 -139
  93. package/user/repositories/user-repository-documentdb.js +2 -27
  94. package/user/repositories/user-repository-interface.js +0 -18
  95. package/user/repositories/user-repository-mongo.js +0 -24
  96. package/user/repositories/user-repository-postgres.js +0 -18
  97. package/user/use-cases/authenticate-user.js +1 -7
  98. package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
  99. package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
  100. package/user/user.js +1 -64
  101. package/database/organization-where.js +0 -18
  102. package/database/unique-violation.js +0 -25
  103. package/extensions/bootstrap-runner.js +0 -43
  104. package/extensions/extension-loader.js +0 -187
  105. package/extensions/index.js +0 -41
  106. package/extensions/initialize-app.js +0 -123
  107. package/extensions/route-mounter.js +0 -103
  108. package/extensions/schema-composer.js +0 -188
  109. package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -216
  110. package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
  111. package/integrations/repositories/integration-not-found.js +0 -14
  112. package/integrations/schedule-config.js +0 -80
  113. package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
  114. package/integrations/use-cases/resolve-integration-ownership.js +0 -177
  115. package/modules/entity-scope.js +0 -15
  116. package/modules/repositories/entity-not-found.js +0 -18
  117. package/modules/scope-config.js +0 -192
  118. package/modules/scope-policy.js +0 -112
  119. package/modules/scope-resolution.js +0 -288
  120. package/modules/scope-visibility.js +0 -247
  121. package/modules/tests/doubles/ownership-matching.js +0 -18
  122. package/modules/use-cases/authorize-entity-for-user.js +0 -61
  123. package/modules/use-cases/create-entity-from-credential.js +0 -113
  124. package/modules/use-cases/manage-access.js +0 -21
  125. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
  126. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
  127. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
  128. package/providers/index.js +0 -23
  129. package/providers/resolve-provider.js +0 -93
  130. package/providers/runtime-detection.js +0 -124
  131. package/queues/providers/index.js +0 -11
  132. package/queues/providers/netlify-background-provider.js +0 -150
  133. package/queues/providers/qstash-queue-provider.js +0 -219
  134. package/queues/providers/sqs-queue-provider.js +0 -70
  135. package/queues/queue-client-interface.js +0 -55
  136. package/queues/queue-message-auth.js +0 -193
  137. package/queues/queue-provider-factory.js +0 -101
  138. package/queues/queue-provider.js +0 -65
  139. package/queues/sqs-queue-client.js +0 -81
  140. package/types/user/index.d.ts +0 -134
  141. package/user/adopter-jwt-config.js +0 -177
  142. package/user/adopter-jwt-errors.js +0 -59
  143. package/user/adopter-token-verifier.js +0 -122
  144. package/user/app-user-helpers.js +0 -97
  145. package/user/create-user-authentication.js +0 -64
  146. package/user/membership.js +0 -51
  147. package/user/repositories/organization-membership-record.js +0 -19
  148. package/user/repositories/organization-membership-repository-documentdb.js +0 -106
  149. package/user/repositories/organization-membership-repository-factory.js +0 -42
  150. package/user/repositories/organization-membership-repository-interface.js +0 -57
  151. package/user/repositories/organization-membership-repository-mongo.js +0 -13
  152. package/user/repositories/organization-membership-repository-postgres.js +0 -29
  153. package/user/repositories/organization-membership-repository-prisma.js +0 -81
  154. package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
  155. package/user/use-cases/ensure-organization-membership.js +0 -92
  156. package/user/use-cases/find-individual-users-by-organization.js +0 -54
@@ -3,39 +3,6 @@
3
3
  * Manages configuration for Prisma ORM operations
4
4
  */
5
5
 
6
- /**
7
- * Maps an app definition's `database` block to a Prisma database type.
8
- *
9
- * @param {Object|undefined} database - `Definition.database`
10
- * @param {string} where - where the definition lives, for the error message
11
- * @returns {'mongodb'|'postgresql'|'documentdb'}
12
- */
13
- function databaseTypeFromConfig(database, where) {
14
- if (!database) {
15
- throw new Error(
16
- '[Frigg] App definition missing database configuration. ' +
17
- `Add database: { postgres: { enable: true } } (or mongoDB/documentDB) to ${where}`
18
- );
19
- }
20
-
21
- // Determine database type from enabled database
22
- // Priority order: postgres > mongoDB > documentDB
23
- if (database.postgres?.enable === true) {
24
- return 'postgresql';
25
- }
26
- if (database.mongoDB?.enable === true) {
27
- return 'mongodb';
28
- }
29
- if (database.documentDB?.enable === true) {
30
- return 'documentdb';
31
- }
32
-
33
- throw new Error(
34
- '[Frigg] No database enabled in app definition. ' +
35
- 'Set one of: database.postgres.enable, database.mongoDB.enable, or database.documentDB.enable to true'
36
- );
37
- }
38
-
39
6
  /**
40
7
  * Determines database type from environment or app definition
41
8
  *
@@ -54,20 +21,6 @@ function getDatabaseType() {
54
21
 
55
22
  // Fallback: Load app definition
56
23
  try {
57
- // A definition preset with setAppDefinition() (provider-netlify's
58
- // function preamble) wins: on Netlify process.cwd() is the function
59
- // root at runtime, so discovery cannot find the backend.
60
- const {
61
- getPresetAppDefinition,
62
- } = require('../handlers/app-definition-loader');
63
- const preset = getPresetAppDefinition();
64
- if (preset) {
65
- return databaseTypeFromConfig(
66
- preset.database,
67
- 'your backend/index.js'
68
- );
69
- }
70
-
71
24
  const path = require('node:path');
72
25
  const fs = require('node:fs');
73
26
  const { findNearestBackendPackageJson } = require('../utils');
@@ -123,7 +76,30 @@ function getDatabaseType() {
123
76
  }
124
77
 
125
78
  database = backendModule?.Definition?.database;
126
- return databaseTypeFromConfig(database, backendIndexPath);
79
+
80
+ if (!database) {
81
+ throw new Error(
82
+ '[Frigg] App definition missing database configuration. ' +
83
+ `Add database: { postgres: { enable: true } } (or mongoDB/documentDB) to ${backendIndexPath}`
84
+ );
85
+ }
86
+
87
+ // Determine database type from enabled database
88
+ // Priority order: postgres > mongoDB > documentDB
89
+ if (database.postgres?.enable === true) {
90
+ return 'postgresql';
91
+ }
92
+ if (database.mongoDB?.enable === true) {
93
+ return 'mongodb';
94
+ }
95
+ if (database.documentDB?.enable === true) {
96
+ return 'documentdb';
97
+ }
98
+
99
+ throw new Error(
100
+ '[Frigg] No database enabled in app definition. ' +
101
+ 'Set one of: database.postgres.enable, database.mongoDB.enable, or database.documentDB.enable to true'
102
+ );
127
103
  } catch (error) {
128
104
  // Re-throw with context if it's our error
129
105
  if (error.message.includes('[Frigg]')) {
@@ -58,12 +58,7 @@ async function insertOne(client, collection, document) {
58
58
 
59
59
  // Provide helpful context for common errors
60
60
  if (error.code === 11000) {
61
- // `code` lets callers recognise a lost race without parsing text
62
- // (see database/unique-violation.js).
63
- throw Object.assign(
64
- new Error(`${errorMsg} - Duplicate key violation`),
65
- { code: 11000 }
66
- );
61
+ throw new Error(`${errorMsg} - Duplicate key violation`);
67
62
  }
68
63
  throw new Error(errorMsg);
69
64
  }
@@ -456,6 +456,8 @@ Format: "keyId:encryptedText:encryptedKey"
456
456
  Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey"
457
457
  ```
458
458
 
459
+ **Recognising ciphertext on read.** A value is decrypted only when it has exactly four colon-separated parts: base64 `keyId`, 32-hex-character `iv`, hex ciphertext, base64 `encryptedKey`. Plaintext that merely contains colons (`https://user:pw@host:443`) is left alone. A value with that shape that still fails to decrypt (a wrong, rotated or missing key) fails the read with `FieldDecryptionError` in a deployed runtime (see [When Encryption Runs](#when-encryption-runs)), so ciphertext never reaches an API module as if it were a token. Locally it is returned as stored and logged as a warning, so one bad field does not fail a whole read. A field that becomes encrypted later (for example a module's `credentialFields`) stays plaintext in existing rows until they are next written.
460
+
459
461
  **Why Envelope Encryption?**
460
462
 
461
463
  - Reduces KMS API calls (one DEK per field, cached)
@@ -18,9 +18,6 @@
18
18
  */
19
19
 
20
20
  const { logger } = require('./logger');
21
- const {
22
- isDeployedRuntime: isDeployedProviderRuntime,
23
- } = require('../../providers/runtime-detection');
24
21
 
25
22
  const LOCAL_BYPASS_STAGES = ['dev', 'test', 'local'];
26
23
  const OPT_OUT_VAR = 'FRIGG_ENCRYPTION_DISABLED';
@@ -43,18 +40,42 @@ function isTrue(value) {
43
40
  }
44
41
 
45
42
  /**
46
- * True when the process runs as a deployed function, on AWS Lambda or on the
47
- * active provider's platform (provider-netlify: FRIGG_PROVIDER=netlify, set by
48
- * its generated entry points), and not under a local emulator or test runner.
49
- * One rule for encryption and queue authentication: core/providers/
50
- * runtime-detection asks the provider's detect(), and fails closed when a
51
- * named non-AWS provider cannot be loaded.
43
+ * True when the process runs as a deployed function, on AWS Lambda or on
44
+ * another provider's platform.
45
+ *
46
+ * - AWS Lambda sets AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT and
47
+ * AWS_EXECUTION_ENV (AWS_Lambda_<runtime>). Other AWS hosts (CloudShell,
48
+ * CodeBuild, ECS) set AWS_EXECUTION_ENV to other values and count as local:
49
+ * CLI commands run from CI must not trip the deployed-stage check.
50
+ * - Other providers: FRIGG_PROVIDER names a non-AWS provider. Frigg's
51
+ * generated function entry points for that provider set it (provider-netlify
52
+ * sets 'netlify'), so it is present in every deployed function. Netlify
53
+ * exposes SITE_ID, CONTEXT and NETLIFY at build time only, so they cannot
54
+ * tell a running function from a laptop.
55
+ *
56
+ * A local run is never deployed: serverless-offline and `serverless invoke
57
+ * local` (which set the Lambda variables themselves) mark IS_OFFLINE /
58
+ * IS_LOCAL, `netlify dev` marks NETLIFY_DEV, and Jest marks JEST_WORKER_ID.
52
59
  *
53
60
  * @param {Object} [env=process.env]
54
61
  * @returns {boolean}
55
62
  */
56
63
  function isDeployedRuntime(env = process.env) {
57
- return isDeployedProviderRuntime(env);
64
+ const localRun =
65
+ isTrue(env.IS_OFFLINE) ||
66
+ isTrue(env.IS_LOCAL) ||
67
+ isTrue(env.NETLIFY_DEV) ||
68
+ isSet(env.JEST_WORKER_ID);
69
+ if (localRun) return false;
70
+
71
+ const onAwsRuntime =
72
+ isSet(env.AWS_LAMBDA_FUNCTION_NAME) ||
73
+ isSet(env.LAMBDA_TASK_ROOT) ||
74
+ String(env.AWS_EXECUTION_ENV ?? '').startsWith('AWS_Lambda_');
75
+ if (onAwsRuntime) return true;
76
+
77
+ const provider = String(env.FRIGG_PROVIDER ?? '').trim().toLowerCase();
78
+ return provider !== '' && provider !== 'aws';
58
79
  }
59
80
 
60
81
  function missingKeyMessage(stage) {
@@ -10,6 +10,11 @@
10
10
 
11
11
  const { logger } = require('./logger');
12
12
  const { registerDeniedKeys } = require('../../logs/denied-keys');
13
+ const {
14
+ getEntityTypeRegistry,
15
+ getEntityTypeDefinitions,
16
+ EntityRegistryError,
17
+ } = require('../../modules/entity-type-registry');
13
18
 
14
19
  /**
15
20
  * Core encryption schema (immutable - cannot be overridden by custom schemas)
@@ -166,8 +171,9 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
166
171
  * Each module can declare credentialFields to encrypt in its encryption config.
167
172
  *
168
173
  * @param {Array} integrations - Array of integration classes with modules
174
+ * @param {object} [entities] - `appDefinition.entities` (ADR-059 registry)
169
175
  */
170
- function loadModuleEncryptionSchemas(integrations) {
176
+ function loadModuleEncryptionSchemas(integrations, entities) {
171
177
  if (!integrations) {
172
178
  throw new Error('integrations parameter is required');
173
179
  }
@@ -176,34 +182,39 @@ function loadModuleEncryptionSchemas(integrations) {
176
182
  throw new Error('integrations must be an array');
177
183
  }
178
184
 
179
- if (integrations.length === 0) {
185
+ // `entities` is the app definition's registry (ADR-059): a module an app
186
+ // registers without any integration still carries credentials to encrypt.
187
+ if (integrations.length === 0 && !entities) {
180
188
  return;
181
189
  }
182
190
 
183
- // NOTE: this path does not exist ('../integrations' resolves to
184
- // database/integrations), so today this function throws MODULE_NOT_FOUND,
185
- // loadCustomEncryptionSchema swallows it, and module credentialFields are
186
- // effectively NOT registered. Fixing it changes which fields are encrypted
187
- // and needs a decrypt that tolerates plaintext, so it ships separately:
188
- // branch claude/internal-mcp-03b-module-credential-encryption. Left as is
189
- // here on purpose (ADR-059 must not change what gets encrypted).
190
- const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
191
-
192
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
191
+ const moduleDefinitions = getEntityTypeDefinitions(
192
+ getEntityTypeRegistry({ integrations, entities })
193
+ );
193
194
  const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
194
195
 
195
- if (credentialFields.length === 0) {
196
+ // Core fields are encrypted already and a custom schema may not repeat
197
+ // them. Everything the app registered itself stays: module fields are added
198
+ // to the custom schema, never substituted for it (other models included).
199
+ const coreFields = new Set(CORE_ENCRYPTION_SCHEMA.Credential?.fields || []);
200
+ const fields = [
201
+ ...new Set([
202
+ ...(customSchema.Credential?.fields || []),
203
+ ...credentialFields.filter((field) => !coreFields.has(field)),
204
+ ]),
205
+ ];
206
+
207
+ if (fields.length === 0) {
196
208
  return;
197
209
  }
198
210
 
199
211
  const moduleSchema = {
200
- Credential: {
201
- fields: credentialFields
202
- }
212
+ ...customSchema,
213
+ Credential: { ...customSchema.Credential, fields },
203
214
  };
204
215
 
205
216
  logger.info(
206
- `Registering module-level encryption for ${credentialFields.length} credential fields`
217
+ `Registering module-level encryption for ${fields.length} credential fields`
207
218
  );
208
219
 
209
220
  registerCustomSchema(moduleSchema);
@@ -218,80 +229,22 @@ function loadModuleEncryptionSchemas(integrations) {
218
229
  *
219
230
  * Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services.
220
231
  */
221
- /**
222
- * Registers encryption schemas declared by app-level extensions
223
- * (`appDefinition.coreExtensions[].encryption`), e.g. the db-credentials
224
- * extension's `OAuthAppCredential.clientSecret`.
225
- *
226
- * @param {Array<Object>} extensions - Normalized extensions (extension-loader)
227
- */
228
- function loadExtensionEncryptionSchemas(extensions) {
229
- if (!extensions || !Array.isArray(extensions) || extensions.length === 0) {
230
- return;
231
- }
232
-
233
- for (const ext of extensions) {
234
- if (!ext.encryption || typeof ext.encryption !== 'object') {
235
- continue;
236
- }
237
-
238
- const schema = {};
239
- for (const [modelName, config] of Object.entries(ext.encryption)) {
240
- if (config && Array.isArray(config.fields) && config.fields.length > 0) {
241
- schema[modelName] = { fields: config.fields };
242
- }
243
- }
232
+ function loadCustomEncryptionSchema() {
233
+ try {
234
+ // Lazy require to avoid circular dependency issues
235
+ const path = require('node:path');
236
+ const { findNearestBackendPackageJson } = require('../../utils');
244
237
 
245
- if (Object.keys(schema).length > 0) {
246
- logger.info(
247
- `Registering encryption schema from extension "${ext.name}" for models: ${Object.keys(schema).join(', ')}`
248
- );
249
- // registerCustomSchema replaces the registered schema, so merge:
250
- // an extension adds its models to the app's encryption.schema
251
- // rather than dropping it.
252
- const merged = { ...customSchema };
253
- for (const [modelName, config] of Object.entries(schema)) {
254
- const existing = merged[modelName]?.fields || [];
255
- merged[modelName] = {
256
- ...merged[modelName],
257
- fields: [...new Set([...existing, ...config.fields])],
258
- };
259
- }
260
- registerCustomSchema(merged);
238
+ const backendPackagePath = findNearestBackendPackageJson();
239
+ if (!backendPackagePath) {
240
+ return; // No backend found, skip custom schema
261
241
  }
262
- }
263
- }
264
-
265
- function readAppDefinitionForEncryption() {
266
- // Lazy require to avoid circular dependency issues
267
- const {
268
- getPresetAppDefinition,
269
- } = require('../../handlers/app-definition-loader');
270
- // A definition preset with setAppDefinition() (provider-netlify's
271
- // preamble) wins: on Netlify process.cwd() cannot find the backend.
272
- const preset = getPresetAppDefinition();
273
- if (preset) {
274
- return preset;
275
- }
276
-
277
- const path = require('node:path');
278
- const { findNearestBackendPackageJson } = require('../../utils');
279
-
280
- const backendPackagePath = findNearestBackendPackageJson();
281
- if (!backendPackagePath) {
282
- return null; // No backend found, skip custom schema
283
- }
284
-
285
- const backendDir = path.dirname(backendPackagePath);
286
- const backendIndexPath = path.join(backendDir, 'index.js');
287
242
 
288
- const backendModule = require(backendIndexPath);
289
- return backendModule?.Definition || null;
290
- }
243
+ const backendDir = path.dirname(backendPackagePath);
244
+ const backendIndexPath = path.join(backendDir, 'index.js');
291
245
 
292
- function loadCustomEncryptionSchema() {
293
- try {
294
- const appDefinition = readAppDefinitionForEncryption();
246
+ const backendModule = require(backendIndexPath);
247
+ const appDefinition = backendModule?.Definition;
295
248
 
296
249
  if (!appDefinition) {
297
250
  return; // No app definition found
@@ -311,21 +264,17 @@ function loadCustomEncryptionSchema() {
311
264
  registerEncryptionOptOut(disable);
312
265
  }
313
266
 
314
- // Load app-level extension encryption schemas. Before the module
315
- // schemas: loadModuleEncryptionSchemas currently throws (see the
316
- // NOTE inside it) and the throw would skip anything after it.
317
- const { loadExtensions } = require('../../extensions/extension-loader');
318
- const extensions = loadExtensions(appDefinition);
319
- if (extensions.length > 0) {
320
- loadExtensionEncryptionSchemas(extensions);
321
- }
322
-
323
267
  // Load module-level encryption schemas from integrations
324
268
  const integrations = appDefinition.integrations;
325
- if (integrations && Array.isArray(integrations)) {
326
- loadModuleEncryptionSchemas(integrations);
269
+ if (Array.isArray(integrations) || appDefinition.entities) {
270
+ loadModuleEncryptionSchemas(
271
+ Array.isArray(integrations) ? integrations : [],
272
+ appDefinition.entities
273
+ );
327
274
  }
328
275
  } catch (error) {
276
+ // A bad entity registry is a configuration error, not "no custom schema".
277
+ if (error instanceof EntityRegistryError) throw error;
329
278
  // Silently ignore errors - custom schema is optional
330
279
  // This handles cases like:
331
280
  // - Backend package.json not found (tests, standalone usage)
@@ -476,7 +425,6 @@ module.exports = {
476
425
  registerEncryptionOptOut,
477
426
  loadCustomEncryptionSchema,
478
427
  loadModuleEncryptionSchemas,
479
- loadExtensionEncryptionSchemas,
480
428
  extractCredentialFieldsFromModules,
481
429
  validateCustomSchema,
482
430
  validateOptOut,
@@ -4,8 +4,41 @@
4
4
  * Infrastructure layer service that orchestrates field-level encryption/decryption.
5
5
  * Handles nested JSON paths (e.g., 'data.access_token') and bulk operations.
6
6
  */
7
+ const { logger } = require('./logger');
8
+ const { isDeployedRuntime } = require('./encryption-config');
9
+
10
+ /**
11
+ * Thrown when a value that looks like ciphertext cannot be decrypted in a
12
+ * deployed runtime (a wrong, rotated or missing key). Returning the stored
13
+ * ciphertext would hand it to API modules as if it were a token.
14
+ */
15
+ class FieldDecryptionError extends Error {
16
+ constructor(message, options) {
17
+ super(message, options);
18
+ this.name = 'FieldDecryptionError';
19
+ }
20
+ }
21
+
22
+ // Cryptor.encrypt() returns `${keyId}:${iv}:${ciphertext}:${encryptedKey}`:
23
+ // keyId and encryptedKey are base64, iv is 16 bytes of hex, ciphertext is hex.
24
+ // Plaintext with three colons (`https://user:pw@host:443`, `a:b:c:d`) must not
25
+ // be mistaken for it.
26
+ const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/;
27
+ const IV_PART = /^[0-9a-f]{32}$/i;
28
+ const CIPHERTEXT_PART = /^(?:[0-9a-f]{2})+$/i;
29
+
7
30
  class FieldEncryptionService {
8
- constructor({ cryptor, schema }) {
31
+ /**
32
+ * @param {Object} params
33
+ * @param {Object} params.cryptor
34
+ * @param {Object} params.schema
35
+ * @param {() => boolean} [params.failClosedOnDecryptError] - whether a
36
+ * ciphertext-shaped value that fails to decrypt throws. Defaults to
37
+ * "running deployed" (encryption-config's isDeployedRuntime), read on
38
+ * each failure. Locally such a value is returned as stored, with a
39
+ * warning.
40
+ */
41
+ constructor({ cryptor, schema, failClosedOnDecryptError }) {
9
42
  if (!cryptor) {
10
43
  throw new Error('Cryptor instance required');
11
44
  }
@@ -15,6 +48,10 @@ class FieldEncryptionService {
15
48
 
16
49
  this.cryptor = cryptor;
17
50
  this.schema = schema;
51
+ this.failClosedOnDecryptError =
52
+ typeof failClosedOnDecryptError === 'function'
53
+ ? failClosedOnDecryptError
54
+ : () => isDeployedRuntime();
18
55
  }
19
56
 
20
57
  /**
@@ -97,12 +134,33 @@ class FieldEncryptionService {
97
134
  const decryptionPromises = fields.map(async (fieldPath) => {
98
135
  const value = this._getNestedValue(decrypted, fieldPath);
99
136
 
100
- if (this._isEncrypted(value)) {
137
+ if (!this._isEncrypted(value)) {
138
+ return null;
139
+ }
140
+ try {
101
141
  const decryptedValue = await this.cryptor.decrypt(value);
102
142
  const deserializedValue = this._deserializeAfterDecryption(decryptedValue);
103
143
  return { fieldPath, decryptedValue: deserializedValue };
144
+ } catch (error) {
145
+ // Deployed: a ciphertext-shaped value under a wrong, rotated or
146
+ // missing key fails the read rather than reaching an API
147
+ // module as a token. Legacy plaintext never gets here: it does
148
+ // not look like ciphertext (_isEncrypted).
149
+ if (this.failClosedOnDecryptError()) {
150
+ throw new FieldDecryptionError(
151
+ `Could not decrypt ${modelName}.${fieldPath}`,
152
+ { cause: error }
153
+ );
154
+ }
155
+ // Local: a value that looks like ciphertext but is not is read
156
+ // as it is stored. One such field must not fail the whole
157
+ // read, findMany included.
158
+ logger.warn(
159
+ `Could not decrypt ${modelName}.${fieldPath}; returning the stored value`,
160
+ { errorName: error?.name }
161
+ );
162
+ return null;
104
163
  }
105
- return null;
106
164
  });
107
165
 
108
166
  const results = await Promise.all(decryptionPromises);
@@ -152,7 +210,13 @@ class FieldEncryptionService {
152
210
  }
153
211
 
154
212
  const parts = value.split(':');
155
- return parts.length >= 4;
213
+ return (
214
+ parts.length === 4 &&
215
+ BASE64_PART.test(parts[0]) &&
216
+ IV_PART.test(parts[1]) &&
217
+ CIPHERTEXT_PART.test(parts[2]) &&
218
+ BASE64_PART.test(parts[3])
219
+ );
156
220
  }
157
221
 
158
222
  _getNestedValue(obj, path) {
@@ -251,4 +315,4 @@ class FieldEncryptionService {
251
315
  }
252
316
  }
253
317
 
254
- module.exports = { FieldEncryptionService };
318
+ module.exports = { FieldEncryptionService, FieldDecryptionError };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Test double for Cryptor output. Same shape as the real thing
3
+ * (`keyId:iv:ciphertext:encryptedKey`: base64, 16-byte hex, hex, base64) so
4
+ * FieldEncryptionService._isEncrypted accepts it, with the plaintext
5
+ * recoverable for the fake decrypt.
6
+ */
7
+ const fakeCiphertext = (plain) =>
8
+ `a2V5SWQ=:${'0'.repeat(32)}:${Buffer.from(String(plain)).toString(
9
+ 'hex'
10
+ )}:ZW5jS2V5`;
11
+
12
+ const fakePlaintext = (ciphertext) =>
13
+ Buffer.from(String(ciphertext).split(':')[2], 'hex').toString();
14
+
15
+ module.exports = { fakeCiphertext, fakePlaintext };
@@ -41,15 +41,13 @@ const prismaClientSingleton = () => {
41
41
  const paths = [
42
42
  // Lambda layer location (when using Prisma Lambda layer)
43
43
  `/opt/nodejs/node_modules/generated/prisma-${dbType}`,
44
- // __dirname-based absolute path (Netlify functions and bundlers)
45
- require('node:path').resolve(__dirname, `../generated/prisma-${dbType}`),
46
44
  // Local development location (relative to core package)
47
45
  `../generated/prisma-${dbType}`,
48
46
  ];
49
47
 
50
- for (const candidate of paths) {
48
+ for (const path of paths) {
51
49
  try {
52
- return require(candidate).PrismaClient;
50
+ return require(path).PrismaClient;
53
51
  } catch (err) {
54
52
  // Continue to next path
55
53
  }