@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 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
- getModulesDefinitionFromIntegrationClasses,
34
- } = require('../../integrations/utils/map-integration-dto');
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
- function createIntegrationCommands({ integrationClass } = {}) {
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({
@@ -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({ integrationClass });
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
- 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
- const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
184
-
185
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
191
+ const moduleDefinitions = getEntityTypeDefinitions(
192
+ getEntityTypeRegistry({ integrations, entities })
193
+ );
186
194
  const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
187
195
 
188
- 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) {
189
208
  return;
190
209
  }
191
210
 
192
211
  const moduleSchema = {
193
- Credential: {
194
- fields: credentialFields
195
- }
212
+ ...customSchema,
213
+ Credential: { ...customSchema.Credential, fields },
196
214
  };
197
215
 
198
216
  logger.info(
199
- `Registering module-level encryption for ${credentialFields.length} credential fields`
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 (integrations && Array.isArray(integrations)) {
252
- loadModuleEncryptionSchemas(integrations);
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 parts.length >= 4;
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
- getModulesDefinitionFromIntegrationClasses,
11
- } = require('../integrations/utils/map-integration-dto');
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, integrations) {
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
- registerCredentialLogKeys(appDefinition, integrations);
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
- getModulesDefinitionFromIntegrationClasses,
22
- } = require('../integrations/utils/map-integration-dto');
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 } = loadAppDefinition();
67
+ const { integrations: integrationClasses, moduleDefinitions } =
68
+ loadAppDefinition();
80
69
 
81
70
  const { integrationRepository, moduleRepository } =
82
71
  initializeRepositories();
83
72
 
84
- const moduleFactory = createModuleFactoryWithDefinitions(
73
+ const moduleFactory = new ModuleFactory({
85
74
  moduleRepository,
86
- integrationClasses
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 (processId, integrationClass) => {
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 = createModuleFactoryWithDefinitions(moduleRepository, [
137
- integrationClass,
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
- const createQueueWorker = (integrationClass) => {
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 = getModulesDefinitionFromIntegrationClasses(integrationClasses);
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 } = loadAppDefinition();
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
 
@@ -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
- const { integrations: integrationClasses, userConfig } =
78
- loadAppDefinition();
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
- const getModulesDefinitionFromIntegrationClasses = (integrationClasses) => {
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
- function buildV2Dependencies({ appDefinition, config }) {
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
- getModulesDefinitionFromIntegrationClasses,
21
- } = require('../../integrations/utils/map-integration-dto');
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
- const moduleDefinitions =
74
- getModulesDefinitionFromIntegrationClasses(integrationClasses);
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({ moduleDefinitions }),
204
- getEntityType: new GetEntityType({ moduleDefinitions }),
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: definition.moduleName,
30
+ type: typeName,
30
31
  name:
31
32
  callIfFunction(definition, 'getDisplayName') ??
32
33
  display.label ??
33
34
  display.name ??
34
- definition.moduleName,
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
- /** Lists the entity types (API modules) this app can authorize. */
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({ moduleDefinitions }) {
64
- this.moduleDefinitions = moduleDefinitions || [];
81
+ constructor({ entityTypeRegistry }) {
82
+ this.registry = requireRegistry(entityTypeRegistry);
65
83
  }
66
84
 
67
85
  async execute() {
68
- return this.moduleDefinitions
69
- .map(describeEntityType)
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; 404 when the app does not configure it. */
92
+ /** Describes one entity type, looked up by its registry key; 404 when absent. */
75
93
  class GetEntityType {
76
- constructor({ moduleDefinitions }) {
77
- this.moduleDefinitions = moduleDefinitions || [];
94
+ constructor({ entityTypeRegistry }) {
95
+ this.registry = requireRegistry(entityTypeRegistry);
78
96
  }
79
97
 
80
98
  async execute(entityType) {
81
- return describeEntityType(findDefinition(this.moduleDefinitions, entityType));
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.671.e50fe9c.0",
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.671.e50fe9c.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.671.e50fe9c.0",
52
- "@friggframework/test": "2.0.0--canary.671.e50fe9c.0",
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": "e50fe9c3d910653eef00ca7248658e7a7f637493"
92
+ "gitHead": "b3ca7f1295d79b4d0cb5fb9fb4ec41093033ba4d"
93
93
  }
package/types/index.d.ts CHANGED
@@ -12,3 +12,4 @@
12
12
  /// <reference path="./eslint-config/index.d.ts" />
13
13
  /// <reference path="./associations/index.d.ts" />
14
14
  /// <reference path="./syncs/index.d.ts" />
15
+ /// <reference path="./modules/index.d.ts" />
@@ -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
+ }