@friggframework/core 2.0.0--canary.671.e50fe9c.0 → 2.0.0--canary.680.b3ca7f1.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/application/commands/integration-commands.js +18 -8
- package/application/index.js +6 -2
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-schema-registry.js +35 -12
- package/database/encryption/field-encryption-service.js +30 -3
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/handlers/app-definition-loader.js +17 -8
- package/handlers/backend-utils.js +31 -25
- package/handlers/routers/health.js +1 -4
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +4 -2
- package/integrations/index.js +0 -4
- package/integrations/integration-router.js +16 -23
- package/integrations/utils/map-integration-dto.js +1 -15
- package/management-api/openapi/components.js +7 -1
- package/management-api/openapi/v2-operations.js +4 -0
- package/management-api/v2/dependencies.js +22 -7
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +6 -0
- package/modules/use-cases/entity-types.js +36 -12
- package/package.json +5 -5
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
# Unreleased
|
|
2
|
+
|
|
3
|
+
#### Behaviour change: module credential fields are now encrypted
|
|
4
|
+
|
|
5
|
+
- `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059).
|
|
6
|
+
- Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored.
|
|
7
|
+
- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value that fails to decrypt is returned as stored with a warning instead of failing the whole read.
|
|
8
|
+
- An app's own `encryption.schema` is no longer replaced when module fields are added to it.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
1
12
|
# v1.2.2 (Fri Aug 09 2024)
|
|
2
13
|
|
|
3
14
|
#### 🐛 Bug Fix
|
|
@@ -30,8 +30,9 @@ const {
|
|
|
30
30
|
PatchIntegrationConfig,
|
|
31
31
|
} = require('../../integrations/use-cases/patch-integration-config');
|
|
32
32
|
const {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
getEntityTypeRegistry,
|
|
34
|
+
getEntityTypeDefinitions,
|
|
35
|
+
} = require('../../modules/entity-type-registry');
|
|
35
36
|
|
|
36
37
|
const ERROR_CODE_MAP = {
|
|
37
38
|
ENTITY_NOT_FOUND: 401,
|
|
@@ -51,7 +52,16 @@ function mapErrorToResponse(error) {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* @param {Object} [params]
|
|
57
|
+
* @param {Function} [params.integrationClass]
|
|
58
|
+
* @param {Array<object>} [params.moduleDefinitions] - the app's offered module
|
|
59
|
+
* definitions (`loadAppDefinition().moduleDefinitions`). Integration code
|
|
60
|
+
* calls this from a constructor with no app definition in hand, so when it
|
|
61
|
+
* is omitted the commands hydrate with this integration's own modules only
|
|
62
|
+
* (a registry built from `{ integrations: [integrationClass] }`).
|
|
63
|
+
*/
|
|
64
|
+
function createIntegrationCommands({ integrationClass, moduleDefinitions } = {}) {
|
|
55
65
|
// Always use Frigg's default repositories and use cases
|
|
56
66
|
const integrationRepository = createIntegrationRepository();
|
|
57
67
|
|
|
@@ -108,13 +118,13 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
108
118
|
|
|
109
119
|
const moduleRepository = createModuleRepository();
|
|
110
120
|
|
|
111
|
-
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([
|
|
112
|
-
integrationClass,
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
121
|
const moduleFactory = new ModuleFactory({
|
|
116
122
|
moduleRepository,
|
|
117
|
-
moduleDefinitions
|
|
123
|
+
moduleDefinitions:
|
|
124
|
+
moduleDefinitions ??
|
|
125
|
+
getEntityTypeDefinitions(
|
|
126
|
+
getEntityTypeRegistry({ integrations: [integrationClass] })
|
|
127
|
+
),
|
|
118
128
|
});
|
|
119
129
|
|
|
120
130
|
const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({
|
package/application/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const { createUsageCommands } = require('./commands/usage-commands');
|
|
|
17
17
|
*
|
|
18
18
|
* @param {Object} params
|
|
19
19
|
* @param {Object} params.integrationClass - Integration class (required)
|
|
20
|
+
* @param {Array<object>} [params.moduleDefinitions] - the app's offered module definitions; defaults to the integration's own modules
|
|
20
21
|
* @returns {Object} Unified commands object with all CRUD operations
|
|
21
22
|
*
|
|
22
23
|
* @example
|
|
@@ -24,9 +25,12 @@ const { createUsageCommands } = require('./commands/usage-commands');
|
|
|
24
25
|
* const user = await commands.createUser({ username: 'user@example.com' });
|
|
25
26
|
* const credential = await commands.createCredential({ userId: user.id, ... });
|
|
26
27
|
*/
|
|
27
|
-
function createFriggCommands({ integrationClass }) {
|
|
28
|
+
function createFriggCommands({ integrationClass, moduleDefinitions }) {
|
|
28
29
|
// All commands use Frigg's default repositories and use cases
|
|
29
|
-
const integrationCommands = createIntegrationCommands({
|
|
30
|
+
const integrationCommands = createIntegrationCommands({
|
|
31
|
+
integrationClass,
|
|
32
|
+
moduleDefinitions,
|
|
33
|
+
});
|
|
30
34
|
|
|
31
35
|
const userCommands = createUserCommands();
|
|
32
36
|
|
|
@@ -428,6 +428,8 @@ Format: "keyId:encryptedText:encryptedKey"
|
|
|
428
428
|
Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey"
|
|
429
429
|
```
|
|
430
430
|
|
|
431
|
+
**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 is returned as stored and logged as a warning, so one bad field never fails 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.
|
|
432
|
+
|
|
431
433
|
**Why Envelope Encryption?**
|
|
432
434
|
|
|
433
435
|
- Reduces KMS API calls (one DEK per field, cached)
|
|
@@ -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,27 +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
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
const moduleDefinitions = getEntityTypeDefinitions(
|
|
192
|
+
getEntityTypeRegistry({ integrations, entities })
|
|
193
|
+
);
|
|
186
194
|
const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
|
|
187
195
|
|
|
188
|
-
|
|
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) {
|
|
189
208
|
return;
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
const moduleSchema = {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
212
|
+
...customSchema,
|
|
213
|
+
Credential: { ...customSchema.Credential, fields },
|
|
196
214
|
};
|
|
197
215
|
|
|
198
216
|
logger.info(
|
|
199
|
-
`Registering module-level encryption for ${
|
|
217
|
+
`Registering module-level encryption for ${fields.length} credential fields`
|
|
200
218
|
);
|
|
201
219
|
|
|
202
220
|
registerCustomSchema(moduleSchema);
|
|
@@ -248,10 +266,15 @@ function loadCustomEncryptionSchema() {
|
|
|
248
266
|
|
|
249
267
|
// Load module-level encryption schemas from integrations
|
|
250
268
|
const integrations = appDefinition.integrations;
|
|
251
|
-
if (
|
|
252
|
-
loadModuleEncryptionSchemas(
|
|
269
|
+
if (Array.isArray(integrations) || appDefinition.entities) {
|
|
270
|
+
loadModuleEncryptionSchemas(
|
|
271
|
+
Array.isArray(integrations) ? integrations : [],
|
|
272
|
+
appDefinition.entities
|
|
273
|
+
);
|
|
253
274
|
}
|
|
254
275
|
} catch (error) {
|
|
276
|
+
// A bad entity registry is a configuration error, not "no custom schema".
|
|
277
|
+
if (error instanceof EntityRegistryError) throw error;
|
|
255
278
|
// Silently ignore errors - custom schema is optional
|
|
256
279
|
// This handles cases like:
|
|
257
280
|
// - Backend package.json not found (tests, standalone usage)
|
|
@@ -4,6 +4,16 @@
|
|
|
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
|
+
|
|
9
|
+
// Cryptor.encrypt() returns `${keyId}:${iv}:${ciphertext}:${encryptedKey}`:
|
|
10
|
+
// keyId and encryptedKey are base64, iv is 16 bytes of hex, ciphertext is hex.
|
|
11
|
+
// Plaintext with three colons (`https://user:pw@host:443`, `a:b:c:d`) must not
|
|
12
|
+
// be mistaken for it.
|
|
13
|
+
const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
14
|
+
const IV_PART = /^[0-9a-f]{32}$/i;
|
|
15
|
+
const CIPHERTEXT_PART = /^(?:[0-9a-f]{2})+$/i;
|
|
16
|
+
|
|
7
17
|
class FieldEncryptionService {
|
|
8
18
|
constructor({ cryptor, schema }) {
|
|
9
19
|
if (!cryptor) {
|
|
@@ -97,12 +107,23 @@ class FieldEncryptionService {
|
|
|
97
107
|
const decryptionPromises = fields.map(async (fieldPath) => {
|
|
98
108
|
const value = this._getNestedValue(decrypted, fieldPath);
|
|
99
109
|
|
|
100
|
-
if (this._isEncrypted(value)) {
|
|
110
|
+
if (!this._isEncrypted(value)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
101
114
|
const decryptedValue = await this.cryptor.decrypt(value);
|
|
102
115
|
const deserializedValue = this._deserializeAfterDecryption(decryptedValue);
|
|
103
116
|
return { fieldPath, decryptedValue: deserializedValue };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// A value that looks like ciphertext but is not (or was written
|
|
119
|
+
// before the field was encrypted) is read as it is stored. One
|
|
120
|
+
// such field must not fail the whole read, findMany included.
|
|
121
|
+
logger.warn(
|
|
122
|
+
`Could not decrypt ${modelName}.${fieldPath}; returning the stored value`,
|
|
123
|
+
{ errorName: error?.name }
|
|
124
|
+
);
|
|
125
|
+
return null;
|
|
104
126
|
}
|
|
105
|
-
return null;
|
|
106
127
|
});
|
|
107
128
|
|
|
108
129
|
const results = await Promise.all(decryptionPromises);
|
|
@@ -152,7 +173,13 @@ class FieldEncryptionService {
|
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
const parts = value.split(':');
|
|
155
|
-
return
|
|
176
|
+
return (
|
|
177
|
+
parts.length === 4 &&
|
|
178
|
+
BASE64_PART.test(parts[0]) &&
|
|
179
|
+
IV_PART.test(parts[1]) &&
|
|
180
|
+
CIPHERTEXT_PART.test(parts[2]) &&
|
|
181
|
+
BASE64_PART.test(parts[3])
|
|
182
|
+
);
|
|
156
183
|
}
|
|
157
184
|
|
|
158
185
|
_getNestedValue(obj, path) {
|
|
@@ -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 };
|
|
@@ -7,16 +7,15 @@ const {
|
|
|
7
7
|
extractCredentialFieldsFromModules,
|
|
8
8
|
} = require('../database/encryption/encryption-schema-registry');
|
|
9
9
|
const {
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
getEntityTypeRegistry,
|
|
11
|
+
getEntityTypeDefinitions,
|
|
12
|
+
} = require('../modules/entity-type-registry');
|
|
12
13
|
|
|
13
14
|
// Handlers without a database never load the encryption registry, so the
|
|
14
15
|
// credential leaf keys are registered for redaction here too.
|
|
15
|
-
function registerCredentialLogKeys(appDefinition,
|
|
16
|
+
function registerCredentialLogKeys(appDefinition, moduleDefinitions) {
|
|
16
17
|
try {
|
|
17
|
-
extractCredentialFieldsFromModules(
|
|
18
|
-
getModulesDefinitionFromIntegrationClasses(integrations)
|
|
19
|
-
);
|
|
18
|
+
extractCredentialFieldsFromModules(moduleDefinitions);
|
|
20
19
|
} catch {
|
|
21
20
|
// An odd integration shape must not stop the app from loading.
|
|
22
21
|
}
|
|
@@ -33,7 +32,7 @@ function registerCredentialLogKeys(appDefinition, integrations) {
|
|
|
33
32
|
* @function loadAppDefinition
|
|
34
33
|
* @description Searches for the nearest backend package.json, loads the corresponding index.js file,
|
|
35
34
|
* and extracts the application definition containing integrations and user configuration.
|
|
36
|
-
* @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition.
|
|
35
|
+
* @returns {{integrations: Array<object>, entities: object | null, entityTypeRegistry: Map<string, object>, moduleDefinitions: Array<object>, userConfig: object | null, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition.
|
|
37
36
|
* @throws {Error} Throws error if backend package.json cannot be found.
|
|
38
37
|
* @throws {Error} Throws error if index.js file cannot be found in the backend directory.
|
|
39
38
|
* @example
|
|
@@ -57,6 +56,7 @@ function loadAppDefinition() {
|
|
|
57
56
|
|
|
58
57
|
const {
|
|
59
58
|
integrations = [],
|
|
59
|
+
entities = null,
|
|
60
60
|
user: userConfig = null,
|
|
61
61
|
adminScripts = [],
|
|
62
62
|
reports = [],
|
|
@@ -65,7 +65,13 @@ function loadAppDefinition() {
|
|
|
65
65
|
managementApi = {},
|
|
66
66
|
} = appDefinition;
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// Fail fast: a bad `entities` registry (key/name mismatch, conflicting
|
|
69
|
+
// definitions, `$infer: false` with an unregistered module) must stop the
|
|
70
|
+
// app at load, not on the first request that needs a module.
|
|
71
|
+
const entityTypeRegistry = getEntityTypeRegistry(appDefinition);
|
|
72
|
+
const moduleDefinitions = getEntityTypeDefinitions(entityTypeRegistry);
|
|
73
|
+
|
|
74
|
+
registerCredentialLogKeys(appDefinition, moduleDefinitions);
|
|
69
75
|
|
|
70
76
|
// Degrade consistently: an invalid telemetry block must never take down a
|
|
71
77
|
// router bundle that loads the app definition at module scope (telemetry is
|
|
@@ -90,6 +96,9 @@ function loadAppDefinition() {
|
|
|
90
96
|
|
|
91
97
|
return {
|
|
92
98
|
integrations,
|
|
99
|
+
entities,
|
|
100
|
+
entityTypeRegistry,
|
|
101
|
+
moduleDefinitions,
|
|
93
102
|
userConfig,
|
|
94
103
|
adminScripts,
|
|
95
104
|
reports,
|
|
@@ -18,8 +18,9 @@ const {
|
|
|
18
18
|
createModuleRepository,
|
|
19
19
|
} = require('../modules/repositories/module-repository-factory');
|
|
20
20
|
const {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
getEntityTypeRegistry,
|
|
22
|
+
getEntityTypeDefinitions,
|
|
23
|
+
} = require('../modules/entity-type-registry');
|
|
23
24
|
const { processNotFound } = require('../integrations/use-cases/process-errors');
|
|
24
25
|
|
|
25
26
|
const loadRouterFromObject = (IntegrationClass, routerObject) => {
|
|
@@ -61,30 +62,18 @@ const initializeRepositories = () => {
|
|
|
61
62
|
return { processRepository, integrationRepository, moduleRepository };
|
|
62
63
|
};
|
|
63
64
|
|
|
64
|
-
const createModuleFactoryWithDefinitions = (
|
|
65
|
-
moduleRepository,
|
|
66
|
-
integrationClasses
|
|
67
|
-
) => {
|
|
68
|
-
const moduleDefinitions =
|
|
69
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses);
|
|
70
|
-
|
|
71
|
-
return new ModuleFactory({
|
|
72
|
-
moduleRepository,
|
|
73
|
-
moduleDefinitions,
|
|
74
|
-
});
|
|
75
|
-
};
|
|
76
|
-
|
|
77
65
|
const loadIntegrationForWebhook = async (integrationId) => {
|
|
78
66
|
const { loadAppDefinition } = require('./app-definition-loader');
|
|
79
|
-
const { integrations: integrationClasses } =
|
|
67
|
+
const { integrations: integrationClasses, moduleDefinitions } =
|
|
68
|
+
loadAppDefinition();
|
|
80
69
|
|
|
81
70
|
const { integrationRepository, moduleRepository } =
|
|
82
71
|
initializeRepositories();
|
|
83
72
|
|
|
84
|
-
const moduleFactory =
|
|
73
|
+
const moduleFactory = new ModuleFactory({
|
|
85
74
|
moduleRepository,
|
|
86
|
-
|
|
87
|
-
);
|
|
75
|
+
moduleDefinitions,
|
|
76
|
+
});
|
|
88
77
|
|
|
89
78
|
const getIntegrationInstance = new GetIntegrationInstance({
|
|
90
79
|
integrationRepository,
|
|
@@ -129,13 +118,23 @@ const integrationExists = async (integrationId) => {
|
|
|
129
118
|
}
|
|
130
119
|
};
|
|
131
120
|
|
|
132
|
-
const loadIntegrationForProcess = async (
|
|
121
|
+
const loadIntegrationForProcess = async (
|
|
122
|
+
processId,
|
|
123
|
+
integrationClass,
|
|
124
|
+
moduleDefinitions
|
|
125
|
+
) => {
|
|
133
126
|
const { processRepository, integrationRepository, moduleRepository } =
|
|
134
127
|
initializeRepositories();
|
|
135
128
|
|
|
136
|
-
const moduleFactory =
|
|
137
|
-
|
|
138
|
-
|
|
129
|
+
const moduleFactory = new ModuleFactory({
|
|
130
|
+
moduleRepository,
|
|
131
|
+
moduleDefinitions:
|
|
132
|
+
moduleDefinitions ??
|
|
133
|
+
// No app definition handed in: this integration's own modules only.
|
|
134
|
+
getEntityTypeDefinitions(
|
|
135
|
+
getEntityTypeRegistry({ integrations: [integrationClass] })
|
|
136
|
+
),
|
|
137
|
+
});
|
|
139
138
|
|
|
140
139
|
const getIntegrationInstance = new GetIntegrationInstance({
|
|
141
140
|
integrationRepository,
|
|
@@ -161,7 +160,13 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
|
|
|
161
160
|
return instance;
|
|
162
161
|
};
|
|
163
162
|
|
|
164
|
-
|
|
163
|
+
/**
|
|
164
|
+
* @param {Function} integrationClass
|
|
165
|
+
* @param {{moduleDefinitions?: Array<object>}} [options] - the app's offered
|
|
166
|
+
* module definitions (`loadAppDefinition().moduleDefinitions`). When omitted
|
|
167
|
+
* the worker hydrates with this integration's own modules only.
|
|
168
|
+
*/
|
|
169
|
+
const createQueueWorker = (integrationClass, { moduleDefinitions } = {}) => {
|
|
165
170
|
const integrationName = integrationClass.Definition.name;
|
|
166
171
|
|
|
167
172
|
class QueueWorker extends Worker {
|
|
@@ -183,7 +188,8 @@ const createQueueWorker = (integrationClass) => {
|
|
|
183
188
|
console.log(`[QueueWorker] hydrating by processId`, logCtx);
|
|
184
189
|
integrationInstance = await loadIntegrationForProcess(
|
|
185
190
|
params.data.processId,
|
|
186
|
-
integrationClass
|
|
191
|
+
integrationClass,
|
|
192
|
+
moduleDefinitions
|
|
187
193
|
);
|
|
188
194
|
console.log(`[QueueWorker] hydrated`, {
|
|
189
195
|
...logCtx,
|
|
@@ -2,9 +2,6 @@ const { Router } = require('express');
|
|
|
2
2
|
const { createAppHandler } = require('./../app-handler-helpers');
|
|
3
3
|
const { loadAppDefinition } = require('./../app-definition-loader');
|
|
4
4
|
const { ModuleFactory } = require('../../modules/module-factory');
|
|
5
|
-
const {
|
|
6
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
7
|
-
} = require('../../integrations/utils/map-integration-dto');
|
|
8
5
|
const {
|
|
9
6
|
createModuleRepository,
|
|
10
7
|
} = require('../../modules/repositories/module-repository-factory');
|
|
@@ -45,7 +42,7 @@ try {
|
|
|
45
42
|
integrationClasses = appDef.integrations || [];
|
|
46
43
|
|
|
47
44
|
const moduleRepository = createModuleRepository();
|
|
48
|
-
const moduleDefinitions =
|
|
45
|
+
const { moduleDefinitions } = appDef;
|
|
49
46
|
|
|
50
47
|
moduleFactory = new ModuleFactory({
|
|
51
48
|
moduleRepository,
|
|
@@ -10,10 +10,13 @@ const { createQueueWorker } = require('../backend-utils');
|
|
|
10
10
|
// const { getExtensionWorkers } = require('../../integrations/extension');
|
|
11
11
|
|
|
12
12
|
const handlers = {};
|
|
13
|
-
const { integrations: integrationClasses } =
|
|
13
|
+
const { integrations: integrationClasses, moduleDefinitions } =
|
|
14
|
+
loadAppDefinition();
|
|
14
15
|
|
|
15
16
|
integrationClasses.forEach((IntegrationClass) => {
|
|
16
|
-
const defaultQueueWorker = createQueueWorker(IntegrationClass
|
|
17
|
+
const defaultQueueWorker = createQueueWorker(IntegrationClass, {
|
|
18
|
+
moduleDefinitions,
|
|
19
|
+
});
|
|
17
20
|
|
|
18
21
|
handlers[`${IntegrationClass.Definition.name}`] = {
|
|
19
22
|
queueWorker: createHandler({
|
package/index.js
CHANGED
|
@@ -63,7 +63,6 @@ const {
|
|
|
63
63
|
Options,
|
|
64
64
|
createIntegrationRouter,
|
|
65
65
|
checkRequiredParams,
|
|
66
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
67
66
|
LoadIntegrationContextUseCase,
|
|
68
67
|
} = require('./integrations/index');
|
|
69
68
|
const {
|
|
@@ -99,6 +98,8 @@ const {
|
|
|
99
98
|
Requester,
|
|
100
99
|
ModuleConstants,
|
|
101
100
|
ModuleFactory,
|
|
101
|
+
getEntityTypeRegistry,
|
|
102
|
+
EntityRegistryError,
|
|
102
103
|
} = require('./modules/index');
|
|
103
104
|
const application = require('./application');
|
|
104
105
|
const utils = require('./utils');
|
|
@@ -150,7 +151,6 @@ module.exports = {
|
|
|
150
151
|
Options,
|
|
151
152
|
checkRequiredParams,
|
|
152
153
|
createIntegrationRouter,
|
|
153
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
154
154
|
LoadIntegrationContextUseCase,
|
|
155
155
|
CreateProcess,
|
|
156
156
|
UpdateProcessState,
|
|
@@ -206,6 +206,8 @@ module.exports = {
|
|
|
206
206
|
Requester,
|
|
207
207
|
ModuleConstants,
|
|
208
208
|
ModuleFactory,
|
|
209
|
+
getEntityTypeRegistry,
|
|
210
|
+
EntityRegistryError,
|
|
209
211
|
// queues
|
|
210
212
|
QueuerUtil,
|
|
211
213
|
|
package/integrations/index.js
CHANGED
|
@@ -4,9 +4,6 @@ const {
|
|
|
4
4
|
createIntegrationRouter,
|
|
5
5
|
checkRequiredParams,
|
|
6
6
|
} = require('./integration-router');
|
|
7
|
-
const {
|
|
8
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
9
|
-
} = require('./utils/map-integration-dto');
|
|
10
7
|
const {
|
|
11
8
|
LoadIntegrationContextUseCase,
|
|
12
9
|
} = require('./use-cases/load-integration-context');
|
|
@@ -21,7 +18,6 @@ module.exports = {
|
|
|
21
18
|
Options,
|
|
22
19
|
createIntegrationRouter,
|
|
23
20
|
checkRequiredParams,
|
|
24
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
25
21
|
LoadIntegrationContextUseCase,
|
|
26
22
|
validateExtensionBinding,
|
|
27
23
|
getExtensionRoutes,
|
|
@@ -31,9 +31,6 @@ const {
|
|
|
31
31
|
GetIntegrationInstance,
|
|
32
32
|
} = require('./use-cases/get-integration-instance');
|
|
33
33
|
const { UpdateIntegration } = require('./use-cases/update-integration');
|
|
34
|
-
const {
|
|
35
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
36
|
-
} = require('./utils/map-integration-dto');
|
|
37
34
|
const {
|
|
38
35
|
GetModuleInstanceFromType,
|
|
39
36
|
} = require('../modules/use-cases/get-module-instance-from-type');
|
|
@@ -74,8 +71,13 @@ const {
|
|
|
74
71
|
const log = getLogger('frigg.integrations');
|
|
75
72
|
|
|
76
73
|
function createIntegrationRouter() {
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// moduleDefinitions is the app registry's definitions (ADR-059), so
|
|
75
|
+
// /api/authorize and friends accept a type no integration binds.
|
|
76
|
+
const {
|
|
77
|
+
integrations: integrationClasses,
|
|
78
|
+
userConfig,
|
|
79
|
+
moduleDefinitions,
|
|
80
|
+
} = loadAppDefinition();
|
|
79
81
|
const moduleRepository = createModuleRepository();
|
|
80
82
|
const integrationRepository = createIntegrationRepository();
|
|
81
83
|
const credentialRepository = createCredentialRepository();
|
|
@@ -108,8 +110,7 @@ function createIntegrationRouter() {
|
|
|
108
110
|
|
|
109
111
|
const moduleFactory = new ModuleFactory({
|
|
110
112
|
moduleRepository,
|
|
111
|
-
moduleDefinitions
|
|
112
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
113
|
+
moduleDefinitions,
|
|
113
114
|
});
|
|
114
115
|
const deleteIntegrationForUser = new DeleteIntegrationForUser({
|
|
115
116
|
integrationRepository,
|
|
@@ -136,8 +137,7 @@ function createIntegrationRouter() {
|
|
|
136
137
|
|
|
137
138
|
const getEntitiesForUser = new GetEntitiesForUser({
|
|
138
139
|
moduleRepository,
|
|
139
|
-
moduleDefinitions
|
|
140
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
140
|
+
moduleDefinitions,
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
const getIntegrationInstance = new GetIntegrationInstance({
|
|
@@ -153,37 +153,31 @@ function createIntegrationRouter() {
|
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
const getModuleInstanceFromType = new GetModuleInstanceFromType({
|
|
156
|
-
moduleDefinitions
|
|
157
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
156
|
+
moduleDefinitions,
|
|
158
157
|
});
|
|
159
158
|
|
|
160
159
|
const getEntityOptionsByType = new GetEntityOptionsByType({
|
|
161
|
-
moduleDefinitions
|
|
162
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
160
|
+
moduleDefinitions,
|
|
163
161
|
});
|
|
164
162
|
|
|
165
163
|
const testModuleAuth = new TestModuleAuth({
|
|
166
164
|
moduleRepository,
|
|
167
|
-
moduleDefinitions
|
|
168
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
165
|
+
moduleDefinitions,
|
|
169
166
|
});
|
|
170
167
|
|
|
171
168
|
const getModule = new GetModule({
|
|
172
169
|
moduleRepository,
|
|
173
|
-
moduleDefinitions
|
|
174
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
170
|
+
moduleDefinitions,
|
|
175
171
|
});
|
|
176
172
|
|
|
177
173
|
const getEntityOptionsById = new GetEntityOptionsById({
|
|
178
174
|
moduleRepository,
|
|
179
|
-
moduleDefinitions
|
|
180
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
175
|
+
moduleDefinitions,
|
|
181
176
|
});
|
|
182
177
|
|
|
183
178
|
const refreshEntityOptions = new RefreshEntityOptions({
|
|
184
179
|
moduleRepository,
|
|
185
|
-
moduleDefinitions
|
|
186
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
180
|
+
moduleDefinitions,
|
|
187
181
|
});
|
|
188
182
|
|
|
189
183
|
const getPossibleIntegrations = new GetPossibleIntegrations({
|
|
@@ -194,8 +188,7 @@ function createIntegrationRouter() {
|
|
|
194
188
|
moduleRepository,
|
|
195
189
|
credentialRepository,
|
|
196
190
|
integrationRepository,
|
|
197
|
-
moduleDefinitions
|
|
198
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
191
|
+
moduleDefinitions,
|
|
199
192
|
});
|
|
200
193
|
|
|
201
194
|
const router = express();
|
|
@@ -20,18 +20,4 @@ function mapIntegrationClassToIntegrationDTO(integration) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
return [
|
|
25
|
-
...new Set(
|
|
26
|
-
integrationClasses
|
|
27
|
-
.map((integration) =>
|
|
28
|
-
Object.values(integration.Definition.modules).map(
|
|
29
|
-
(module) => module.definition
|
|
30
|
-
)
|
|
31
|
-
)
|
|
32
|
-
.flat()
|
|
33
|
-
),
|
|
34
|
-
];
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
module.exports = { mapIntegrationClassToIntegrationDTO, getModulesDefinitionFromIntegrationClasses };
|
|
23
|
+
module.exports = { mapIntegrationClassToIntegrationDTO };
|
|
@@ -138,11 +138,17 @@ const SCHEMAS = {
|
|
|
138
138
|
},
|
|
139
139
|
EntityType: {
|
|
140
140
|
type: 'object',
|
|
141
|
-
required: ['type', 'name', 'authType', 'isMultiStep', 'stepCount'],
|
|
141
|
+
required: ['type', 'name', 'authType', 'isMultiStep', 'stepCount', 'usedBy'],
|
|
142
142
|
properties: {
|
|
143
143
|
type: { type: 'string' },
|
|
144
144
|
name: { type: 'string' },
|
|
145
145
|
description: { type: 'string' },
|
|
146
|
+
usedBy: {
|
|
147
|
+
type: 'array',
|
|
148
|
+
items: { type: 'string' },
|
|
149
|
+
description:
|
|
150
|
+
'Names of the integrations that bind this entity type. Empty for a type registered only in appDefinition.entities.',
|
|
151
|
+
},
|
|
146
152
|
authType: { type: 'string', description: 'oauth2, oauth1, api-key, basic or custom' },
|
|
147
153
|
isMultiStep: { type: 'boolean' },
|
|
148
154
|
stepCount: { type: 'integer', minimum: 1 },
|
|
@@ -112,10 +112,14 @@ const V2_OPERATIONS = {
|
|
|
112
112
|
},
|
|
113
113
|
listEntityTypes: {
|
|
114
114
|
summary: 'List the entity types (API modules) this app can connect',
|
|
115
|
+
description:
|
|
116
|
+
'Every type registered in appDefinition.entities plus every module a declared integration binds. `usedBy` lists the integrations that bind each type; it is empty for a type registered without any integration.',
|
|
115
117
|
responses: { 200: ok('Entity types', ref('ListEntityTypesResponse')), ...errors('Unauthorized') },
|
|
116
118
|
},
|
|
117
119
|
getEntityType: {
|
|
118
120
|
summary: 'Describe an entity type',
|
|
121
|
+
description:
|
|
122
|
+
'Includes `usedBy`, the integrations that bind the type (empty for a registry-only type).',
|
|
119
123
|
responses: { 200: ok('Entity type', ref('EntityType')), ...errors('Unauthorized', 'NotFound') },
|
|
120
124
|
},
|
|
121
125
|
getEntityTypeRequirements: {
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
* Wires the repositories and use cases behind Management API v2. Kept apart
|
|
3
3
|
* from the router so tests can hand the router doubles instead.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* @param {Object} params
|
|
7
|
+
* @param {Object} params.appDefinition - the loaded app definition. Its
|
|
8
|
+
* `entityTypeRegistry` (built once by loadAppDefinition) is used.
|
|
9
|
+
* @param {Object} params.config
|
|
10
|
+
* @param {Map} [params.entityTypeRegistry] - overrides the registry on the
|
|
11
|
+
* app definition. When neither exists (a raw definition in a test), it is
|
|
12
|
+
* built here from the definition.
|
|
13
|
+
*/
|
|
14
|
+
function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
6
15
|
const {
|
|
7
16
|
createIntegrationRepository,
|
|
8
17
|
} = require('../../integrations/repositories/integration-repository-factory');
|
|
@@ -17,8 +26,9 @@ function buildV2Dependencies({ appDefinition, config }) {
|
|
|
17
26
|
} = require('../../user/repositories/user-repository-factory');
|
|
18
27
|
const { ModuleFactory } = require('../../modules/module-factory');
|
|
19
28
|
const {
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
getEntityTypeRegistry,
|
|
30
|
+
getEntityTypeDefinitions,
|
|
31
|
+
} = require('../../modules/entity-type-registry');
|
|
22
32
|
const { GetUserFromBearerToken } = require('../../user/use-cases/get-user-from-bearer-token');
|
|
23
33
|
const { GetUserFromXFriggHeaders } = require('../../user/use-cases/get-user-from-x-frigg-headers');
|
|
24
34
|
const { GetUserFromAdopterJwt } = require('../../user/use-cases/get-user-from-adopter-jwt');
|
|
@@ -70,8 +80,13 @@ function buildV2Dependencies({ appDefinition, config }) {
|
|
|
70
80
|
|
|
71
81
|
const integrationClasses = appDefinition.integrations || [];
|
|
72
82
|
const userConfig = appDefinition.userConfig || {};
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
// The registry holds entity types registered on the app as well as those
|
|
84
|
+
// integrations bind (ADR-059), so v2 authorize/entities accept both.
|
|
85
|
+
const registry =
|
|
86
|
+
entityTypeRegistry ??
|
|
87
|
+
appDefinition.entityTypeRegistry ??
|
|
88
|
+
getEntityTypeRegistry(appDefinition);
|
|
89
|
+
const moduleDefinitions = getEntityTypeDefinitions(registry);
|
|
75
90
|
|
|
76
91
|
const moduleRepository = createModuleRepository();
|
|
77
92
|
const integrationRepository = createIntegrationRepository();
|
|
@@ -200,8 +215,8 @@ function buildV2Dependencies({ appDefinition, config }) {
|
|
|
200
215
|
refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({
|
|
201
216
|
getEntityModuleForUser,
|
|
202
217
|
}),
|
|
203
|
-
listEntityTypes: new ListEntityTypes({
|
|
204
|
-
getEntityType: new GetEntityType({
|
|
218
|
+
listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }),
|
|
219
|
+
getEntityType: new GetEntityType({ entityTypeRegistry: registry }),
|
|
205
220
|
getAuthorizationRequirements,
|
|
206
221
|
|
|
207
222
|
// Credentials
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-level entity type registry (ADR-059).
|
|
3
|
+
*
|
|
4
|
+
* Pure: reads an app definition, does no I/O. It answers "which entity types
|
|
5
|
+
* (API modules) can this app connect?" from two sources:
|
|
6
|
+
*
|
|
7
|
+
* 1. `appDefinition.entities`, an object keyed by entity type name whose
|
|
8
|
+
* values are `{ definition, scope?, proxy?, display? }`.
|
|
9
|
+
* 2. Inference: every module bound by `appDefinition.integrations[*]
|
|
10
|
+
* .Definition.modules` (skipped when `entities.$infer === false`).
|
|
11
|
+
*
|
|
12
|
+
* `loadAppDefinition()` builds the registry once, at load, and returns it as
|
|
13
|
+
* `entityTypeRegistry` / `moduleDefinitions`; every consumer that holds a
|
|
14
|
+
* loaded app definition reads those instead of rebuilding.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { BaseError } = require('../errors/base-error');
|
|
18
|
+
const { getLogger } = require('../logs');
|
|
19
|
+
|
|
20
|
+
const log = getLogger('frigg.modules.entity_registry');
|
|
21
|
+
|
|
22
|
+
const INFER_KEY = '$infer';
|
|
23
|
+
const UNNAMED = '(unnamed definition)';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Raised for an invalid `entities` registry. `code` is always
|
|
27
|
+
* `ENTITY_REGISTRY_INVALID`, so callers can tell it from other load errors.
|
|
28
|
+
*/
|
|
29
|
+
class EntityRegistryError extends BaseError {
|
|
30
|
+
constructor(message, options) {
|
|
31
|
+
super(message, options);
|
|
32
|
+
this.code = 'ENTITY_REGISTRY_INVALID';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isPlainObject = (value) =>
|
|
37
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
38
|
+
|
|
39
|
+
const appSource = () => ({ kind: 'app' });
|
|
40
|
+
const integrationSource = (integrationClass) => ({
|
|
41
|
+
kind: 'integration',
|
|
42
|
+
name: integrationClass?.Definition?.name || 'unnamed',
|
|
43
|
+
});
|
|
44
|
+
const describeSource = (source) =>
|
|
45
|
+
source.kind === 'app' ? 'app' : `integration:${source.name}`;
|
|
46
|
+
|
|
47
|
+
const safeGetName = (definition) => {
|
|
48
|
+
if (typeof definition.getName !== 'function') return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const name = definition.getName();
|
|
51
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const moduleNameOf = (definition) =>
|
|
58
|
+
typeof definition?.moduleName === 'string' && definition.moduleName
|
|
59
|
+
? definition.moduleName
|
|
60
|
+
: undefined;
|
|
61
|
+
|
|
62
|
+
/** The name an entity type is known by: moduleName, else getName(). */
|
|
63
|
+
function entityTypeNameOf(definition) {
|
|
64
|
+
if (!definition) return undefined;
|
|
65
|
+
return moduleNameOf(definition) ?? safeGetName(definition);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Same object, or the same package name and version when both can be read. */
|
|
69
|
+
function isSameDefinition(a, b) {
|
|
70
|
+
if (a === b) return true;
|
|
71
|
+
const packageId = (definition) => {
|
|
72
|
+
const { packageName, version } = definition || {};
|
|
73
|
+
return typeof packageName === 'string' &&
|
|
74
|
+
packageName &&
|
|
75
|
+
typeof version === 'string' &&
|
|
76
|
+
version
|
|
77
|
+
? `${packageName}@${version}`
|
|
78
|
+
: null;
|
|
79
|
+
};
|
|
80
|
+
const left = packageId(a);
|
|
81
|
+
return left !== null && left === packageId(b);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function conflictError(name, existing, source) {
|
|
85
|
+
return new EntityRegistryError(
|
|
86
|
+
`Entity type '${name}' is supplied by two different definitions: ` +
|
|
87
|
+
`${existing.sources
|
|
88
|
+
.map(describeSource)
|
|
89
|
+
.join(', ')} and ${describeSource(source)}. ` +
|
|
90
|
+
'They must be the same definition object (or the same package name and version).'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A registry key must equal the definition's moduleName and, when it has one,
|
|
96
|
+
* its getName(). Case matters. This leaves one name per entity type.
|
|
97
|
+
*/
|
|
98
|
+
function assertKeyMatchesDefinition(key, definition) {
|
|
99
|
+
const moduleName = moduleNameOf(definition);
|
|
100
|
+
const getName = safeGetName(definition);
|
|
101
|
+
const matches =
|
|
102
|
+
(moduleName !== undefined || getName !== undefined) &&
|
|
103
|
+
(moduleName === undefined || key === moduleName) &&
|
|
104
|
+
(getName === undefined || key === getName);
|
|
105
|
+
if (!matches) {
|
|
106
|
+
throw new EntityRegistryError(
|
|
107
|
+
`appDefinition.entities key '${key}' does not match its definition's name ` +
|
|
108
|
+
`(moduleName '${moduleName ?? '(none)'}', getName() '${
|
|
109
|
+
getName ?? '(none)'
|
|
110
|
+
}'). ` +
|
|
111
|
+
'The key must equal both, so each entity type has one name.'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const settingsOf = (entry) => {
|
|
117
|
+
const settings = {};
|
|
118
|
+
for (const field of ['scope', 'proxy', 'display']) {
|
|
119
|
+
if (entry[field] !== undefined) settings[field] = entry[field];
|
|
120
|
+
}
|
|
121
|
+
return settings;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `entities` absent keeps today's behaviour: inference on, and a name clash
|
|
126
|
+
* between integrations is not fatal. `entities` present (even `{}`) opts in to
|
|
127
|
+
* the strict rules.
|
|
128
|
+
*/
|
|
129
|
+
function readEntities(appDefinition) {
|
|
130
|
+
const entities = appDefinition?.entities;
|
|
131
|
+
if (entities === undefined || entities === null) {
|
|
132
|
+
return { strict: false, infer: true, entries: [] };
|
|
133
|
+
}
|
|
134
|
+
if (!isPlainObject(entities)) {
|
|
135
|
+
throw new EntityRegistryError(
|
|
136
|
+
'appDefinition.entities must be an object keyed by entity type name'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const { [INFER_KEY]: inferFlag, ...rest } = entities;
|
|
140
|
+
if (inferFlag !== undefined && typeof inferFlag !== 'boolean') {
|
|
141
|
+
throw new EntityRegistryError(
|
|
142
|
+
`appDefinition.entities.${INFER_KEY} must be a boolean`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
strict: true,
|
|
147
|
+
infer: inferFlag !== false,
|
|
148
|
+
entries: Object.entries(rest),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function registerAppEntries(registry, entries) {
|
|
153
|
+
for (const [key, entry] of entries) {
|
|
154
|
+
if (
|
|
155
|
+
!isPlainObject(entry) ||
|
|
156
|
+
!entry.definition ||
|
|
157
|
+
typeof entry.definition !== 'object'
|
|
158
|
+
) {
|
|
159
|
+
throw new EntityRegistryError(
|
|
160
|
+
`appDefinition.entities.${key} must be an object with a 'definition'`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
assertKeyMatchesDefinition(key, entry.definition);
|
|
164
|
+
registry.set(key, {
|
|
165
|
+
definition: entry.definition,
|
|
166
|
+
sources: [appSource()],
|
|
167
|
+
settings: settingsOf(entry),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function* boundDefinitions(integrationClass) {
|
|
173
|
+
const modules = integrationClass?.Definition?.modules;
|
|
174
|
+
if (!isPlainObject(modules)) return;
|
|
175
|
+
for (const bound of Object.values(modules)) {
|
|
176
|
+
if (bound?.definition) yield bound.definition;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const addSource = (entry, source) => {
|
|
181
|
+
const known = entry.sources.some(
|
|
182
|
+
(s) => s.kind === source.kind && s.name === source.name
|
|
183
|
+
);
|
|
184
|
+
if (!known) entry.sources.push(source);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Folds one integration-bound definition into the registry. Returns the name
|
|
189
|
+
* it could not register (only possible when inference is off), else undefined.
|
|
190
|
+
*/
|
|
191
|
+
function mergeBoundDefinition(registry, definition, source, { strict, infer }) {
|
|
192
|
+
const name = entityTypeNameOf(definition);
|
|
193
|
+
|
|
194
|
+
if (!name) {
|
|
195
|
+
// No moduleName / getName(): it cannot be keyed by name. Offering it
|
|
196
|
+
// as before, de-duplicated by identity, keeps apps that never named
|
|
197
|
+
// one working. It cannot be registered, so `$infer: false` rejects it.
|
|
198
|
+
if (!infer) return UNNAMED;
|
|
199
|
+
const known = [...registry.values()].find(
|
|
200
|
+
(entry) => entry.definition === definition
|
|
201
|
+
);
|
|
202
|
+
if (known) addSource(known, source);
|
|
203
|
+
else {
|
|
204
|
+
registry.set(`${UNNAMED} #${registry.size}`, {
|
|
205
|
+
definition,
|
|
206
|
+
sources: [source],
|
|
207
|
+
settings: {},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const existing = registry.get(name);
|
|
214
|
+
if (existing) {
|
|
215
|
+
if (isSameDefinition(existing.definition, definition)) {
|
|
216
|
+
addSource(existing, source);
|
|
217
|
+
} else if (strict) {
|
|
218
|
+
throw conflictError(name, existing, source);
|
|
219
|
+
} else {
|
|
220
|
+
// Pre-ADR-059 apps keep working: the first definition wins, as
|
|
221
|
+
// ModuleFactory would have picked it.
|
|
222
|
+
log.warn('Two different definitions share one entity type name', {
|
|
223
|
+
eventName: `${log.name}.duplicate_definition`,
|
|
224
|
+
entityType: name,
|
|
225
|
+
kept: existing.sources.map(describeSource).join(', '),
|
|
226
|
+
ignored: describeSource(source),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!infer) return name;
|
|
233
|
+
registry.set(name, { definition, sources: [source], settings: {} });
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assertAllRegistered(unregistered) {
|
|
238
|
+
if (unregistered.size === 0) return;
|
|
239
|
+
const detail = [...unregistered]
|
|
240
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
241
|
+
.map(
|
|
242
|
+
([name, sources]) => `${name} (bound by ${[...sources].join(', ')})`
|
|
243
|
+
);
|
|
244
|
+
throw new EntityRegistryError(
|
|
245
|
+
`appDefinition.entities.${INFER_KEY} is false, so every module an integration binds must be registered. Missing: ${detail.join(
|
|
246
|
+
'; '
|
|
247
|
+
)}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const freezeEntries = (registry) => {
|
|
252
|
+
for (const entry of registry.values()) {
|
|
253
|
+
entry.sources.forEach(Object.freeze);
|
|
254
|
+
Object.freeze(entry.sources);
|
|
255
|
+
Object.freeze(entry.settings);
|
|
256
|
+
Object.freeze(entry);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Builds the registry for an app definition.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} [appDefinition] - `{ entities?, integrations? }`
|
|
264
|
+
* @returns {Map<string, {definition: object, sources: Array<{kind: 'app'} | {kind: 'integration', name: string}>, settings: {scope?: string|object, proxy?: object, display?: object}}>}
|
|
265
|
+
* @throws {EntityRegistryError} on a key/name mismatch, two different
|
|
266
|
+
* definitions under one name (when `entities` is present), or, with
|
|
267
|
+
* `$infer: false`, a bound module that is not registered.
|
|
268
|
+
*/
|
|
269
|
+
function getEntityTypeRegistry(appDefinition = {}) {
|
|
270
|
+
const mode = readEntities(appDefinition);
|
|
271
|
+
const registry = new Map();
|
|
272
|
+
registerAppEntries(registry, mode.entries);
|
|
273
|
+
|
|
274
|
+
const unregistered = new Map();
|
|
275
|
+
for (const integrationClass of appDefinition?.integrations || []) {
|
|
276
|
+
const source = integrationSource(integrationClass);
|
|
277
|
+
for (const definition of boundDefinitions(integrationClass)) {
|
|
278
|
+
const missing = mergeBoundDefinition(
|
|
279
|
+
registry,
|
|
280
|
+
definition,
|
|
281
|
+
source,
|
|
282
|
+
mode
|
|
283
|
+
);
|
|
284
|
+
if (missing !== undefined) {
|
|
285
|
+
if (!unregistered.has(missing))
|
|
286
|
+
unregistered.set(missing, new Set());
|
|
287
|
+
unregistered.get(missing).add(describeSource(source));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
assertAllRegistered(unregistered);
|
|
292
|
+
|
|
293
|
+
freezeEntries(registry);
|
|
294
|
+
return registry;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The definitions of a registry, in registry order. */
|
|
298
|
+
const getEntityTypeDefinitions = (registry) =>
|
|
299
|
+
[...registry.values()].map((entry) => entry.definition);
|
|
300
|
+
|
|
301
|
+
/** Names of the integrations that bind an entity type; empty for registry-only. */
|
|
302
|
+
const usedByOf = (entry) =>
|
|
303
|
+
entry.sources.filter((s) => s.kind === 'integration').map((s) => s.name);
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
getEntityTypeRegistry,
|
|
307
|
+
getEntityTypeDefinitions,
|
|
308
|
+
usedByOf,
|
|
309
|
+
entityTypeNameOf,
|
|
310
|
+
EntityRegistryError,
|
|
311
|
+
};
|
package/modules/index.js
CHANGED
|
@@ -4,6 +4,10 @@ const { OAuth2Requester } = require('./requester/oauth-2');
|
|
|
4
4
|
const { Requester } = require('./requester/requester');
|
|
5
5
|
const { ModuleConstants } = require('./ModuleConstants');
|
|
6
6
|
const { ModuleFactory } = require('./module-factory');
|
|
7
|
+
const {
|
|
8
|
+
getEntityTypeRegistry,
|
|
9
|
+
EntityRegistryError,
|
|
10
|
+
} = require('./entity-type-registry');
|
|
7
11
|
|
|
8
12
|
module.exports = {
|
|
9
13
|
ApiKeyRequester,
|
|
@@ -12,4 +16,6 @@ module.exports = {
|
|
|
12
16
|
Requester,
|
|
13
17
|
ModuleConstants,
|
|
14
18
|
ModuleFactory,
|
|
19
|
+
getEntityTypeRegistry,
|
|
20
|
+
EntityRegistryError,
|
|
15
21
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { ModuleConstants } = require('../ModuleConstants');
|
|
3
|
+
const { usedByOf } = require('../entity-type-registry');
|
|
3
4
|
|
|
4
5
|
const AUTH_TYPE_BY_REQUESTER = {
|
|
5
6
|
[ModuleConstants.authType.oauth2]: 'oauth2',
|
|
@@ -22,16 +23,16 @@ function getAuthStepCount(definition) {
|
|
|
22
23
|
* definition: display metadata, auth type and step count. Never credentials
|
|
23
24
|
* or env values.
|
|
24
25
|
*/
|
|
25
|
-
function describeEntityType(definition) {
|
|
26
|
+
function describeEntityType(definition, typeName = definition.moduleName) {
|
|
26
27
|
const stepCount = getAuthStepCount(definition);
|
|
27
28
|
const display = definition.display || {};
|
|
28
29
|
const description = {
|
|
29
|
-
type:
|
|
30
|
+
type: typeName,
|
|
30
31
|
name:
|
|
31
32
|
callIfFunction(definition, 'getDisplayName') ??
|
|
32
33
|
display.label ??
|
|
33
34
|
display.name ??
|
|
34
|
-
|
|
35
|
+
typeName,
|
|
35
36
|
authType:
|
|
36
37
|
callIfFunction(definition, 'getAuthType') ??
|
|
37
38
|
AUTH_TYPE_BY_REQUESTER[definition.API?.requesterType] ??
|
|
@@ -58,27 +59,50 @@ function findDefinition(moduleDefinitions, entityType) {
|
|
|
58
59
|
return definition;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
const describeEntry = (name, entry) => ({
|
|
63
|
+
// The registry key is the name the API uses, whatever the definition spells.
|
|
64
|
+
...describeEntityType(entry.definition, name),
|
|
65
|
+
usedBy: usedByOf(entry),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function requireRegistry(entityTypeRegistry) {
|
|
69
|
+
if (!(entityTypeRegistry instanceof Map)) {
|
|
70
|
+
throw new TypeError('entityTypeRegistry is required (see getEntityTypeRegistry)');
|
|
71
|
+
}
|
|
72
|
+
return entityTypeRegistry;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lists the entity types this app can authorize: registry entries plus every
|
|
77
|
+
* module an integration binds (ADR-059). `usedBy` names the integrations that
|
|
78
|
+
* bind each type; it is empty for a registry-only type.
|
|
79
|
+
*/
|
|
62
80
|
class ListEntityTypes {
|
|
63
|
-
constructor({
|
|
64
|
-
this.
|
|
81
|
+
constructor({ entityTypeRegistry }) {
|
|
82
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
async execute() {
|
|
68
|
-
return this.
|
|
69
|
-
.map(
|
|
86
|
+
return [...this.registry]
|
|
87
|
+
.map(([name, entry]) => describeEntry(name, entry))
|
|
70
88
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
/** Describes one entity type
|
|
92
|
+
/** Describes one entity type, looked up by its registry key; 404 when absent. */
|
|
75
93
|
class GetEntityType {
|
|
76
|
-
constructor({
|
|
77
|
-
this.
|
|
94
|
+
constructor({ entityTypeRegistry }) {
|
|
95
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
async execute(entityType) {
|
|
81
|
-
|
|
99
|
+
const entry = this.registry.get(entityType);
|
|
100
|
+
if (!entry) {
|
|
101
|
+
throw Boom.notFound(`Entity type '${entityType}' not found`, {
|
|
102
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return describeEntry(entityType, entry);
|
|
82
106
|
}
|
|
83
107
|
}
|
|
84
108
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.680.b3ca7f1.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0--canary.680.b3ca7f1.0",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0--canary.680.b3ca7f1.0",
|
|
52
|
+
"@friggframework/test": "2.0.0--canary.680.b3ca7f1.0",
|
|
53
53
|
"@prisma/client": "^6.19.3",
|
|
54
54
|
"@types/lodash": "4.17.15",
|
|
55
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"publishConfig": {
|
|
90
90
|
"access": "public"
|
|
91
91
|
},
|
|
92
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "b3ca7f1295d79b4d0cb5fb9fb4ec41093033ba4d"
|
|
93
93
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
declare module "@friggframework/core" {
|
|
2
|
+
/** Where an entity type comes from: the app's `entities` map or an integration binding it. */
|
|
3
|
+
export type EntityTypeSource =
|
|
4
|
+
| { kind: "app" }
|
|
5
|
+
| { kind: "integration"; name: string };
|
|
6
|
+
|
|
7
|
+
export type EntityScope = "individual" | "organization" | "global";
|
|
8
|
+
|
|
9
|
+
/** App-level settings carried from the `entities` entry; empty for an inferred-only type. */
|
|
10
|
+
export interface EntityTypeSettings {
|
|
11
|
+
scope?: EntityScope | { default?: EntityScope; allow?: EntityScope[] };
|
|
12
|
+
proxy?: Record<string, unknown>;
|
|
13
|
+
display?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EntityTypeEntry {
|
|
17
|
+
/** The API module definition. */
|
|
18
|
+
definition: any;
|
|
19
|
+
/** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */
|
|
20
|
+
sources: readonly EntityTypeSource[];
|
|
21
|
+
/** Frozen. */
|
|
22
|
+
settings: Readonly<EntityTypeSettings>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Entity type name (the definition's moduleName) to its entry. */
|
|
26
|
+
export type EntityTypeRegistry = Map<string, EntityTypeEntry>;
|
|
27
|
+
|
|
28
|
+
/** One `appDefinition.entities` entry. The key must equal the definition's moduleName (and getName() when defined). */
|
|
29
|
+
export interface EntityRegistration extends EntityTypeSettings {
|
|
30
|
+
definition: any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AppDefinitionEntities {
|
|
34
|
+
/** false turns inference off: every module an integration binds must be registered. Default true. */
|
|
35
|
+
$infer?: boolean;
|
|
36
|
+
[entityType: string]: EntityRegistration | boolean | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AppDefinition {
|
|
40
|
+
integrations: any[];
|
|
41
|
+
/** App-level entity type registry (ADR-059). */
|
|
42
|
+
entities?: AppDefinitionEntities;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds the registry from `entities` plus the modules integrations bind.
|
|
48
|
+
* Throws EntityRegistryError on an invalid registry.
|
|
49
|
+
*/
|
|
50
|
+
export function getEntityTypeRegistry(
|
|
51
|
+
appDefinition?: Pick<AppDefinition, "entities" | "integrations">
|
|
52
|
+
): EntityTypeRegistry;
|
|
53
|
+
|
|
54
|
+
/** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */
|
|
55
|
+
export class EntityRegistryError extends Error {
|
|
56
|
+
code: "ENTITY_REGISTRY_INVALID";
|
|
57
|
+
}
|
|
58
|
+
}
|