@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.
- package/integrations/EXTENSIONS.md +45 -8
- package/integrations/integration-base.js +97 -29
- package/modules/repositories/module-repository-documentdb.js +15 -0
- package/modules/repositories/module-repository-interface.js +16 -0
- package/modules/repositories/module-repository-mongo.js +28 -0
- package/modules/repositories/module-repository-postgres.js +28 -0
- package/modules/repositories/module-repository.js +24 -0
- package/package.json +5 -5
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
163
|
+
## Reverse-lookup helpers
|
|
159
164
|
|
|
160
|
-
For app-level webhooks (HubSpot, Slack, etc.) where one URL serves many accounts, extension default handlers
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
606
|
-
* to resolve an
|
|
607
|
-
*
|
|
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
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
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
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
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
|
|
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
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
631
|
+
const entities = await this._findEntitiesByExternalId(
|
|
632
|
+
externalId,
|
|
633
|
+
moduleName
|
|
634
|
+
);
|
|
635
|
+
if (entities.length === 0) {
|
|
634
636
|
console.log(
|
|
635
|
-
`[Frigg]
|
|
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]
|
|
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
|
-
`
|
|
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.
|
|
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.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0--canary.590.
|
|
43
|
-
"@friggframework/test": "2.0.0--canary.590.
|
|
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": "
|
|
83
|
+
"gitHead": "0836cf99aa6b9d273e19c4fcc890b4d98d7bc05b"
|
|
84
84
|
}
|