@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.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +0 -28
- package/README.md +0 -105
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +7 -11
- package/application/commands/user-commands.js +5 -17
- package/core/Worker.js +7 -31
- package/core/create-handler.js +0 -9
- package/credential/use-cases/credential-operations-for-user.js +25 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +8 -15
- package/database/config.js +24 -48
- package/database/documentdb-utils.js +1 -6
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-config.js +31 -10
- package/database/encryption/encryption-schema-registry.js +47 -99
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/prisma.js +2 -4
- package/generated/prisma-mongodb/edge.js +4 -27
- package/generated/prisma-mongodb/index-browser.js +1 -24
- package/generated/prisma-mongodb/index.d.ts +48 -3228
- package/generated/prisma-mongodb/index.js +4 -27
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -65
- package/generated/prisma-mongodb/wasm.js +4 -27
- package/generated/prisma-postgresql/edge.js +4 -27
- package/generated/prisma-postgresql/index-browser.js +1 -24
- package/generated/prisma-postgresql/index.d.ts +51 -3503
- package/generated/prisma-postgresql/index.js +4 -27
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -61
- package/generated/prisma-postgresql/wasm.js +4 -27
- package/handlers/app-definition-loader.js +12 -103
- package/handlers/routers/auth.js +12 -40
- package/handlers/routers/health.js +2 -4
- package/handlers/routers/integration-defined-routers.js +92 -144
- package/handlers/routers/integration-webhook-routers.js +64 -102
- package/index.js +1 -63
- package/infrastructure/scheduler/index.js +6 -19
- package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
- package/integrations/index.js +0 -5
- package/integrations/integration-router.js +73 -115
- package/integrations/repositories/integration-repository-documentdb.js +3 -35
- package/integrations/repositories/integration-repository-interface.js +1 -17
- package/integrations/repositories/integration-repository-mongo.js +76 -59
- package/integrations/repositories/integration-repository-postgres.js +76 -62
- package/integrations/tests/doubles/test-integration-repository.js +1 -12
- package/integrations/use-cases/create-integration.js +20 -55
- package/integrations/use-cases/get-integrations-for-user.js +40 -80
- package/integrations/use-cases/index.js +1 -7
- package/integrations/use-cases/integration-operations-for-user.js +24 -55
- package/integrations/utils/map-integration-dto.js +1 -44
- package/management-api/management-api-config.js +2 -71
- package/management-api/meta-router.js +2 -21
- package/management-api/openapi/components.js +0 -40
- package/management-api/openapi/v2-operations.js +4 -16
- package/management-api/route-registry.js +6 -123
- package/management-api/v2/dependencies.js +15 -40
- package/management-api/v2/dto.js +0 -20
- package/management-api/v2/handlers/authorization.js +0 -1
- package/management-api/v2/handlers/credentials.js +2 -6
- package/management-api/v2/handlers/entities.js +7 -10
- package/management-api/v2/handlers/integrations.js +3 -6
- package/management-api/v2/request.js +1 -32
- package/modules/index.js +0 -8
- package/modules/module-factory.js +0 -12
- package/modules/module.js +2 -53
- package/modules/repositories/module-repository-documentdb.js +6 -51
- package/modules/repositories/module-repository-interface.js +2 -19
- package/modules/repositories/module-repository-mongo.js +74 -67
- package/modules/repositories/module-repository-postgres.js +74 -66
- package/modules/repositories/module-repository.js +1 -2
- package/modules/use-cases/entity-operations-for-user.js +13 -50
- package/modules/use-cases/entity-types.js +7 -31
- package/modules/use-cases/execute-entity-proxy-request.js +3 -50
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +13 -26
- package/modules/use-cases/get-entity-module-for-user.js +7 -34
- package/modules/use-cases/get-entity-options-by-id.js +4 -10
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +4 -10
- package/modules/use-cases/multi-step-authorization.js +13 -40
- package/modules/use-cases/process-authorization-callback.js +19 -53
- package/modules/use-cases/refresh-entity-options.js +4 -10
- package/modules/use-cases/test-module-auth.js +4 -10
- package/package.json +5 -6
- package/prisma-mongodb/schema.prisma +0 -65
- package/prisma-postgresql/schema.prisma +0 -61
- package/queues/index.js +0 -25
- package/types/index.d.ts +0 -1
- package/types/modules/index.d.ts +0 -139
- package/user/repositories/user-repository-documentdb.js +2 -27
- package/user/repositories/user-repository-interface.js +0 -18
- package/user/repositories/user-repository-mongo.js +0 -24
- package/user/repositories/user-repository-postgres.js +0 -18
- package/user/use-cases/authenticate-user.js +1 -7
- package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
- package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
- package/user/user.js +1 -64
- package/database/organization-where.js +0 -18
- package/database/unique-violation.js +0 -25
- package/extensions/bootstrap-runner.js +0 -43
- package/extensions/extension-loader.js +0 -187
- package/extensions/index.js +0 -41
- package/extensions/initialize-app.js +0 -123
- package/extensions/route-mounter.js +0 -103
- package/extensions/schema-composer.js +0 -188
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -216
- package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
- package/integrations/repositories/integration-not-found.js +0 -14
- package/integrations/schedule-config.js +0 -80
- package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
- package/integrations/use-cases/resolve-integration-ownership.js +0 -177
- package/modules/entity-scope.js +0 -15
- package/modules/repositories/entity-not-found.js +0 -18
- package/modules/scope-config.js +0 -192
- package/modules/scope-policy.js +0 -112
- package/modules/scope-resolution.js +0 -288
- package/modules/scope-visibility.js +0 -247
- package/modules/tests/doubles/ownership-matching.js +0 -18
- package/modules/use-cases/authorize-entity-for-user.js +0 -61
- package/modules/use-cases/create-entity-from-credential.js +0 -113
- package/modules/use-cases/manage-access.js +0 -21
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
- package/providers/index.js +0 -23
- package/providers/resolve-provider.js +0 -93
- package/providers/runtime-detection.js +0 -124
- package/queues/providers/index.js +0 -11
- package/queues/providers/netlify-background-provider.js +0 -150
- package/queues/providers/qstash-queue-provider.js +0 -219
- package/queues/providers/sqs-queue-provider.js +0 -70
- package/queues/queue-client-interface.js +0 -55
- package/queues/queue-message-auth.js +0 -193
- package/queues/queue-provider-factory.js +0 -101
- package/queues/queue-provider.js +0 -65
- package/queues/sqs-queue-client.js +0 -81
- package/types/user/index.d.ts +0 -134
- package/user/adopter-jwt-config.js +0 -177
- package/user/adopter-jwt-errors.js +0 -59
- package/user/adopter-token-verifier.js +0 -122
- package/user/app-user-helpers.js +0 -97
- package/user/create-user-authentication.js +0 -64
- package/user/membership.js +0 -51
- package/user/repositories/organization-membership-record.js +0 -19
- package/user/repositories/organization-membership-repository-documentdb.js +0 -106
- package/user/repositories/organization-membership-repository-factory.js +0 -42
- package/user/repositories/organization-membership-repository-interface.js +0 -57
- package/user/repositories/organization-membership-repository-mongo.js +0 -13
- package/user/repositories/organization-membership-repository-postgres.js +0 -29
- package/user/repositories/organization-membership-repository-prisma.js +0 -81
- package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
- package/user/use-cases/ensure-organization-membership.js +0 -92
- package/user/use-cases/find-individual-users-by-organization.js +0 -54
package/database/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* runtime
|
|
51
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
212
|
+
...customSchema,
|
|
213
|
+
Credential: { ...customSchema.Credential, fields },
|
|
203
214
|
};
|
|
204
215
|
|
|
205
216
|
logger.info(
|
|
206
|
-
`Registering module-level encryption for ${
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
243
|
+
const backendDir = path.dirname(backendPackagePath);
|
|
244
|
+
const backendIndexPath = path.join(backendDir, 'index.js');
|
|
291
245
|
|
|
292
|
-
|
|
293
|
-
|
|
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 (
|
|
326
|
-
loadModuleEncryptionSchemas(
|
|
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
|
-
|
|
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
|
|
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 };
|
package/database/prisma.js
CHANGED
|
@@ -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
|
|
48
|
+
for (const path of paths) {
|
|
51
49
|
try {
|
|
52
|
-
return require(
|
|
50
|
+
return require(path).PrismaClient;
|
|
53
51
|
} catch (err) {
|
|
54
52
|
// Continue to next path
|
|
55
53
|
}
|