@friggframework/core 2.0.0--canary.590.094a71b.0 → 2.0.0--canary.590.0836cf9.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.
@@ -104,10 +104,15 @@ module.exports = {
104
104
  // verify signature, look up integration by portalId, queue
105
105
  await verifyHubSpotSignature(req);
106
106
  for (const evt of req.body) {
107
- const integrationId = await this.findIntegrationByPortalId(
108
- evt.portalId,
109
- 'hubspot'
110
- );
107
+ // Core helper is generic; the extension wraps it in
108
+ // HubSpot vocabulary for its own readability:
109
+ // findIntegrationByPortalId(portalId) =
110
+ // findIntegrationByEntityExternalId(portalId, 'hubspot')
111
+ const integrationId =
112
+ await this.findIntegrationByEntityExternalId(
113
+ evt.portalId,
114
+ 'hubspot'
115
+ );
111
116
  if (!integrationId) continue;
112
117
  await this.queueWebhook({
113
118
  integrationId,
@@ -155,15 +160,47 @@ At `initialize()`, the framework validates each binding:
155
160
 
156
161
  Validation failures throw at boot with a message identifying the integration, binding, and field.
157
162
 
158
- ## Reverse-lookup helper
163
+ ## Reverse-lookup helpers
159
164
 
160
- For app-level webhooks (HubSpot, Slack, etc.) where one URL serves many accounts, extension default handlers commonly need to resolve the inbound external ID (portalId, team_id, workspace_id) to a Frigg integration record. Use the inherited helper:
165
+ For app-level webhooks (HubSpot, Slack, Asana, Microsoft Teams, etc.) where one URL serves many accounts, extension default handlers need to resolve the inbound external ID (HubSpot `portalId`, Slack `team_id`, Asana `workspace_id`, Teams `tenant_id`) to a Frigg integration record. Two inherited helpers:
161
166
 
162
167
  ```javascript
163
- const integrationId = await this.findIntegrationByPortalId(externalId, 'hubspot');
168
+ // Throws on ambiguous resolution. Use when one externalId is expected
169
+ // to map to exactly one integration.
170
+ const integrationId = await this.findIntegrationByEntityExternalId(
171
+ externalId,
172
+ 'hubspot' // optional moduleName
173
+ );
174
+
175
+ // Returns array. Use when one externalId may legitimately fan out to
176
+ // multiple integrations (e.g. one upstream account broadcasting to
177
+ // several Frigg integration records).
178
+ const integrationIds = await this.listIntegrationsByEntityExternalId(
179
+ externalId,
180
+ 'hubspot'
181
+ );
164
182
  ```
165
183
 
166
- Returns the integration ID (or `null`) for the first matching entity. The second arg disambiguates when multiple modules in the same app could carry colliding external IDs.
184
+ `findIntegrationByEntityExternalId` throws if:
185
+ - the (externalId, moduleName) tuple matches more than one Entity row, OR
186
+ - the matched entity is owned by more than one Integration record
187
+
188
+ A silent first-match at either layer is a cross-tenant routing risk; the helper refuses to pick. The second argument (moduleName) disambiguates when multiple modules in the same app could carry colliding external IDs — pass it whenever an api-module knows its own moduleName.
189
+
190
+ ### Where platform-named wrappers belong
191
+
192
+ The core helpers are intentionally platform-neutral. Platform-vocabulary wrappers (`findIntegrationByPortalId`, `findIntegrationByTeamId`, `findIntegrationByWorkspaceId`, etc.) belong **inside the api-module's own extension**, not in core:
193
+
194
+ ```javascript
195
+ // inside @friggframework/api-module-hubspot/extensions/webhooks
196
+ async function findIntegrationByPortalId(portalId) {
197
+ // thin wrapper — reads as self-documenting HubSpot code,
198
+ // delegates to the platform-neutral core primitive
199
+ return this.findIntegrationByEntityExternalId(portalId, 'hubspot');
200
+ }
201
+ ```
202
+
203
+ This keeps core platform-neutral and reusable while keeping the api-module code self-documenting for the platform's developers. The same rule applies to any helper that can be named in a single platform's vocabulary.
167
204
 
168
205
  ## See also
169
206
 
@@ -600,66 +600,134 @@ class IntegrationBase {
600
600
  }
601
601
 
602
602
  /**
603
- * Reverse-lookup helper: find an integration ID by a module entity's externalId.
603
+ * Reverse-lookup helper: find a single integration ID by the externalId of
604
+ * one of its module entities.
604
605
  *
605
- * Used by extension default handlers (e.g. HubSpot webhook receiver) that need
606
- * to resolve an incoming app-level event to a specific per-account integration
607
- * record. The external ID is provided by the upstream system (HubSpot portalId,
608
- * Slack team_id, etc.) — this helper looks up which Frigg integration owns the
609
- * entity carrying that external ID.
606
+ * Used by extension default handlers (HubSpot's `portalId`, Slack's `team_id`,
607
+ * Asana's `workspace_id`, etc.) to resolve an inbound app-level event to the
608
+ * specific per-account integration record that should handle it.
610
609
  *
611
- * Throws on ambiguous resolution. A single externalId mapping to multiple
612
- * integrations is a cross-tenant routing risk (e.g. two users connecting the
613
- * same HubSpot portal): we refuse to silently pick `[0]`. Callers that
614
- * legitimately need to fan out to multiple integrations should use the
615
- * lower-level repository call directly.
610
+ * The helper is intentionally platform-neutral. Platform-vocabulary wrappers
611
+ * (e.g. `findIntegrationByPortalId(portalId)`) belong in the api-module's
612
+ * own extension, where they read as self-documenting for that platform's
613
+ * developers and where naming collisions across providers are impossible.
616
614
  *
617
- * @param {string} externalId - The provider's stable identifier for the account/portal/workspace.
618
- * @param {string} [moduleName] - Optional module name to disambiguate when multiple modules in the same app could carry colliding externalIds.
619
- * @returns {Promise<string|null>} The integration ID, or null if no match.
620
- * @throws {Error} If more than one integration is found for the (externalId, moduleName) tuple.
615
+ * **Refuses to pick** on ambiguous resolution at either layer:
616
+ * - more than one matching Entity row for the (externalId, moduleName) tuple
617
+ * - more than one Integration owning the matched entity
618
+ *
619
+ * A silent first-match here is a cross-tenant routing risk. Callers that
620
+ * legitimately expect a one-to-many relationship must use
621
+ * {@link listIntegrationsByEntityExternalId} instead.
622
+ *
623
+ * @param {string} externalId - Provider's stable identifier for the account/portal/workspace.
624
+ * @param {string} [moduleName] - Disambiguates when multiple modules in the same app could carry colliding externalIds.
625
+ * @returns {Promise<string|null>} The integration ID, or null if no entity or no owning integration matches.
626
+ * @throws {Error} If the (externalId, moduleName) tuple matches multiple entities, or if the matched entity is owned by multiple integrations.
621
627
  */
622
- async findIntegrationByPortalId(externalId, moduleName) {
628
+ async findIntegrationByEntityExternalId(externalId, moduleName) {
623
629
  if (!externalId) return null;
624
- const {
625
- createModuleRepository,
626
- } = require('../modules/repositories/module-repository-factory');
627
- const moduleRepository = createModuleRepository();
628
630
 
629
- const filter = { externalId: String(externalId) };
630
- if (moduleName) filter.moduleName = moduleName;
631
-
632
- const entity = await moduleRepository.findEntity(filter);
633
- if (!entity) {
631
+ const entities = await this._findEntitiesByExternalId(
632
+ externalId,
633
+ moduleName
634
+ );
635
+ if (entities.length === 0) {
634
636
  console.log(
635
- `[Frigg] findIntegrationByPortalId: no entity for externalId=${externalId}${
637
+ `[Frigg] findIntegrationByEntityExternalId: no entity for externalId=${externalId}${
636
638
  moduleName ? ` moduleName=${moduleName}` : ''
637
639
  }`
638
640
  );
639
641
  return null;
640
642
  }
643
+ if (entities.length > 1) {
644
+ const ids = entities.map((e) => e.id).join(', ');
645
+ throw new Error(
646
+ `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
647
+ `${moduleName ? ` moduleName=${moduleName}` : ''} matches ${entities.length} entities [${ids}]. ` +
648
+ `Refusing to pick one to avoid cross-tenant routing. ` +
649
+ `Pass a moduleName, or use listIntegrationsByEntityExternalId if multiple integrations are expected.`
650
+ );
651
+ }
641
652
 
653
+ const entity = entities[0];
642
654
  const integrations =
643
655
  await this.integrationRepository.findIntegrationsByEntityId(
644
656
  entity.id
645
657
  );
646
658
  if (!integrations || integrations.length === 0) {
647
659
  console.log(
648
- `[Frigg] findIntegrationByPortalId: entity ${entity.id} has no owning integrations (orphan)`
660
+ `[Frigg] findIntegrationByEntityExternalId: entity ${entity.id} has no owning integrations (orphan)`
649
661
  );
650
662
  return null;
651
663
  }
652
664
  if (integrations.length > 1) {
653
665
  const ids = integrations.map((i) => i.id).join(', ');
654
666
  throw new Error(
655
- `findIntegrationByPortalId: ambiguous resolution — externalId=${externalId}` +
667
+ `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
656
668
  `${moduleName ? ` moduleName=${moduleName}` : ''} maps to ${integrations.length} integrations [${ids}]. ` +
657
- `Refusing to pick one to avoid cross-tenant routing.`
669
+ `Refusing to pick one to avoid cross-tenant routing. ` +
670
+ `Use listIntegrationsByEntityExternalId if a one-to-many fan-out is intended.`
658
671
  );
659
672
  }
660
673
  return integrations[0].id;
661
674
  }
662
675
 
676
+ /**
677
+ * List all integration IDs whose module entities match an externalId.
678
+ *
679
+ * Use this instead of {@link findIntegrationByEntityExternalId} when a single
680
+ * externalId is *expected* to map to multiple integrations (e.g. an
681
+ * intentional fan-out: one upstream account broadcasting to several Frigg
682
+ * integration records, possibly across tenants).
683
+ *
684
+ * Does not throw on ambiguous resolution — that's the whole point.
685
+ *
686
+ * @param {string} externalId - Provider's stable identifier for the account/portal/workspace.
687
+ * @param {string} [moduleName] - Disambiguates when multiple modules in the same app could carry colliding externalIds.
688
+ * @returns {Promise<Array<string>>} Array of integration IDs (empty if no entity or no owning integrations).
689
+ */
690
+ async listIntegrationsByEntityExternalId(externalId, moduleName) {
691
+ if (!externalId) return [];
692
+
693
+ const entities = await this._findEntitiesByExternalId(
694
+ externalId,
695
+ moduleName
696
+ );
697
+ if (entities.length === 0) return [];
698
+
699
+ const integrationIds = new Set();
700
+ for (const entity of entities) {
701
+ const owners =
702
+ await this.integrationRepository.findIntegrationsByEntityId(
703
+ entity.id
704
+ );
705
+ for (const integration of owners || []) {
706
+ integrationIds.add(integration.id);
707
+ }
708
+ }
709
+ return Array.from(integrationIds);
710
+ }
711
+
712
+ /**
713
+ * Internal: load entities matching an externalId (+ optional moduleName).
714
+ * Pulled out so the single-result and list-result helpers share lookup logic.
715
+ *
716
+ * @private
717
+ */
718
+ async _findEntitiesByExternalId(externalId, moduleName) {
719
+ const {
720
+ createModuleRepository,
721
+ } = require('../modules/repositories/module-repository-factory');
722
+ const moduleRepository = createModuleRepository();
723
+
724
+ const filter = { externalId: String(externalId) };
725
+ if (moduleName) filter.moduleName = moduleName;
726
+
727
+ const entities = await moduleRepository.findEntities(filter);
728
+ return entities || [];
729
+ }
730
+
663
731
  async initialize() {
664
732
  try {
665
733
  const additionalUserActions = await this.loadDynamicUserActions();
@@ -100,6 +100,21 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
100
100
  return this._mapEntity(doc, credential);
101
101
  }
102
102
 
103
+ async findEntities(filter) {
104
+ const query = this._buildFilter(filter);
105
+ const docs = await findMany(this.prisma, 'Entity', query);
106
+ if (!docs || docs.length === 0) return [];
107
+ const credentialMap = await this._fetchCredentialsBulk(
108
+ docs.map((doc) => doc.credentialId)
109
+ );
110
+ return docs.map((doc) =>
111
+ this._mapEntity(
112
+ doc,
113
+ credentialMap.get(fromObjectId(doc.credentialId)) || null
114
+ )
115
+ );
116
+ }
117
+
103
118
  async createEntity(entityData) {
104
119
  const {
105
120
  user,
@@ -91,6 +91,22 @@ class ModuleRepositoryInterface {
91
91
  throw new Error('Method findEntity must be implemented by subclass');
92
92
  }
93
93
 
94
+ /**
95
+ * Find all entities matching a filter.
96
+ *
97
+ * Symmetric with findEntity but returns the full match set. Use this when
98
+ * a single externalId may correspond to more than one Entity row
99
+ * (e.g. shared upstream account across tenants) and the caller needs to
100
+ * detect/handle the multi-match case explicitly.
101
+ *
102
+ * @param {Object} filter - Filter criteria
103
+ * @returns {Promise<Array>} Array of entity objects (empty if no match)
104
+ * @abstract
105
+ */
106
+ async findEntities(filter) {
107
+ throw new Error('Method findEntities must be implemented by subclass');
108
+ }
109
+
94
110
  /**
95
111
  * Create a new entity
96
112
  *
@@ -234,6 +234,34 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
234
234
  };
235
235
  }
236
236
 
237
+ /**
238
+ * Find all entities matching a filter.
239
+ *
240
+ * @param {Object} filter - Filter criteria
241
+ * @returns {Promise<Array>} Array of entity objects with string IDs (empty if no match)
242
+ */
243
+ async findEntities(filter) {
244
+ const where = this._convertFilterToWhere(filter);
245
+ const entities = await this.prisma.entity.findMany({
246
+ where,
247
+ });
248
+
249
+ const credentialIds = entities
250
+ .map((e) => e.credentialId)
251
+ .filter(Boolean);
252
+ const credentialMap = await this._fetchCredentialsBulk(credentialIds);
253
+
254
+ return entities.map((e) => ({
255
+ id: e.id,
256
+ credential: credentialMap.get(e.credentialId) || null,
257
+ userId: e.userId,
258
+ name: e.name,
259
+ externalId: e.externalId,
260
+ moduleName: e.moduleName,
261
+ ...(e.data || {}),
262
+ }));
263
+ }
264
+
237
265
  /**
238
266
  * Create a new entity
239
267
  * Replaces: Entity.create(entityData)
@@ -275,6 +275,34 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
275
275
  };
276
276
  }
277
277
 
278
+ /**
279
+ * Find all entities matching a filter.
280
+ *
281
+ * @param {Object} filter - Filter criteria
282
+ * @returns {Promise<Array>} Array of entity objects with string IDs (empty if no match)
283
+ */
284
+ async findEntities(filter) {
285
+ const where = this._convertFilterToWhere(filter);
286
+ const entities = await this.prisma.entity.findMany({
287
+ where,
288
+ });
289
+
290
+ const credentialIds = entities
291
+ .map((e) => e.credentialId)
292
+ .filter(Boolean);
293
+ const credentialMap = await this._fetchCredentialsBulk(credentialIds);
294
+
295
+ return entities.map((e) => ({
296
+ id: e.id.toString(),
297
+ credential: credentialMap.get(e.credentialId) || null,
298
+ userId: e.userId?.toString(),
299
+ name: e.name,
300
+ externalId: e.externalId,
301
+ moduleName: e.moduleName,
302
+ ...(e.data || {}),
303
+ }));
304
+ }
305
+
278
306
  /**
279
307
  * Create a new entity
280
308
  * Replaces: Entity.create(entityData)
@@ -172,6 +172,30 @@ class ModuleRepository extends ModuleRepositoryInterface {
172
172
  };
173
173
  }
174
174
 
175
+ /**
176
+ * Find all entities matching a filter.
177
+ *
178
+ * @param {Object} filter - Filter criteria
179
+ * @returns {Promise<Array>} Array of entity objects (empty if no match)
180
+ */
181
+ async findEntities(filter) {
182
+ const where = this._convertFilterToWhere(filter);
183
+ const entities = await this.prisma.entity.findMany({
184
+ where,
185
+ include: { credential: true },
186
+ });
187
+
188
+ return entities.map((e) => ({
189
+ id: e.id,
190
+ credential: e.credential,
191
+ userId: e.userId,
192
+ name: e.name,
193
+ externalId: e.externalId,
194
+ moduleName: e.moduleName,
195
+ ...(e.data || {}),
196
+ }));
197
+ }
198
+
175
199
  /**
176
200
  * Create a new entity
177
201
  * Replaces: Entity.create(entityData)
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.590.094a71b.0",
4
+ "version": "2.0.0--canary.590.0836cf9.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0--canary.590.094a71b.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.590.094a71b.0",
43
- "@friggframework/test": "2.0.0--canary.590.094a71b.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.590.0836cf9.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.590.0836cf9.0",
43
+ "@friggframework/test": "2.0.0--canary.590.0836cf9.0",
44
44
  "@prisma/client": "^6.17.0",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "094a71baaac437d208e2ad749bc3c79a9b0c15bb"
83
+ "gitHead": "0836cf99aa6b9d273e19c4fcc890b4d98d7bc05b"
84
84
  }