@friggframework/core 2.0.0--canary.590.ffb7d1b.0 → 2.0.0--canary.590.b2cd5e2.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.
@@ -11,6 +11,12 @@ const {
11
11
  const {
12
12
  FindIntegrationContextByExternalEntityIdUseCase,
13
13
  } = require('../../integrations/use-cases/find-integration-context-by-external-entity-id');
14
+ const {
15
+ FindIntegrationByEntityExternalIdUseCase,
16
+ } = require('../../integrations/use-cases/find-integration-by-entity-external-id');
17
+ const {
18
+ ListIntegrationsByEntityExternalIdUseCase,
19
+ } = require('../../integrations/use-cases/list-integrations-by-entity-external-id');
14
20
  const {
15
21
  GetIntegrationsForUser,
16
22
  } = require('../../integrations/use-cases/get-integrations-for-user');
@@ -69,6 +75,18 @@ function createIntegrationCommands({ integrationClass }) {
69
75
  loadIntegrationContextUseCase: loadIntegrationContextUseCase,
70
76
  });
71
77
 
78
+ const findIntegrationByEntityExternalIdUseCase =
79
+ new FindIntegrationByEntityExternalIdUseCase({
80
+ integrationRepository,
81
+ moduleRepository,
82
+ });
83
+
84
+ const listIntegrationsByEntityExternalIdUseCase =
85
+ new ListIntegrationsByEntityExternalIdUseCase({
86
+ integrationRepository,
87
+ moduleRepository,
88
+ });
89
+
72
90
  const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
73
91
  integrationRepository,
74
92
  integrationClasses: [integrationClass],
@@ -96,6 +114,41 @@ function createIntegrationCommands({ integrationClass }) {
96
114
  }
97
115
  },
98
116
 
117
+ /**
118
+ * Resolve an externalId (e.g. HubSpot portalId, Slack team_id) to a
119
+ * single integration ID. Throws on ambiguous resolution at either the
120
+ * entity or integration layer — cross-tenant routing is refused.
121
+ *
122
+ * @param {string|number} externalId - Provider's stable identifier.
123
+ * @param {string} [moduleName] - Disambiguates when multiple modules in
124
+ * the same app could carry colliding externalIds.
125
+ * @returns {Promise<string|null>} Integration ID, or null on no match.
126
+ * @throws {Error} On ambiguous resolution (multiple entities or
127
+ * multiple owning integrations).
128
+ */
129
+ async findIntegrationByEntityExternalId(externalId, moduleName) {
130
+ return findIntegrationByEntityExternalIdUseCase.execute({
131
+ externalId,
132
+ moduleName,
133
+ });
134
+ },
135
+
136
+ /**
137
+ * List all integration IDs whose module entities match an externalId.
138
+ * Use when one externalId is expected to map to multiple integrations
139
+ * (intentional fan-out). Does not throw on ambiguity.
140
+ *
141
+ * @param {string|number} externalId - Provider's stable identifier.
142
+ * @param {string} [moduleName] - Disambiguates across modules.
143
+ * @returns {Promise<Array<string>>} Array of integration IDs (possibly empty).
144
+ */
145
+ async listIntegrationsByEntityExternalId(externalId, moduleName) {
146
+ return listIntegrationsByEntityExternalIdUseCase.execute({
147
+ externalId,
148
+ moduleName,
149
+ });
150
+ },
151
+
99
152
  async loadIntegrationContextById(integrationId) {
100
153
  try {
101
154
  const context = await loadIntegrationContextUseCase.execute({
@@ -104,12 +104,12 @@ 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
- // Core helper is generic; the extension wraps it in
108
- // HubSpot vocabulary for its own readability:
109
- // findIntegrationByPortalId(portalId) =
110
- // findIntegrationByEntityExternalId(portalId, 'hubspot')
107
+ // Reverse-lookup is exposed via friggCommands, the
108
+ // canonical access pattern for cross-cutting lookups.
109
+ // The HubSpot-named wrapper lives in the api-module's
110
+ // extension package.
111
111
  const integrationId =
112
- await this.findIntegrationByEntityExternalId(
112
+ await this.commands.findIntegrationByEntityExternalId(
113
113
  evt.portalId,
114
114
  'hubspot'
115
115
  );
@@ -162,12 +162,15 @@ Validation failures throw at boot with a message identifying the integration, bi
162
162
 
163
163
  ## Reverse-lookup helpers
164
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:
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 helpers are exposed via [`createFriggCommands`](../application/index.js) — the canonical access pattern for cross-cutting lookups:
166
166
 
167
167
  ```javascript
168
+ // inside an integration class
169
+ this.commands = createFriggCommands({ integrationClass: MyIntegration });
170
+
168
171
  // Throws on ambiguous resolution. Use when one externalId is expected
169
172
  // to map to exactly one integration.
170
- const integrationId = await this.findIntegrationByEntityExternalId(
173
+ const integrationId = await this.commands.findIntegrationByEntityExternalId(
171
174
  externalId,
172
175
  'hubspot' // optional moduleName
173
176
  );
@@ -175,7 +178,7 @@ const integrationId = await this.findIntegrationByEntityExternalId(
175
178
  // Returns array. Use when one externalId may legitimately fan out to
176
179
  // multiple integrations (e.g. one upstream account broadcasting to
177
180
  // several Frigg integration records).
178
- const integrationIds = await this.listIntegrationsByEntityExternalId(
181
+ const integrationIds = await this.commands.listIntegrationsByEntityExternalId(
179
182
  externalId,
180
183
  'hubspot'
181
184
  );
@@ -185,18 +188,21 @@ const integrationIds = await this.listIntegrationsByEntityExternalId(
185
188
  - the (externalId, moduleName) tuple matches more than one Entity row, OR
186
189
  - the matched entity is owned by more than one Integration record
187
190
 
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.
191
+ A silent first-match at either layer is a cross-tenant routing risk; the command 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
192
 
190
193
  ### Where platform-named wrappers belong
191
194
 
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:
195
+ The commands are intentionally platform-neutral. Platform-vocabulary wrappers (`findIntegrationByPortalId`, `findIntegrationByTeamId`, `findIntegrationByWorkspaceId`, etc.) belong **inside the api-module's own extension**, not in core:
193
196
 
194
197
  ```javascript
195
198
  // inside @friggframework/api-module-hubspot/extensions/webhooks
196
- async function findIntegrationByPortalId(portalId) {
199
+ async function findIntegrationByPortalId(integration, portalId) {
197
200
  // thin wrapper — reads as self-documenting HubSpot code,
198
- // delegates to the platform-neutral core primitive
199
- return this.findIntegrationByEntityExternalId(portalId, 'hubspot');
201
+ // delegates to the platform-neutral command
202
+ return integration.commands.findIntegrationByEntityExternalId(
203
+ portalId,
204
+ 'hubspot'
205
+ );
200
206
  }
201
207
  ```
202
208
 
@@ -614,135 +614,6 @@ class IntegrationBase {
614
614
  }
615
615
  }
616
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
617
  async initialize() {
747
618
  try {
748
619
  const additionalUserActions = await this.loadDynamicUserActions();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Reverse-lookup use case: resolve an externalId (e.g. HubSpot portalId,
3
+ * Slack team_id, Asana workspace_id) to a single integration ID.
4
+ *
5
+ * Refuses to pick on ambiguous resolution at either layer:
6
+ * - more than one matching Entity row for the (externalId, moduleName) tuple
7
+ * - more than one Integration owning the matched entity
8
+ *
9
+ * A silent first-match in either case is a cross-tenant routing risk.
10
+ * Callers that expect a one-to-many fan-out should use
11
+ * {@link ListIntegrationsByEntityExternalIdUseCase} instead.
12
+ */
13
+ class FindIntegrationByEntityExternalIdUseCase {
14
+ constructor({ integrationRepository, moduleRepository } = {}) {
15
+ if (!integrationRepository) {
16
+ throw new Error('integrationRepository is required');
17
+ }
18
+ if (!moduleRepository) {
19
+ throw new Error('moduleRepository is required');
20
+ }
21
+ this.integrationRepository = integrationRepository;
22
+ this.moduleRepository = moduleRepository;
23
+ }
24
+
25
+ async execute({ externalId, moduleName } = {}) {
26
+ if (!externalId) return null;
27
+
28
+ const filter = { externalId: String(externalId) };
29
+ if (moduleName) filter.moduleName = moduleName;
30
+
31
+ const entities = await this.moduleRepository.findEntities(filter);
32
+ if (!entities || entities.length === 0) {
33
+ console.log(
34
+ `[Frigg] findIntegrationByEntityExternalId: no entity for externalId=${externalId}${
35
+ moduleName ? ` moduleName=${moduleName}` : ''
36
+ }`
37
+ );
38
+ return null;
39
+ }
40
+ if (entities.length > 1) {
41
+ const ids = entities.map((e) => e.id).join(', ');
42
+ throw new Error(
43
+ `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
44
+ `${moduleName ? ` moduleName=${moduleName}` : ''} matches ${entities.length} entities [${ids}]. ` +
45
+ `Refusing to pick one to avoid cross-tenant routing. ` +
46
+ `Pass a moduleName, or use listIntegrationsByEntityExternalId if multiple integrations are expected.`
47
+ );
48
+ }
49
+
50
+ const entity = entities[0];
51
+ const integrations =
52
+ await this.integrationRepository.findIntegrationsByEntityId(
53
+ entity.id
54
+ );
55
+ if (!integrations || integrations.length === 0) {
56
+ console.log(
57
+ `[Frigg] findIntegrationByEntityExternalId: entity ${entity.id} has no owning integrations (orphan)`
58
+ );
59
+ return null;
60
+ }
61
+ if (integrations.length > 1) {
62
+ const ids = integrations.map((i) => i.id).join(', ');
63
+ throw new Error(
64
+ `findIntegrationByEntityExternalId: ambiguous resolution — externalId=${externalId}` +
65
+ `${moduleName ? ` moduleName=${moduleName}` : ''} maps to ${integrations.length} integrations [${ids}]. ` +
66
+ `Refusing to pick one to avoid cross-tenant routing. ` +
67
+ `Use listIntegrationsByEntityExternalId if a one-to-many fan-out is intended.`
68
+ );
69
+ }
70
+ return integrations[0].id;
71
+ }
72
+ }
73
+
74
+ module.exports = { FindIntegrationByEntityExternalIdUseCase };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * List all integration IDs whose module entities match an externalId.
3
+ *
4
+ * Use this instead of {@link FindIntegrationByEntityExternalIdUseCase} when a
5
+ * single externalId is *expected* to map to multiple integrations (intentional
6
+ * fan-out: one upstream account broadcasting to several Frigg integration
7
+ * records, possibly across tenants).
8
+ *
9
+ * Does not throw on ambiguous resolution — that's the whole point.
10
+ */
11
+ class ListIntegrationsByEntityExternalIdUseCase {
12
+ constructor({ integrationRepository, moduleRepository } = {}) {
13
+ if (!integrationRepository) {
14
+ throw new Error('integrationRepository is required');
15
+ }
16
+ if (!moduleRepository) {
17
+ throw new Error('moduleRepository is required');
18
+ }
19
+ this.integrationRepository = integrationRepository;
20
+ this.moduleRepository = moduleRepository;
21
+ }
22
+
23
+ async execute({ externalId, moduleName } = {}) {
24
+ if (!externalId) return [];
25
+
26
+ const filter = { externalId: String(externalId) };
27
+ if (moduleName) filter.moduleName = moduleName;
28
+
29
+ const entities = await this.moduleRepository.findEntities(filter);
30
+ if (!entities || entities.length === 0) return [];
31
+
32
+ const integrationIds = new Set();
33
+ for (const entity of entities) {
34
+ const owners =
35
+ await this.integrationRepository.findIntegrationsByEntityId(
36
+ entity.id
37
+ );
38
+ for (const integration of owners || []) {
39
+ integrationIds.add(integration.id);
40
+ }
41
+ }
42
+ return Array.from(integrationIds);
43
+ }
44
+ }
45
+
46
+ module.exports = { ListIntegrationsByEntityExternalIdUseCase };
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.590.b2cd5e2.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.590.b2cd5e2.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.590.b2cd5e2.0",
43
+ "@friggframework/test": "2.0.0--canary.590.b2cd5e2.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": "b2cd5e23078cc2c77f49d498bedef06862e28fbc"
84
84
  }