@friggframework/core 2.0.0--canary.590.ffb7d1b.0 → 2.0.0--canary.593.a7bacab.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.
@@ -31,9 +31,6 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
31
31
  router[method.toLowerCase()](path, async (req, res, next) => {
32
32
  try {
33
33
  const integrationInstance = new IntegrationClass();
34
- // initialize() registers dynamic user actions AND merges any Tier 3
35
- // Integration Extension events into instance.events before dispatch.
36
- await integrationInstance.initialize();
37
34
  const dispatcher = new IntegrationEventDispatcher(
38
35
  integrationInstance
39
36
  );
@@ -215,9 +212,6 @@ const createQueueWorker = (integrationClass) => {
215
212
  logCtx
216
213
  );
217
214
  integrationInstance = new integrationClass();
218
- // Merge Tier 3 Integration Extension events into instance.events
219
- // so extension-contributed queue events can be dispatched.
220
- await integrationInstance.initialize();
221
215
  }
222
216
 
223
217
  const dispatcher = new IntegrationEventDispatcher(
@@ -2,47 +2,24 @@ const { createAppHandler } = require('./../app-handler-helpers');
2
2
  const {
3
3
  loadAppDefinition,
4
4
  } = require('../app-definition-loader');
5
- const express = require('express');
6
- const { Router } = express;
5
+ const { Router } = require('express');
7
6
  const { loadRouterFromObject } = require('../backend-utils');
8
- const { getExtensionRoutes } = require('../../integrations/extension');
9
7
 
10
8
  const handlers = {};
11
9
  const { integrations: integrationClasses } = loadAppDefinition();
12
10
 
13
- const routeKey = (method, path) => `${(method || 'ANY').toUpperCase()} ${path}`;
14
-
15
11
  //todo: this should be in a use case class
16
12
  for (const IntegrationClass of integrationClasses) {
17
13
  const router = Router();
18
14
  const basePath = `/api/${IntegrationClass.Definition.name}-integration`;
19
- // Track (method, path) tuples to fail fast on conflicts between Definition.routes,
20
- // extension routes, or two extensions claiming the same path.
21
- const claimedRoutes = new Map();
22
- const claim = (method, path, source) => {
23
- const key = routeKey(method, path);
24
- if (claimedRoutes.has(key)) {
25
- const prev = claimedRoutes.get(key);
26
- throw new Error(
27
- `Integration "${IntegrationClass.Definition.name}" route conflict: ` +
28
- `${key} declared by ${prev} and ${source}`
29
- );
30
- }
31
- claimedRoutes.set(key, source);
32
- };
33
15
 
34
16
  console.log(`\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:`);
35
17
 
36
- const routes = IntegrationClass.Definition.routes || [];
37
- for (const routeDef of routes) {
18
+ for (const routeDef of IntegrationClass.Definition.routes) {
38
19
  if (typeof routeDef === 'function') {
39
20
  router.use(basePath, routeDef(IntegrationClass));
40
21
  console.log(`│ ANY ${basePath}/* (function handler)`);
41
- } else if (routeDef instanceof express.Router) {
42
- router.use(basePath, routeDef);
43
- console.log(`│ ANY ${basePath}/* (express router)`);
44
22
  } else if (typeof routeDef === 'object') {
45
- claim(routeDef.method, routeDef.path, 'Definition.routes');
46
23
  router.use(
47
24
  basePath,
48
25
  loadRouterFromObject(IntegrationClass, routeDef)
@@ -50,25 +27,11 @@ for (const IntegrationClass of integrationClasses) {
50
27
  const method = (routeDef.method || 'ANY').toUpperCase();
51
28
  const fullPath = `${basePath}${routeDef.path}`;
52
29
  console.log(`│ ${method} ${fullPath}`);
30
+ } else if (routeDef instanceof express.Router) {
31
+ router.use(basePath, routeDef);
32
+ console.log(`│ ANY ${basePath}/* (express router)`);
53
33
  }
54
34
  }
55
-
56
- // Tier 3 Integration Extension routes — see EXTENSIONS.md
57
- for (const extRoute of getExtensionRoutes(IntegrationClass)) {
58
- claim(
59
- extRoute.method,
60
- extRoute.path,
61
- `extension "${extRoute.extensionName}" (binding "${extRoute.bindingName}")`
62
- );
63
- router.use(
64
- basePath,
65
- loadRouterFromObject(IntegrationClass, extRoute)
66
- );
67
- const method = extRoute.method.toUpperCase();
68
- console.log(
69
- `│ ${method} ${basePath}${extRoute.path} (extension: ${extRoute.extensionName})`
70
- );
71
- }
72
35
  console.log('│');
73
36
 
74
37
  handlers[`${IntegrationClass.Definition.name}`] = {
@@ -1,13 +1,6 @@
1
1
  const { createHandler } = require('@friggframework/core');
2
2
  const { loadAppDefinition } = require('../app-definition-loader');
3
3
  const { createQueueWorker } = require('../backend-utils');
4
- // TODO(Phase 2): mount extension-declared workers in addition to the per-integration
5
- // default queue worker. Today, getExtensionWorkers(IntegrationClass) returns the
6
- // declared workers but they are not yet bound to dedicated SQS sources. Extension-
7
- // contributed *events* still flow through the default queue worker below because
8
- // _mergeExtensions() registers them in instance.events, so end-to-end webhook
9
- // delivery for Tier 3 extensions works without this Phase 2 work.
10
- // const { getExtensionWorkers } = require('../../integrations/extension');
11
4
 
12
5
  const handlers = {};
13
6
  const { integrations: integrationClasses } = loadAppDefinition();
@@ -10,11 +10,6 @@ const {
10
10
  const {
11
11
  LoadIntegrationContextUseCase,
12
12
  } = require('./use-cases/load-integration-context');
13
- const {
14
- validateExtensionBinding,
15
- getExtensionRoutes,
16
- getExtensionWorkers,
17
- } = require('./extension');
18
13
 
19
14
  module.exports = {
20
15
  IntegrationBase,
@@ -23,7 +18,4 @@ module.exports = {
23
18
  checkRequiredParams,
24
19
  getModulesDefinitionFromIntegrationClasses,
25
20
  LoadIntegrationContextUseCase,
26
- validateExtensionBinding,
27
- getExtensionRoutes,
28
- getExtensionWorkers,
29
21
  };
@@ -11,7 +11,6 @@ const {
11
11
  const {
12
12
  UpdateIntegrationMessages,
13
13
  } = require('./use-cases/update-integration-messages');
14
- const { validateExtensionBinding } = require('./extension');
15
14
 
16
15
  const constantsToBeMigrated = {
17
16
  defaultEvents: {
@@ -61,9 +60,6 @@ class IntegrationBase {
61
60
  supportedVersions: [], // Eventually usable for deprecation and future test version purposes
62
61
 
63
62
  modules: {},
64
- // Tier 3 Integration Extensions — see packages/core/integrations/EXTENSIONS.md
65
- // Shape: { [bindingName]: { extension, handlers?: { [eventName]: methodName } } }
66
- extensions: {},
67
63
  display: {
68
64
  name: 'Integration Name',
69
65
  logo: '',
@@ -434,19 +430,6 @@ class IntegrationBase {
434
430
  // Default: no-op, integrations override this
435
431
  }
436
432
 
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
- */
450
433
  async queueWebhook(data) {
451
434
  const { QueuerUtil } = require('../queues');
452
435
 
@@ -459,12 +442,10 @@ class IntegrationBase {
459
442
  throw new Error(`Queue URL not found for ${queueName}`);
460
443
  }
461
444
 
462
- const { event: dispatchEvent, ...payload } = data || {};
463
-
464
445
  return QueuerUtil.send(
465
446
  {
466
- event: dispatchEvent || 'ON_WEBHOOK',
467
- data: payload,
447
+ event: 'ON_WEBHOOK',
448
+ data,
468
449
  },
469
450
  queueUrl
470
451
  );
@@ -523,226 +504,6 @@ class IntegrationBase {
523
504
  };
524
505
  }
525
506
 
526
- /**
527
- * Merge Tier 3 Integration Extension events into `this.events`.
528
- *
529
- * For each binding declared on `static Definition.extensions`, this method:
530
- * 1. Validates the extension bundle shape and binding handlers
531
- * 2. For each event the extension declares, resolves the handler in priority order:
532
- * a. Subclass-defined `this.events[eventName]` (set in the constructor) — wins; if a
533
- * binding tried to override that event with `handlers`, we log a warning so the
534
- * author knows their override is shadowed.
535
- * b. A method-name string in `binding.handlers[eventName]` → method on this instance
536
- * c. The extension's own default `handler` function
537
- * d. Otherwise throw — neither side provided a handler
538
- * 3. Binds the resolved function to this instance and writes it to `this.events[eventName]`
539
- *
540
- * Two bindings declaring the same event throw a deterministic conflict error — silent
541
- * "first/last writer wins" makes routing bugs nearly impossible to diagnose.
542
- *
543
- * @private
544
- */
545
- _mergeExtensions() {
546
- const extensions = this.constructor.Definition?.extensions || {};
547
- const integrationName = this.constructor.Definition?.name;
548
- // Tracks which event names have been claimed by an extension binding during
549
- // this merge — distinct from subclass-defined events on `this.events`.
550
- const mergedByExtension = new Map();
551
-
552
- for (const [bindingName, binding] of Object.entries(extensions)) {
553
- if (!binding || typeof binding !== 'object') {
554
- throw new Error(
555
- `Integration "${integrationName}" extension binding "${bindingName}" must be an object`
556
- );
557
- }
558
- const { extension, handlers = {} } = binding;
559
- validateExtensionBinding(
560
- extension,
561
- bindingName,
562
- integrationName,
563
- binding
564
- );
565
-
566
- const extEvents = extension.events || {};
567
- for (const [eventName, eventDef] of Object.entries(extEvents)) {
568
- // Conflict detection: another extension binding already claimed this event name.
569
- if (mergedByExtension.has(eventName)) {
570
- const prev = mergedByExtension.get(eventName);
571
- throw new Error(
572
- `Integration "${integrationName}" extension event conflict: ` +
573
- `event "${eventName}" is declared by both binding "${prev}" and binding "${bindingName}" — ` +
574
- `use distinct event names per binding or omit duplicates`
575
- );
576
- }
577
-
578
- // Subclass shadowing: if the subclass set this.events[eventName] before initialize(),
579
- // it wins. Warn if the binding tried to wire an override that's now ignored.
580
- if (this.events[eventName]) {
581
- if (typeof handlers[eventName] === 'string') {
582
- console.warn(
583
- `[Frigg] Integration "${integrationName}" binding "${bindingName}": ` +
584
- `handler "${handlers[eventName]}" for event "${eventName}" is ignored because ` +
585
- `this.events["${eventName}"] was already set (subclass constructor or earlier merge)`
586
- );
587
- }
588
- continue;
589
- }
590
-
591
- let fn;
592
- const override = handlers[eventName];
593
- if (typeof override === 'string') {
594
- if (typeof this[override] !== 'function') {
595
- throw new Error(
596
- `Integration "${integrationName}" extension binding "${bindingName}": handler method "${override}" not found on instance`
597
- );
598
- }
599
- fn = this[override];
600
- } else if (typeof eventDef.handler === 'function') {
601
- fn = eventDef.handler;
602
- } else {
603
- throw new Error(
604
- `Extension "${extension.name}" event "${eventName}" has no default handler and binding "${bindingName}" did not provide one`
605
- );
606
- }
607
-
608
- this.events[eventName] = {
609
- type: eventDef.type,
610
- handler: fn.bind(this),
611
- };
612
- mergedByExtension.set(eventName, bindingName);
613
- }
614
- }
615
- }
616
-
617
- /**
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.
624
- *
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.
629
- *
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
633
- *
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.
642
- */
643
- async findIntegrationByEntityExternalId(externalId, moduleName) {
644
- if (!externalId) return null;
645
-
646
- const entities = await this._findEntitiesByExternalId(
647
- externalId,
648
- moduleName
649
- );
650
- if (entities.length === 0) {
651
- console.log(
652
- `[Frigg] findIntegrationByEntityExternalId: no entity for externalId=${externalId}${
653
- moduleName ? ` moduleName=${moduleName}` : ''
654
- }`
655
- );
656
- return null;
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
- }
667
-
668
- const entity = entities[0];
669
- const integrations =
670
- await this.integrationRepository.findIntegrationsByEntityId(
671
- entity.id
672
- );
673
- if (!integrations || integrations.length === 0) {
674
- console.log(
675
- `[Frigg] findIntegrationByEntityExternalId: entity ${entity.id} has no owning integrations (orphan)`
676
- );
677
- return null;
678
- }
679
- if (integrations.length > 1) {
680
- const ids = integrations.map((i) => i.id).join(', ');
681
- throw new Error(
682
- `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
683
- `${moduleName ? ` moduleName=${moduleName}` : ''} maps to ${integrations.length} integrations [${ids}]. ` +
684
- `Refusing to pick one to avoid cross-tenant routing. ` +
685
- `Use listIntegrationsByEntityExternalId if a one-to-many fan-out is intended.`
686
- );
687
- }
688
- return integrations[0].id;
689
- }
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
-
746
507
  async initialize() {
747
508
  try {
748
509
  const additionalUserActions = await this.loadDynamicUserActions();
@@ -751,7 +512,6 @@ class IntegrationBase {
751
512
  this.addError(e);
752
513
  }
753
514
 
754
- this._mergeExtensions();
755
515
  this.registerEventHandlers();
756
516
  }
757
517
 
@@ -19,39 +19,43 @@ class FindIntegrationContextByExternalEntityIdUseCase {
19
19
  this.loadIntegrationContextUseCase = loadIntegrationContextUseCase;
20
20
  }
21
21
 
22
- async execute({ externalEntityId }) {
23
- if (!externalEntityId) {
24
- const error = new Error('externalEntityId is required');
25
- error.code = 'EXTERNAL_ENTITY_ID_REQUIRED';
22
+ async execute({ externalId, type }) {
23
+ if (!externalId) {
24
+ const error = new Error('externalId is required');
25
+ error.code = 'EXTERNAL_ID_REQUIRED';
26
+ throw error;
27
+ }
28
+
29
+ if (!type) {
30
+ const error = new Error('type is required');
31
+ error.code = 'TYPE_REQUIRED';
26
32
  throw error;
27
33
  }
28
34
 
29
35
  const entity = await this.moduleRepository.findEntity({
30
- externalId: externalEntityId,
36
+ externalId,
31
37
  });
32
38
 
33
39
  if (!entity) {
34
40
  const error = new Error(
35
- `Entity not found for externalId: ${externalEntityId}`
41
+ `Entity not found for externalId: ${externalId}`
36
42
  );
37
43
  error.code = 'ENTITY_NOT_FOUND';
38
44
  throw error;
39
45
  }
40
46
 
41
- if (!entity.userId) {
42
- const error = new Error('Entity does not have an associated user');
43
- error.code = 'ENTITY_USER_NOT_FOUND';
44
- throw error;
45
- }
46
-
47
- const integrationRecord =
48
- await this.integrationRepository.findIntegrationByUserId(
49
- entity.userId
47
+ const integrations =
48
+ await this.integrationRepository.findIntegrationsByEntityId(
49
+ entity.id
50
50
  );
51
51
 
52
+ const integrationRecord = integrations?.find(
53
+ (i) => i.config?.type === type
54
+ );
55
+
52
56
  if (!integrationRecord) {
53
57
  const error = new Error(
54
- `Integration not found for user: ${entity.userId}`
58
+ `Integration of type '${type}' not found for entity: ${entity.id}`
55
59
  );
56
60
  error.code = 'INTEGRATION_NOT_FOUND';
57
61
  throw error;
@@ -100,21 +100,6 @@ 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
-
118
103
  async createEntity(entityData) {
119
104
  const {
120
105
  user,
@@ -91,22 +91,6 @@ 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
-
110
94
  /**
111
95
  * Create a new entity
112
96
  *
@@ -234,34 +234,6 @@ 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
-
265
237
  /**
266
238
  * Create a new entity
267
239
  * Replaces: Entity.create(entityData)
@@ -275,34 +275,6 @@ 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
-
306
278
  /**
307
279
  * Create a new entity
308
280
  * Replaces: Entity.create(entityData)
@@ -172,30 +172,6 @@ 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
-
199
175
  /**
200
176
  * Create a new entity
201
177
  * 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.ffb7d1b.0",
4
+ "version": "2.0.0--canary.593.a7bacab.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.ffb7d1b.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.590.ffb7d1b.0",
43
- "@friggframework/test": "2.0.0--canary.590.ffb7d1b.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.593.a7bacab.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.593.a7bacab.0",
43
+ "@friggframework/test": "2.0.0--canary.593.a7bacab.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": "ffb7d1bc294080aa9e9a3611dd30e21a4b800542"
83
+ "gitHead": "a7bacab2557a3bd8ee3baf6887cd6940a8a2cea4"
84
84
  }
@@ -1,209 +0,0 @@
1
- # Integration Extensions Quick Start
2
-
3
- Tier 3 **Integration Extensions** let an API module ship reusable handler bundles — receiver routes, event handlers, queues, workers — that an integration consumes declaratively via `Definition.extensions`. See [ADR-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) for the full taxonomy.
4
-
5
- ## When to use this vs `Definition.webhooks: true`
6
-
7
- | Use `webhooks: true` ([WEBHOOK-QUICKSTART](./WEBHOOK-QUICKSTART.md)) | Use `extensions: {...}` |
8
- |---|---|
9
- | Per-account webhooks scoped to one integration record | App-level webhooks fanned out to many account records by external ID lookup |
10
- | You'll write the receiver, signature check, and queue dispatch yourself | The API module ships the receiver, signature check, and queue dispatch already |
11
- | One pattern, one endpoint | Multiple bundles (webhooks + CRM cards + timeline) declared together |
12
-
13
- ## Step 1: Bind the extension on your Integration's Definition
14
-
15
- ```javascript
16
- const hubspot = require('@friggframework/api-module-hubspot');
17
-
18
- class HubSpotIntegration extends IntegrationBase {
19
- static Definition = {
20
- name: 'hubspot',
21
- version: '1.0.0',
22
- modules: { hubspot: { definition: hubspot.Definition } },
23
- extensions: {
24
- hubspotWebhooks: {
25
- extension: hubspot.extensions.webhooks,
26
- handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' },
27
- },
28
- },
29
- };
30
-
31
- async onHubSpotEvent({ data }) {
32
- // pure business logic — signature verification, portalId lookup,
33
- // and queue dispatch are all done by the extension's default handlers
34
- const { subscriptionType, objectId } = data.body;
35
- if (subscriptionType === 'contact.creation') {
36
- await this.upsertContact(objectId);
37
- }
38
- }
39
- }
40
- ```
41
-
42
- The binding key (`hubspotWebhooks`) is your local name. The extension reference (`hubspot.extensions.webhooks`) is whatever the API module exports.
43
-
44
- ## Step 2: Deploy
45
-
46
- The framework auto-mounts each extension's routes at the integration's base path. Boot logs show:
47
-
48
- ```
49
- │ Configuring routes for hubspot Integration:
50
- │ POST /api/hubspot-integration/webhooks (extension: hubspot-webhooks)
51
-
52
- ```
53
-
54
- Hit that URL and the bound method (`onHubSpotEvent`) fires on the resolved per-account integration instance.
55
-
56
- ## How handler binding works
57
-
58
- Each binding can map extension event names to method names on your integration:
59
-
60
- ```javascript
61
- extensions: {
62
- hubspotWebhooks: {
63
- extension: hubspot.extensions.webhooks,
64
- handlers: {
65
- HUBSPOT_WEBHOOK: 'onHubSpotEvent', // method on `this`
66
- },
67
- },
68
- },
69
- ```
70
-
71
- Resolution priority per event:
72
-
73
- 1. `binding.handlers[eventName]` → resolves to `this[methodName]`, bound to the instance
74
- 2. Extension's own default handler from `extension.events[eventName].handler`, bound to the instance
75
- 3. Otherwise → `initialize()` throws with a message naming the integration, binding, and event
76
-
77
- Strings (not function refs) are intentional: it dodges the `this`-in-static-Definition bootstrapping problem and centralizes binding inside the framework. The framework resolves the method against the live integration instance at startup.
78
-
79
- ## Event-name conflicts (and binding the same extension twice)
80
-
81
- If two bindings in your integration's `extensions` map declare the same event name, the framework **throws at `initialize()`** with a clear conflict error — no silent first/last-writer pick, no surprise routing. The fix is to use distinct event names per binding.
82
-
83
- This means **binding the same extension twice only works if the extension itself defines disjoint event sets per use-case** (rare). For the common "two webhooks, two handlers" pattern, an API module should ship two distinct extensions instead — for example `hubspot.extensions.webhooks` and `hubspot.extensions.sandboxWebhooks`, each with its own event names.
84
-
85
- Subclass overrides via `this.events[eventName]` (set in the constructor) take precedence over extension-declared events. If a binding tried to wire a handler that's now shadowed, the framework logs a warning naming the integration, binding, and ignored method.
86
-
87
- Route path conflicts (two extensions declaring the same `method + path`, or an extension colliding with a `Definition.routes` entry) also throw at boot.
88
-
89
- ## Authoring an extension (for API module authors)
90
-
91
- An extension bundle is a plain object exported from your api-module:
92
-
93
- ```javascript
94
- // @friggframework/api-module-hubspot/extensions/webhooks/index.js
95
- module.exports = {
96
- name: 'hubspot-webhooks',
97
- routes: [
98
- { path: '/webhooks', method: 'POST', event: 'HUBSPOT_WEBHOOK_RECEIVED' },
99
- ],
100
- events: {
101
- HUBSPOT_WEBHOOK_RECEIVED: {
102
- type: 'LIFE_CYCLE_EVENT',
103
- handler: async function ({ req, res }) {
104
- // verify signature, look up integration by portalId, queue
105
- await verifyHubSpotSignature(req);
106
- for (const evt of req.body) {
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
- );
116
- if (!integrationId) continue;
117
- await this.queueWebhook({
118
- integrationId,
119
- body: evt,
120
- event: 'HUBSPOT_WEBHOOK',
121
- });
122
- }
123
- res.status(200).json({ received: req.body.length });
124
- },
125
- },
126
- HUBSPOT_WEBHOOK: {
127
- type: 'LIFE_CYCLE_EVENT',
128
- handler: async function ({ data }) {
129
- // default no-op; integrations override via binding.handlers
130
- },
131
- },
132
- },
133
- };
134
- ```
135
-
136
- Then expose it on your api-module's index:
137
-
138
- ```javascript
139
- // @friggframework/api-module-hubspot/index.js
140
- module.exports = {
141
- Definition: require('./api-module-definition'),
142
- extensions: {
143
- webhooks: require('./extensions/webhooks'),
144
- crmCards: require('./extensions/crm-cards'),
145
- timeline: require('./extensions/timeline'),
146
- },
147
- };
148
- ```
149
-
150
- ## Contract enforced by the framework
151
-
152
- At `initialize()`, the framework validates each binding:
153
-
154
- - `extension` must be an object with a `name`
155
- - `extension.events` must be an object keyed by event name (if present)
156
- - `extension.routes` must be an array (if present)
157
- - Every route's `event` must exist in `extension.events`
158
- - Every route's `method` must be a known HTTP verb
159
- - For each event, either `binding.handlers[eventName]` resolves to an instance method, OR `extension.events[eventName].handler` is a function
160
-
161
- Validation failures throw at boot with a message identifying the integration, binding, and field.
162
-
163
- ## Reverse-lookup helpers
164
-
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:
166
-
167
- ```javascript
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
- );
182
- ```
183
-
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.
204
-
205
- ## See also
206
-
207
- - [ADR-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) — the three-tier taxonomy (Core Plugins / Application Extensions / Integration Extensions)
208
- - [WEBHOOK-QUICKSTART](./WEBHOOK-QUICKSTART.md) — per-account `Definition.webhooks: true` pattern
209
- - `extension.js` — the validation + flattening helpers (`validateExtensionBinding`, `getExtensionRoutes`, `getExtensionWorkers`)
@@ -1,233 +0,0 @@
1
- /**
2
- * Tier 3 — Integration Extensions
3
- *
4
- * An Integration Extension is a reusable bundle exported by an API module (or a
5
- * shared extensions library) that contributes routes, events, queues, and workers
6
- * to a consumer integration. The integration binds the bundle declaratively via
7
- * `static Definition.extensions` and the framework merges its contributions into
8
- * the integration's effective definition at instantiation time.
9
- *
10
- * Extension bundle shape:
11
- *
12
- * {
13
- * name: string, // required, unique within the API module
14
- * routes?: Array<{ // optional, mounted alongside Definition.routes
15
- * path: string,
16
- * method: 'GET' | 'POST' | 'PUT' | 'DELETE' | ...,
17
- * event: string // must exist in `events` below
18
- * }>,
19
- * events?: { // optional, merged into instance.events
20
- * [eventName]: {
21
- * type?: string, // e.g. 'LIFE_CYCLE_EVENT'
22
- * handler: Function // default handler; integration may override per-binding
23
- * }
24
- * },
25
- * queues?: Array<Object>, // reserved — Phase 2
26
- * workers?: Array<Object> // reserved — Phase 2
27
- * }
28
- *
29
- * Integration binding shape (on the integration's static Definition.extensions):
30
- *
31
- * extensions: {
32
- * hubspotWebhooks: { // local binding name (developer's choice)
33
- * extension: hubspot.extensions.webhooks,
34
- * handlers: { // optional override map
35
- * HUBSPOT_WEBHOOK: 'onHubSpotEvent' // event → method name on the integration
36
- * }
37
- * }
38
- * }
39
- *
40
- * The same extension may be bound multiple times under different local names; the
41
- * binding key is the developer-controlled local handle, not a global registry key.
42
- */
43
-
44
- const KNOWN_HTTP_METHODS = new Set([
45
- 'get',
46
- 'post',
47
- 'put',
48
- 'patch',
49
- 'delete',
50
- 'options',
51
- 'head',
52
- ]);
53
-
54
- /**
55
- * Validate the shape of an extension bundle and its binding.
56
- *
57
- * @param {Object} extension - The extension bundle to validate.
58
- * @param {string} bindingName - The local binding key (for error context).
59
- * @param {string} integrationName - The integration's Definition.name (for error context).
60
- * @param {Object} [binding] - Optional full binding object; if provided, also validates binding.handlers.
61
- * @throws {Error} If the extension is missing required fields or is internally inconsistent.
62
- */
63
- function validateExtensionBinding(extension, bindingName, integrationName, binding) {
64
- const ctx = `Integration "${integrationName}" extension binding "${bindingName}"`;
65
-
66
- if (!extension || typeof extension !== 'object') {
67
- throw new Error(`${ctx}: extension must be an object`);
68
- }
69
- if (!extension.name || typeof extension.name !== 'string') {
70
- throw new Error(`${ctx}: extension is missing required "name" field`);
71
- }
72
-
73
- const events = extension.events || {};
74
- if (typeof events !== 'object' || Array.isArray(events)) {
75
- throw new Error(
76
- `${ctx}: extension "${extension.name}" "events" must be an object keyed by event name`
77
- );
78
- }
79
-
80
- // Validate each event's shape — handler, when present, must be a function.
81
- for (const [eventName, eventDef] of Object.entries(events)) {
82
- if (!eventDef || typeof eventDef !== 'object') {
83
- throw new Error(
84
- `${ctx}: extension "${extension.name}" event "${eventName}" must be an object`
85
- );
86
- }
87
- if (
88
- eventDef.handler !== undefined &&
89
- typeof eventDef.handler !== 'function'
90
- ) {
91
- throw new Error(
92
- `${ctx}: extension "${extension.name}" event "${eventName}" "handler" must be a function`
93
- );
94
- }
95
- }
96
-
97
- const routes = extension.routes || [];
98
- if (!Array.isArray(routes)) {
99
- throw new Error(
100
- `${ctx}: extension "${extension.name}" "routes" must be an array`
101
- );
102
- }
103
-
104
- for (const route of routes) {
105
- if (!route || typeof route !== 'object') {
106
- throw new Error(
107
- `${ctx}: extension "${extension.name}" has a malformed route entry`
108
- );
109
- }
110
- if (typeof route.path !== 'string' || route.path.length === 0) {
111
- throw new Error(
112
- `${ctx}: extension "${extension.name}" route is missing "path"`
113
- );
114
- }
115
- if (typeof route.method !== 'string') {
116
- throw new Error(
117
- `${ctx}: extension "${extension.name}" route "${route.path}" is missing "method"`
118
- );
119
- }
120
- if (!KNOWN_HTTP_METHODS.has(route.method.toLowerCase())) {
121
- throw new Error(
122
- `${ctx}: extension "${extension.name}" route "${route.path}" has unsupported method "${route.method}"`
123
- );
124
- }
125
- if (typeof route.event !== 'string' || route.event.length === 0) {
126
- throw new Error(
127
- `${ctx}: extension "${extension.name}" route "${route.path}" is missing "event"`
128
- );
129
- }
130
- if (!Object.prototype.hasOwnProperty.call(events, route.event)) {
131
- throw new Error(
132
- `${ctx}: extension "${extension.name}" route "${route.path}" references event "${route.event}" which is not declared in extension.events`
133
- );
134
- }
135
- }
136
-
137
- // Validate binding.handlers if a binding was supplied.
138
- if (binding && binding.handlers !== undefined) {
139
- if (
140
- typeof binding.handlers !== 'object' ||
141
- Array.isArray(binding.handlers) ||
142
- binding.handlers === null
143
- ) {
144
- throw new Error(
145
- `${ctx}: "handlers" must be an object keyed by event name`
146
- );
147
- }
148
- for (const [eventName, methodRef] of Object.entries(binding.handlers)) {
149
- if (typeof methodRef !== 'string' || methodRef.length === 0) {
150
- throw new Error(
151
- `${ctx}: handler for event "${eventName}" must be a non-empty method name string (got ${typeof methodRef})`
152
- );
153
- }
154
- if (!Object.prototype.hasOwnProperty.call(events, eventName)) {
155
- throw new Error(
156
- `${ctx}: binding.handlers references event "${eventName}" which is not declared in extension "${extension.name}".events — check for typos`
157
- );
158
- }
159
- }
160
- }
161
- }
162
-
163
- /**
164
- * Get the flattened list of extension-contributed routes for an integration class.
165
- * Each route carries the binding name and extension name alongside the route fields
166
- * so the router builder can produce useful boot-time logs.
167
- *
168
- * @param {Function} IntegrationClass - A class extending IntegrationBase.
169
- * @returns {Array<{bindingName: string, extensionName: string, path: string, method: string, event: string}>}
170
- */
171
- function getExtensionRoutes(IntegrationClass) {
172
- const extensions = IntegrationClass?.Definition?.extensions || {};
173
- const integrationName = IntegrationClass?.Definition?.name;
174
- const flat = [];
175
- for (const [bindingName, binding] of Object.entries(extensions)) {
176
- // Fail fast: surface bad bindings at boot, not at first request.
177
- // Mirrors the validation that _mergeExtensions does at instance time.
178
- validateExtensionBinding(
179
- binding && binding.extension,
180
- bindingName,
181
- integrationName,
182
- binding
183
- );
184
- const routes = binding.extension.routes || [];
185
- for (const route of routes) {
186
- flat.push({
187
- bindingName,
188
- extensionName: binding.extension.name,
189
- path: route.path,
190
- method: route.method,
191
- event: route.event,
192
- });
193
- }
194
- }
195
- return flat;
196
- }
197
-
198
- /**
199
- * Get the flattened list of extension-contributed workers for an integration class.
200
- * Reserved for Phase 2 — today the per-integration QueueWorker handles all events
201
- * by name, so extension-contributed events flow through it without a dedicated worker.
202
- *
203
- * @param {Function} IntegrationClass - A class extending IntegrationBase.
204
- * @returns {Array<Object>}
205
- */
206
- function getExtensionWorkers(IntegrationClass) {
207
- const extensions = IntegrationClass?.Definition?.extensions || {};
208
- const integrationName = IntegrationClass?.Definition?.name;
209
- const flat = [];
210
- for (const [bindingName, binding] of Object.entries(extensions)) {
211
- validateExtensionBinding(
212
- binding && binding.extension,
213
- bindingName,
214
- integrationName,
215
- binding
216
- );
217
- const workers = binding.extension.workers || [];
218
- for (const worker of workers) {
219
- flat.push({
220
- bindingName,
221
- extensionName: binding.extension.name,
222
- ...worker,
223
- });
224
- }
225
- }
226
- return flat;
227
- }
228
-
229
- module.exports = {
230
- validateExtensionBinding,
231
- getExtensionRoutes,
232
- getExtensionWorkers,
233
- };