@friggframework/core 2.0.0--canary.590.094a71b.0 → 2.0.0--canary.590.ffb7d1b.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
 
@@ -434,6 +434,19 @@ class IntegrationBase {
434
434
  // Default: no-op, integrations override this
435
435
  }
436
436
 
437
+ /**
438
+ * Queue a webhook for asynchronous worker dispatch.
439
+ *
440
+ * The dispatch event defaults to `ON_WEBHOOK` for backward compatibility
441
+ * with the `Definition.webhooks: true` path. Extensions (and any caller
442
+ * that needs the worker to invoke a specific bound handler) can override
443
+ * by passing `event` in the payload — it's stripped from the payload and
444
+ * used as the SQS message's dispatch event.
445
+ *
446
+ * @param {Object} data - Webhook payload. May include `event` to override
447
+ * the default `ON_WEBHOOK` dispatch event. All other fields are passed
448
+ * through to the worker as the `data` field of the SQS message.
449
+ */
437
450
  async queueWebhook(data) {
438
451
  const { QueuerUtil } = require('../queues');
439
452
 
@@ -446,10 +459,12 @@ class IntegrationBase {
446
459
  throw new Error(`Queue URL not found for ${queueName}`);
447
460
  }
448
461
 
462
+ const { event: dispatchEvent, ...payload } = data || {};
463
+
449
464
  return QueuerUtil.send(
450
465
  {
451
- event: 'ON_WEBHOOK',
452
- data,
466
+ event: dispatchEvent || 'ON_WEBHOOK',
467
+ data: payload,
453
468
  },
454
469
  queueUrl
455
470
  );
@@ -600,66 +615,134 @@ class IntegrationBase {
600
615
  }
601
616
 
602
617
  /**
603
- * Reverse-lookup helper: find an integration ID by a module entity's externalId.
618
+ * Reverse-lookup helper: find a single integration ID by the externalId of
619
+ * one of its module entities.
620
+ *
621
+ * Used by extension default handlers (HubSpot's `portalId`, Slack's `team_id`,
622
+ * Asana's `workspace_id`, etc.) to resolve an inbound app-level event to the
623
+ * specific per-account integration record that should handle it.
604
624
  *
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.
625
+ * The helper is intentionally platform-neutral. Platform-vocabulary wrappers
626
+ * (e.g. `findIntegrationByPortalId(portalId)`) belong in the api-module's
627
+ * own extension, where they read as self-documenting for that platform's
628
+ * developers and where naming collisions across providers are impossible.
610
629
  *
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.
630
+ * **Refuses to pick** on ambiguous resolution at either layer:
631
+ * - more than one matching Entity row for the (externalId, moduleName) tuple
632
+ * - more than one Integration owning the matched entity
616
633
  *
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.
634
+ * A silent first-match here is a cross-tenant routing risk. Callers that
635
+ * legitimately expect a one-to-many relationship must use
636
+ * {@link listIntegrationsByEntityExternalId} instead.
637
+ *
638
+ * @param {string} externalId - Provider's stable identifier for the account/portal/workspace.
639
+ * @param {string} [moduleName] - Disambiguates when multiple modules in the same app could carry colliding externalIds.
640
+ * @returns {Promise<string|null>} The integration ID, or null if no entity or no owning integration matches.
641
+ * @throws {Error} If the (externalId, moduleName) tuple matches multiple entities, or if the matched entity is owned by multiple integrations.
621
642
  */
622
- async findIntegrationByPortalId(externalId, moduleName) {
643
+ async findIntegrationByEntityExternalId(externalId, moduleName) {
623
644
  if (!externalId) return null;
624
- const {
625
- createModuleRepository,
626
- } = require('../modules/repositories/module-repository-factory');
627
- const moduleRepository = createModuleRepository();
628
645
 
629
- const filter = { externalId: String(externalId) };
630
- if (moduleName) filter.moduleName = moduleName;
631
-
632
- const entity = await moduleRepository.findEntity(filter);
633
- if (!entity) {
646
+ const entities = await this._findEntitiesByExternalId(
647
+ externalId,
648
+ moduleName
649
+ );
650
+ if (entities.length === 0) {
634
651
  console.log(
635
- `[Frigg] findIntegrationByPortalId: no entity for externalId=${externalId}${
652
+ `[Frigg] findIntegrationByEntityExternalId: no entity for externalId=${externalId}${
636
653
  moduleName ? ` moduleName=${moduleName}` : ''
637
654
  }`
638
655
  );
639
656
  return null;
640
657
  }
658
+ if (entities.length > 1) {
659
+ const ids = entities.map((e) => e.id).join(', ');
660
+ throw new Error(
661
+ `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
662
+ `${moduleName ? ` moduleName=${moduleName}` : ''} matches ${entities.length} entities [${ids}]. ` +
663
+ `Refusing to pick one to avoid cross-tenant routing. ` +
664
+ `Pass a moduleName, or use listIntegrationsByEntityExternalId if multiple integrations are expected.`
665
+ );
666
+ }
641
667
 
668
+ const entity = entities[0];
642
669
  const integrations =
643
670
  await this.integrationRepository.findIntegrationsByEntityId(
644
671
  entity.id
645
672
  );
646
673
  if (!integrations || integrations.length === 0) {
647
674
  console.log(
648
- `[Frigg] findIntegrationByPortalId: entity ${entity.id} has no owning integrations (orphan)`
675
+ `[Frigg] findIntegrationByEntityExternalId: entity ${entity.id} has no owning integrations (orphan)`
649
676
  );
650
677
  return null;
651
678
  }
652
679
  if (integrations.length > 1) {
653
680
  const ids = integrations.map((i) => i.id).join(', ');
654
681
  throw new Error(
655
- `findIntegrationByPortalId: ambiguous resolution — externalId=${externalId}` +
682
+ `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
656
683
  `${moduleName ? ` moduleName=${moduleName}` : ''} maps to ${integrations.length} integrations [${ids}]. ` +
657
- `Refusing to pick one to avoid cross-tenant routing.`
684
+ `Refusing to pick one to avoid cross-tenant routing. ` +
685
+ `Use listIntegrationsByEntityExternalId if a one-to-many fan-out is intended.`
658
686
  );
659
687
  }
660
688
  return integrations[0].id;
661
689
  }
662
690
 
691
+ /**
692
+ * List all integration IDs whose module entities match an externalId.
693
+ *
694
+ * Use this instead of {@link findIntegrationByEntityExternalId} when a single
695
+ * externalId is *expected* to map to multiple integrations (e.g. an
696
+ * intentional fan-out: one upstream account broadcasting to several Frigg
697
+ * integration records, possibly across tenants).
698
+ *
699
+ * Does not throw on ambiguous resolution — that's the whole point.
700
+ *
701
+ * @param {string} externalId - Provider's stable identifier for the account/portal/workspace.
702
+ * @param {string} [moduleName] - Disambiguates when multiple modules in the same app could carry colliding externalIds.
703
+ * @returns {Promise<Array<string>>} Array of integration IDs (empty if no entity or no owning integrations).
704
+ */
705
+ async listIntegrationsByEntityExternalId(externalId, moduleName) {
706
+ if (!externalId) return [];
707
+
708
+ const entities = await this._findEntitiesByExternalId(
709
+ externalId,
710
+ moduleName
711
+ );
712
+ if (entities.length === 0) return [];
713
+
714
+ const integrationIds = new Set();
715
+ for (const entity of entities) {
716
+ const owners =
717
+ await this.integrationRepository.findIntegrationsByEntityId(
718
+ entity.id
719
+ );
720
+ for (const integration of owners || []) {
721
+ integrationIds.add(integration.id);
722
+ }
723
+ }
724
+ return Array.from(integrationIds);
725
+ }
726
+
727
+ /**
728
+ * Internal: load entities matching an externalId (+ optional moduleName).
729
+ * Pulled out so the single-result and list-result helpers share lookup logic.
730
+ *
731
+ * @private
732
+ */
733
+ async _findEntitiesByExternalId(externalId, moduleName) {
734
+ const {
735
+ createModuleRepository,
736
+ } = require('../modules/repositories/module-repository-factory');
737
+ const moduleRepository = createModuleRepository();
738
+
739
+ const filter = { externalId: String(externalId) };
740
+ if (moduleName) filter.moduleName = moduleName;
741
+
742
+ const entities = await moduleRepository.findEntities(filter);
743
+ return entities || [];
744
+ }
745
+
663
746
  async initialize() {
664
747
  try {
665
748
  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.ffb7d1b.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.ffb7d1b.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.590.ffb7d1b.0",
43
+ "@friggframework/test": "2.0.0--canary.590.ffb7d1b.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": "ffb7d1bc294080aa9e9a3611dd30e21a4b800542"
84
84
  }