@friggframework/core 2.0.0-next.77 → 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.
- package/errors/fetch-error.js +13 -6
- package/handlers/backend-utils.js +4 -4
- package/integrations/integration-base.js +38 -0
- package/integrations/integration-router.js +1 -0
- package/integrations/repositories/integration-repository-documentdb.js +9 -0
- package/integrations/repositories/integration-repository-interface.js +17 -0
- package/integrations/repositories/integration-repository-mongo.js +27 -0
- package/integrations/repositories/integration-repository-postgres.js +33 -0
- package/integrations/tests/doubles/test-integration-repository.js +13 -0
- package/modules/module.js +28 -0
- package/modules/use-cases/process-authorization-callback.js +45 -1
- package/package.json +5 -5
package/errors/fetch-error.js
CHANGED
|
@@ -12,14 +12,14 @@ class FetchError extends BaseError {
|
|
|
12
12
|
constructor(options = {}) {
|
|
13
13
|
const { resource, init, response, responseBody } = options;
|
|
14
14
|
const method = init?.method ?? 'GET';
|
|
15
|
-
|
|
15
|
+
let initText = init
|
|
16
16
|
? init.body instanceof URLSearchParams
|
|
17
17
|
? (() => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
init.body = init.body.toString();
|
|
19
|
+
return JSON.stringify({ init }, null, 2);
|
|
20
|
+
})()
|
|
21
21
|
: JSON.stringify({ init }, null, 2)
|
|
22
|
-
: '';
|
|
22
|
+
: '';
|
|
23
23
|
|
|
24
24
|
let responseBodyText = '<response body is unavailable>';
|
|
25
25
|
if (typeof responseBody === 'string') {
|
|
@@ -35,10 +35,17 @@ class FetchError extends BaseError {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
let responseHeaderText = response
|
|
39
39
|
? JSON.stringify({ headers: responseHeaders }, null, 2)
|
|
40
40
|
: '';
|
|
41
41
|
|
|
42
|
+
// sanitize error reporting
|
|
43
|
+
if (process.env.STAGE !== 'dev') {
|
|
44
|
+
initText = false;
|
|
45
|
+
responseHeaderText = false;
|
|
46
|
+
responseBodyText = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
const messageParts = [
|
|
43
50
|
stripIndent`
|
|
44
51
|
-----------------------------------------------------
|
|
@@ -154,9 +154,9 @@ const createQueueWorker = (integrationClass) => {
|
|
|
154
154
|
params.data.processId,
|
|
155
155
|
integrationClass
|
|
156
156
|
);
|
|
157
|
-
if (integrationInstance?.status
|
|
157
|
+
if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
|
|
158
158
|
console.warn(
|
|
159
|
-
`[${integrationClass.Definition.name}] Integration for process ${params.data.processId} is
|
|
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
|
|
173
|
+
if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
|
|
174
174
|
console.warn(
|
|
175
|
-
`[${integrationClass.Definition.name}] Integration ${params.data.integrationId} is
|
|
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({
|
|
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.
|
|
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.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
43
|
-
"@friggframework/test": "2.0.0-next.
|
|
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": "
|
|
83
|
+
"gitHead": "b2a04b537ad3efb6206d14862f5dff5053829c73"
|
|
84
84
|
}
|