@friggframework/core 2.0.0--canary.5ff5209.0 → 2.0.0--canary.590.094a71b.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.
@@ -0,0 +1,172 @@
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
+ const integrationId = await this.findIntegrationByPortalId(
108
+ evt.portalId,
109
+ 'hubspot'
110
+ );
111
+ if (!integrationId) continue;
112
+ await this.queueWebhook({
113
+ integrationId,
114
+ body: evt,
115
+ event: 'HUBSPOT_WEBHOOK',
116
+ });
117
+ }
118
+ res.status(200).json({ received: req.body.length });
119
+ },
120
+ },
121
+ HUBSPOT_WEBHOOK: {
122
+ type: 'LIFE_CYCLE_EVENT',
123
+ handler: async function ({ data }) {
124
+ // default no-op; integrations override via binding.handlers
125
+ },
126
+ },
127
+ },
128
+ };
129
+ ```
130
+
131
+ Then expose it on your api-module's index:
132
+
133
+ ```javascript
134
+ // @friggframework/api-module-hubspot/index.js
135
+ module.exports = {
136
+ Definition: require('./api-module-definition'),
137
+ extensions: {
138
+ webhooks: require('./extensions/webhooks'),
139
+ crmCards: require('./extensions/crm-cards'),
140
+ timeline: require('./extensions/timeline'),
141
+ },
142
+ };
143
+ ```
144
+
145
+ ## Contract enforced by the framework
146
+
147
+ At `initialize()`, the framework validates each binding:
148
+
149
+ - `extension` must be an object with a `name`
150
+ - `extension.events` must be an object keyed by event name (if present)
151
+ - `extension.routes` must be an array (if present)
152
+ - Every route's `event` must exist in `extension.events`
153
+ - Every route's `method` must be a known HTTP verb
154
+ - For each event, either `binding.handlers[eventName]` resolves to an instance method, OR `extension.events[eventName].handler` is a function
155
+
156
+ Validation failures throw at boot with a message identifying the integration, binding, and field.
157
+
158
+ ## Reverse-lookup helper
159
+
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:
161
+
162
+ ```javascript
163
+ const integrationId = await this.findIntegrationByPortalId(externalId, 'hubspot');
164
+ ```
165
+
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.
167
+
168
+ ## See also
169
+
170
+ - [ADR-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) — the three-tier taxonomy (Core Plugins / Application Extensions / Integration Extensions)
171
+ - [WEBHOOK-QUICKSTART](./WEBHOOK-QUICKSTART.md) — per-account `Definition.webhooks: true` pattern
172
+ - `extension.js` — the validation + flattening helpers (`validateExtensionBinding`, `getExtensionRoutes`, `getExtensionWorkers`)
@@ -0,0 +1,233 @@
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
+ };
@@ -10,6 +10,11 @@ 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');
13
18
 
14
19
  module.exports = {
15
20
  IntegrationBase,
@@ -18,4 +23,7 @@ module.exports = {
18
23
  checkRequiredParams,
19
24
  getModulesDefinitionFromIntegrationClasses,
20
25
  LoadIntegrationContextUseCase,
26
+ validateExtensionBinding,
27
+ getExtensionRoutes,
28
+ getExtensionWorkers,
21
29
  };
@@ -11,6 +11,7 @@ const {
11
11
  const {
12
12
  UpdateIntegrationMessages,
13
13
  } = require('./use-cases/update-integration-messages');
14
+ const { validateExtensionBinding } = require('./extension');
14
15
 
15
16
  const constantsToBeMigrated = {
16
17
  defaultEvents: {
@@ -60,6 +61,9 @@ class IntegrationBase {
60
61
  supportedVersions: [], // Eventually usable for deprecation and future test version purposes
61
62
 
62
63
  modules: {},
64
+ // Tier 3 Integration Extensions — see packages/core/integrations/EXTENSIONS.md
65
+ // Shape: { [bindingName]: { extension, handlers?: { [eventName]: methodName } } }
66
+ extensions: {},
63
67
  display: {
64
68
  name: 'Integration Name',
65
69
  logo: '',
@@ -504,6 +508,158 @@ class IntegrationBase {
504
508
  };
505
509
  }
506
510
 
511
+ /**
512
+ * Merge Tier 3 Integration Extension events into `this.events`.
513
+ *
514
+ * For each binding declared on `static Definition.extensions`, this method:
515
+ * 1. Validates the extension bundle shape and binding handlers
516
+ * 2. For each event the extension declares, resolves the handler in priority order:
517
+ * a. Subclass-defined `this.events[eventName]` (set in the constructor) — wins; if a
518
+ * binding tried to override that event with `handlers`, we log a warning so the
519
+ * author knows their override is shadowed.
520
+ * b. A method-name string in `binding.handlers[eventName]` → method on this instance
521
+ * c. The extension's own default `handler` function
522
+ * d. Otherwise throw — neither side provided a handler
523
+ * 3. Binds the resolved function to this instance and writes it to `this.events[eventName]`
524
+ *
525
+ * Two bindings declaring the same event throw a deterministic conflict error — silent
526
+ * "first/last writer wins" makes routing bugs nearly impossible to diagnose.
527
+ *
528
+ * @private
529
+ */
530
+ _mergeExtensions() {
531
+ const extensions = this.constructor.Definition?.extensions || {};
532
+ const integrationName = this.constructor.Definition?.name;
533
+ // Tracks which event names have been claimed by an extension binding during
534
+ // this merge — distinct from subclass-defined events on `this.events`.
535
+ const mergedByExtension = new Map();
536
+
537
+ for (const [bindingName, binding] of Object.entries(extensions)) {
538
+ if (!binding || typeof binding !== 'object') {
539
+ throw new Error(
540
+ `Integration "${integrationName}" extension binding "${bindingName}" must be an object`
541
+ );
542
+ }
543
+ const { extension, handlers = {} } = binding;
544
+ validateExtensionBinding(
545
+ extension,
546
+ bindingName,
547
+ integrationName,
548
+ binding
549
+ );
550
+
551
+ const extEvents = extension.events || {};
552
+ for (const [eventName, eventDef] of Object.entries(extEvents)) {
553
+ // Conflict detection: another extension binding already claimed this event name.
554
+ if (mergedByExtension.has(eventName)) {
555
+ const prev = mergedByExtension.get(eventName);
556
+ throw new Error(
557
+ `Integration "${integrationName}" extension event conflict: ` +
558
+ `event "${eventName}" is declared by both binding "${prev}" and binding "${bindingName}" — ` +
559
+ `use distinct event names per binding or omit duplicates`
560
+ );
561
+ }
562
+
563
+ // Subclass shadowing: if the subclass set this.events[eventName] before initialize(),
564
+ // it wins. Warn if the binding tried to wire an override that's now ignored.
565
+ if (this.events[eventName]) {
566
+ if (typeof handlers[eventName] === 'string') {
567
+ console.warn(
568
+ `[Frigg] Integration "${integrationName}" binding "${bindingName}": ` +
569
+ `handler "${handlers[eventName]}" for event "${eventName}" is ignored because ` +
570
+ `this.events["${eventName}"] was already set (subclass constructor or earlier merge)`
571
+ );
572
+ }
573
+ continue;
574
+ }
575
+
576
+ let fn;
577
+ const override = handlers[eventName];
578
+ if (typeof override === 'string') {
579
+ if (typeof this[override] !== 'function') {
580
+ throw new Error(
581
+ `Integration "${integrationName}" extension binding "${bindingName}": handler method "${override}" not found on instance`
582
+ );
583
+ }
584
+ fn = this[override];
585
+ } else if (typeof eventDef.handler === 'function') {
586
+ fn = eventDef.handler;
587
+ } else {
588
+ throw new Error(
589
+ `Extension "${extension.name}" event "${eventName}" has no default handler and binding "${bindingName}" did not provide one`
590
+ );
591
+ }
592
+
593
+ this.events[eventName] = {
594
+ type: eventDef.type,
595
+ handler: fn.bind(this),
596
+ };
597
+ mergedByExtension.set(eventName, bindingName);
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Reverse-lookup helper: find an integration ID by a module entity's externalId.
604
+ *
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.
610
+ *
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.
616
+ *
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.
621
+ */
622
+ async findIntegrationByPortalId(externalId, moduleName) {
623
+ if (!externalId) return null;
624
+ const {
625
+ createModuleRepository,
626
+ } = require('../modules/repositories/module-repository-factory');
627
+ const moduleRepository = createModuleRepository();
628
+
629
+ const filter = { externalId: String(externalId) };
630
+ if (moduleName) filter.moduleName = moduleName;
631
+
632
+ const entity = await moduleRepository.findEntity(filter);
633
+ if (!entity) {
634
+ console.log(
635
+ `[Frigg] findIntegrationByPortalId: no entity for externalId=${externalId}${
636
+ moduleName ? ` moduleName=${moduleName}` : ''
637
+ }`
638
+ );
639
+ return null;
640
+ }
641
+
642
+ const integrations =
643
+ await this.integrationRepository.findIntegrationsByEntityId(
644
+ entity.id
645
+ );
646
+ if (!integrations || integrations.length === 0) {
647
+ console.log(
648
+ `[Frigg] findIntegrationByPortalId: entity ${entity.id} has no owning integrations (orphan)`
649
+ );
650
+ return null;
651
+ }
652
+ if (integrations.length > 1) {
653
+ const ids = integrations.map((i) => i.id).join(', ');
654
+ throw new Error(
655
+ `findIntegrationByPortalId: ambiguous resolution — externalId=${externalId}` +
656
+ `${moduleName ? ` moduleName=${moduleName}` : ''} maps to ${integrations.length} integrations [${ids}]. ` +
657
+ `Refusing to pick one to avoid cross-tenant routing.`
658
+ );
659
+ }
660
+ return integrations[0].id;
661
+ }
662
+
507
663
  async initialize() {
508
664
  try {
509
665
  const additionalUserActions = await this.loadDynamicUserActions();
@@ -512,6 +668,7 @@ class IntegrationBase {
512
668
  this.addError(e);
513
669
  }
514
670
 
671
+ this._mergeExtensions();
515
672
  this.registerEventHandlers();
516
673
  }
517
674
 
@@ -560,13 +717,25 @@ class IntegrationBase {
560
717
  * @returns {Promise<void>}
561
718
  */
562
719
  async receiveNotification(notifier, delegateString, object = null) {
563
- if (delegateString !== 'CREDENTIAL_INVALIDATED') return;
564
720
  if (!this.id) return;
565
- console.log(
566
- `[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
567
- );
568
- await this.updateIntegrationStatus.execute(this.id, 'ERROR');
569
- this.status = 'ERROR';
721
+
722
+ if (delegateString === 'CREDENTIAL_INVALIDATED') {
723
+ console.log(
724
+ `[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
725
+ );
726
+ await this.updateIntegrationStatus.execute(this.id, 'ERROR');
727
+ this.status = 'ERROR';
728
+ return;
729
+ }
730
+
731
+ if (delegateString === 'CREDENTIAL_VALIDATED') {
732
+ if (this.status !== 'ERROR') return;
733
+ console.log(
734
+ `[Frigg] Module ${notifier?.name || '?'} reported valid credentials for integration ${this.id} — clearing ERROR → ENABLED`
735
+ );
736
+ await this.updateIntegrationStatus.execute(this.id, 'ENABLED');
737
+ this.status = 'ENABLED';
738
+ }
570
739
  }
571
740
  }
572
741
 
@@ -531,13 +531,33 @@ function setEntityRoutes(router, authenticateUser, useCases) {
531
531
  'data',
532
532
  ]);
533
533
 
534
- const entityDetails = await processAuthorizationCallback.execute(
535
- userId,
536
- params.entityType,
537
- params.data
534
+ const dataKeys =
535
+ params.data && typeof params.data === 'object'
536
+ ? Object.keys(params.data)
537
+ : [];
538
+ console.log(
539
+ `[Frigg] POST /api/authorize userId=${userId} entityType=${params.entityType} dataKeys=${JSON.stringify(dataKeys)}`
538
540
  );
539
541
 
540
- res.json(entityDetails);
542
+ try {
543
+ const entityDetails =
544
+ await processAuthorizationCallback.execute(
545
+ userId,
546
+ params.entityType,
547
+ params.data
548
+ );
549
+
550
+ console.log(
551
+ `[Frigg] POST /api/authorize success userId=${userId} entityType=${params.entityType} credentialId=${entityDetails?.credential_id} entityId=${entityDetails?.entity_id}`
552
+ );
553
+
554
+ res.json(entityDetails);
555
+ } catch (err) {
556
+ console.error(
557
+ `[Frigg] POST /api/authorize failed userId=${userId} entityType=${params.entityType} error=${err?.message || err}`
558
+ );
559
+ throw err;
560
+ }
541
561
  })
542
562
  );
543
563