@friggframework/core 2.0.0-next.78 → 2.0.0-next.79

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.
@@ -154,9 +154,9 @@ const createQueueWorker = (integrationClass) => {
154
154
  params.data.processId,
155
155
  integrationClass
156
156
  );
157
- if (integrationInstance?.status === 'DISABLED') {
157
+ if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
158
158
  console.warn(
159
- `[${integrationClass.Definition.name}] Integration for process ${params.data.processId} is DISABLED. Discarding ${params.event} message.`
159
+ `[${integrationClass.Definition.name}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
160
160
  );
161
161
  return;
162
162
  }
@@ -170,9 +170,9 @@ const createQueueWorker = (integrationClass) => {
170
170
  );
171
171
  return;
172
172
  }
173
- if (integrationInstance.status === 'DISABLED') {
173
+ if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
174
174
  console.warn(
175
- `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} is DISABLED. Discarding ${params.event} message.`
175
+ `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
176
176
  );
177
177
  return;
178
178
  }
@@ -248,6 +248,15 @@ class IntegrationBase {
248
248
  modules[key] = module;
249
249
  this[key] = module;
250
250
  }
251
+
252
+ // Wire the Delegate pattern so Module can notify this integration
253
+ // of events it cannot handle itself (e.g. credential invalidation
254
+ // needing an Integration.status flip). Without this, Module.notify
255
+ // silently no-ops and Integration.status never updates on auth
256
+ // failure.
257
+ if (module && typeof module === 'object') {
258
+ module.delegate = this;
259
+ }
251
260
  }
252
261
 
253
262
  return modules;
@@ -530,6 +539,35 @@ class IntegrationBase {
530
539
  // For backward compatibility, this is a no-op
531
540
  return;
532
541
  }
542
+
543
+ /**
544
+ * Receives notifications from modules (the Delegate pattern) when
545
+ * something integration-level needs attention. Today this catches the
546
+ * `CREDENTIAL_INVALIDATED` event Module fires from `markCredentialsInvalid`
547
+ * and flips this integration's status to DISABLED so the queue worker
548
+ * stops processing further webhooks until the user re-authorizes.
549
+ *
550
+ * Modules are wired to this delegate in `_appendModules()`, which runs
551
+ * during `setIntegrationRecord()` — this covers every construction path
552
+ * (HTTP read, queue worker, create/update/delete flows, etc.).
553
+ *
554
+ * The delegate string below must match `Module.DLGT_CREDENTIAL_INVALIDATED`
555
+ * in `packages/core/modules/module.js`.
556
+ *
557
+ * @param {Object} notifier - The module that fired the event
558
+ * @param {string} delegateString - Event type string
559
+ * @param {Object} [object] - Optional event payload
560
+ * @returns {Promise<void>}
561
+ */
562
+ async receiveNotification(notifier, delegateString, object = null) {
563
+ if (delegateString !== 'CREDENTIAL_INVALIDATED') return;
564
+ 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';
570
+ }
533
571
  }
534
572
 
535
573
  module.exports = { IntegrationBase };
@@ -190,6 +190,7 @@ function createIntegrationRouter() {
190
190
  const processAuthorizationCallback = new ProcessAuthorizationCallback({
191
191
  moduleRepository,
192
192
  credentialRepository,
193
+ integrationRepository,
193
194
  moduleDefinitions:
194
195
  getModulesDefinitionFromIntegrationClasses(integrationClasses),
195
196
  });
@@ -26,6 +26,15 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
26
26
  return records.map((doc) => this._mapIntegration(doc));
27
27
  }
28
28
 
29
+ async findIntegrationsByEntityId(entityId) {
30
+ const objectId = toObjectId(entityId);
31
+ if (!objectId) return [];
32
+ const records = await findMany(this.prisma, 'Integration', {
33
+ entityIds: objectId,
34
+ });
35
+ return records.map((doc) => this._mapIntegration(doc));
36
+ }
37
+
29
38
  async deleteIntegrationById(integrationId) {
30
39
  const objectId = toObjectId(integrationId);
31
40
  if (!objectId) return { acknowledged: true, deletedCount: 0 };
@@ -122,6 +122,23 @@ class IntegrationRepositoryInterface {
122
122
  async updateIntegrationConfig(integrationId, config) {
123
123
  throw new Error('Method updateIntegrationConfig must be implemented by subclass');
124
124
  }
125
+
126
+ /**
127
+ * Find all integrations whose entity set includes the given entity ID.
128
+ *
129
+ * Used by the authorization callback flow to walk up from a re-authorized
130
+ * entity to its parent integrations so that any in a broken state (ERROR,
131
+ * DISABLED) can be restored to ENABLED.
132
+ *
133
+ * @param {string|number} entityId - Entity ID
134
+ * @returns {Promise<Array>} Array of integration objects (possibly empty)
135
+ * @abstract
136
+ */
137
+ async findIntegrationsByEntityId(entityId) {
138
+ throw new Error(
139
+ 'Method findIntegrationsByEntityId must be implemented by subclass'
140
+ );
141
+ }
125
142
  }
126
143
 
127
144
  module.exports = { IntegrationRepositoryInterface };
@@ -200,6 +200,33 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
200
200
  return true; // Mongoose compatibility
201
201
  }
202
202
 
203
+ /**
204
+ * Find all integrations whose entity set includes the given entity ID.
205
+ *
206
+ * @param {string} entityId - Entity ID (MongoDB ObjectId as string)
207
+ * @returns {Promise<Array>} Array of integration objects (possibly empty)
208
+ */
209
+ async findIntegrationsByEntityId(entityId) {
210
+ const integrations = await this.prisma.integration.findMany({
211
+ where: {
212
+ entityIds: { has: entityId },
213
+ },
214
+ include: {
215
+ entities: true,
216
+ },
217
+ });
218
+
219
+ return integrations.map((integration) => ({
220
+ id: integration.id,
221
+ entitiesIds: integration.entities.map((e) => e.id),
222
+ userId: integration.userId,
223
+ config: integration.config,
224
+ version: integration.version,
225
+ status: integration.status,
226
+ messages: integration.messages,
227
+ }));
228
+ }
229
+
203
230
  /**
204
231
  * Create a new integration
205
232
  * Replaces: IntegrationModel.create({ entities, user, config })
@@ -347,6 +347,39 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
347
347
  messages: converted.messages,
348
348
  };
349
349
  }
350
+
351
+ /**
352
+ * Find all integrations whose entity set includes the given entity ID.
353
+ *
354
+ * @param {string|number} entityId - Entity ID (string from application layer)
355
+ * @returns {Promise<Array>} Array of integration objects with string IDs (possibly empty)
356
+ */
357
+ async findIntegrationsByEntityId(entityId) {
358
+ const intEntityId = this._convertId(entityId);
359
+ const integrations = await this.prisma.integration.findMany({
360
+ where: {
361
+ entities: {
362
+ some: { id: intEntityId },
363
+ },
364
+ },
365
+ include: {
366
+ entities: true,
367
+ },
368
+ });
369
+
370
+ return integrations.map((integration) => {
371
+ const converted = this._convertIntegrationIds(integration);
372
+ return {
373
+ id: converted.id,
374
+ entitiesIds: converted.entities.map((e) => e.id),
375
+ userId: converted.userId,
376
+ config: converted.config,
377
+ version: converted.version,
378
+ status: converted.status,
379
+ messages: converted.messages,
380
+ };
381
+ });
382
+ }
350
383
  }
351
384
 
352
385
  module.exports = { IntegrationRepositoryPostgres };
@@ -46,6 +46,19 @@ class TestIntegrationRepository {
46
46
  return record || null;
47
47
  }
48
48
 
49
+ async findIntegrationsByEntityId(entityId) {
50
+ const target = String(entityId);
51
+ const results = Array.from(this.store.values()).filter((r) =>
52
+ (r.entitiesIds || []).map(String).includes(target)
53
+ );
54
+ this.operationHistory.push({
55
+ operation: 'findByEntityId',
56
+ entityId: target,
57
+ count: results.length,
58
+ });
59
+ return results;
60
+ }
61
+
49
62
  async updateIntegrationMessages(id, type, title, body, timestamp) {
50
63
  const rec = this.store.get(id);
51
64
  if (!rec) {
package/modules/module.js CHANGED
@@ -37,6 +37,10 @@ class Module extends Delegate {
37
37
  this.credentialRepository = createCredentialRepository();
38
38
  this.moduleRepository = createModuleRepository();
39
39
 
40
+ // Module → parent delegate (typically IntegrationBase) events
41
+ this.DLGT_CREDENTIAL_INVALIDATED = 'CREDENTIAL_INVALIDATED';
42
+ this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
43
+
40
44
  Object.assign(this, this.definition.requiredAuthMethods);
41
45
 
42
46
  const apiParams = {
@@ -142,6 +146,30 @@ class Module extends Delegate {
142
146
  // Keep the in-memory snapshot consistent so that callers can read the
143
147
  // updated state without another fetch.
144
148
  this.credential.authIsValid = false;
149
+
150
+ // Propagate upward so a parent delegate (e.g. IntegrationBase) can
151
+ // react — for instance by flipping Integration.status to DISABLED.
152
+ // Delegate.notify is a silent no-op when this.delegate is null, so
153
+ // Module instances constructed outside of an Integration context
154
+ // (e.g. during ProcessAuthorizationCallback) remain unaffected.
155
+ //
156
+ // Best-effort: this method is invoked from the OAuth2Requester 401
157
+ // refresh catch block, which depends on us NOT throwing. A DB hiccup
158
+ // in the downstream status flip must not alter refreshAuth's
159
+ // documented `return false` contract. The credential has already
160
+ // been persisted as invalid; integrations left un-flipped can be
161
+ // recovered by the next retry or by operator intervention.
162
+ try {
163
+ await this.notify(this.DLGT_CREDENTIAL_INVALIDATED, {
164
+ credentialId: this.credential.id,
165
+ moduleName: this.name,
166
+ });
167
+ } catch (err) {
168
+ console.error(
169
+ `[Frigg] Failed to propagate CREDENTIAL_INVALIDATED for module ${this.name}:`,
170
+ err?.message || err
171
+ );
172
+ }
145
173
  }
146
174
 
147
175
  async deauthorize() {
@@ -1,17 +1,30 @@
1
1
  const { Module } = require('../module');
2
2
  const { ModuleConstants } = require('../ModuleConstants');
3
3
 
4
+ // Statuses considered "broken" for an integration whose credentials have just
5
+ // been successfully re-authorized. Both ERROR (system-driven auth failure) and
6
+ // DISABLED (user paused the integration) are flipped back to ENABLED when the
7
+ // user completes a new authorization flow.
8
+ const STATUSES_RESET_ON_REAUTH = ['ERROR', 'DISABLED'];
9
+
4
10
  class ProcessAuthorizationCallback {
5
11
  /**
6
12
  * @param {Object} params - Configuration parameters.
7
13
  * @param {import('../repositories/module-repository-factory').ModuleRepositoryInterface} params.moduleRepository - Repository for module data operations.
8
14
  * @param {import('../../credential/repositories/credential-repository-factory').CredentialRepositoryInterface} params.credentialRepository - Repository for credential data operations.
9
15
  * @param {Array<Object>} params.moduleDefinitions - Array of module definitions.
16
+ * @param {import('../../integrations/repositories/integration-repository-interface').IntegrationRepositoryInterface} [params.integrationRepository] - Repository for integration data operations. When provided, integrations in a broken state linked to the re-authorized entity are restored to ENABLED.
10
17
  */
11
- constructor({ moduleRepository, credentialRepository, moduleDefinitions }) {
18
+ constructor({
19
+ moduleRepository,
20
+ credentialRepository,
21
+ moduleDefinitions,
22
+ integrationRepository,
23
+ }) {
12
24
  this.moduleRepository = moduleRepository;
13
25
  this.credentialRepository = credentialRepository;
14
26
  this.moduleDefinitions = moduleDefinitions;
27
+ this.integrationRepository = integrationRepository;
15
28
  }
16
29
 
17
30
  async execute(userId, entityType, params) {
@@ -73,6 +86,18 @@ class ProcessAuthorizationCallback {
73
86
  module.credential.id
74
87
  );
75
88
 
89
+ // Best-effort: a hiccup here must not fail a successful re-auth whose
90
+ // credential + entity are already persisted. Operators can recover
91
+ // stuck integrations manually.
92
+ try {
93
+ await this.restoreIntegrationsForEntity(persistedEntity.id);
94
+ } catch (err) {
95
+ console.error(
96
+ `[Frigg] Failed to restore integrations for entity ${persistedEntity.id} after successful re-auth — manual intervention may be needed`,
97
+ err
98
+ );
99
+ }
100
+
76
101
  return {
77
102
  credential_id: module.credential.id,
78
103
  entity_id: persistedEntity.id,
@@ -80,6 +105,25 @@ class ProcessAuthorizationCallback {
80
105
  };
81
106
  }
82
107
 
108
+ async restoreIntegrationsForEntity(entityId) {
109
+ if (!this.integrationRepository) return;
110
+ const integrations =
111
+ await this.integrationRepository.findIntegrationsByEntityId(
112
+ entityId
113
+ );
114
+ for (const integration of integrations) {
115
+ if (STATUSES_RESET_ON_REAUTH.includes(integration.status)) {
116
+ console.log(
117
+ `[Frigg] Restoring integration ${integration.id} from ${integration.status} to ENABLED after successful re-auth (entityId=${entityId})`
118
+ );
119
+ await this.integrationRepository.updateIntegrationStatus(
120
+ integration.id,
121
+ 'ENABLED'
122
+ );
123
+ }
124
+ }
125
+ }
126
+
83
127
  async onTokenUpdate(module, moduleDefinition, userId) {
84
128
  const credentialDetails =
85
129
  await moduleDefinition.requiredAuthMethods.getCredentialDetails(
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-next.78",
4
+ "version": "2.0.0-next.79",
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-next.78",
42
- "@friggframework/prettier-config": "2.0.0-next.78",
43
- "@friggframework/test": "2.0.0-next.78",
41
+ "@friggframework/eslint-config": "2.0.0-next.79",
42
+ "@friggframework/prettier-config": "2.0.0-next.79",
43
+ "@friggframework/test": "2.0.0-next.79",
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": "c8e0c0c725a9e49b76d4e095927a443640bf9ec9"
83
+ "gitHead": "b2a04b537ad3efb6206d14862f5dff5053829c73"
84
84
  }