@friggframework/core 2.0.0--canary.566.9bc8a35.0 → 2.0.0--canary.578.e866a27.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.
- package/core/Worker.js +36 -0
- package/core/create-handler.js +55 -0
- package/errors/fetch-error.js +13 -6
- package/handlers/backend-utils.js +46 -8
- 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/queues/queuer-util.js +80 -3
package/core/Worker.js
CHANGED
|
@@ -20,15 +20,45 @@ class Worker {
|
|
|
20
20
|
const records = get(params, 'Records');
|
|
21
21
|
const batchItemFailures = [];
|
|
22
22
|
|
|
23
|
+
console.log(
|
|
24
|
+
`[Worker] run: processing ${records.length} record(s)`
|
|
25
|
+
);
|
|
26
|
+
|
|
23
27
|
for (const record of records) {
|
|
28
|
+
// Log record entry with SQS-provided attributes useful for tracing
|
|
29
|
+
// delivery history (ApproximateReceiveCount for retries, etc.).
|
|
30
|
+
let parsedEvent;
|
|
31
|
+
try {
|
|
32
|
+
parsedEvent = JSON.parse(record.body)?.event;
|
|
33
|
+
} catch {
|
|
34
|
+
parsedEvent = undefined;
|
|
35
|
+
}
|
|
36
|
+
console.log(`[Worker] record begin`, {
|
|
37
|
+
messageId: record.messageId,
|
|
38
|
+
event: parsedEvent,
|
|
39
|
+
receiveCount: record.attributes?.ApproximateReceiveCount,
|
|
40
|
+
});
|
|
41
|
+
|
|
24
42
|
try {
|
|
25
43
|
const runParams = JSON.parse(record.body);
|
|
26
44
|
this._validateParams(runParams);
|
|
27
45
|
await this._run(runParams, context);
|
|
46
|
+
console.log(`[Worker] record success`, {
|
|
47
|
+
messageId: record.messageId,
|
|
48
|
+
event: runParams?.event,
|
|
49
|
+
});
|
|
28
50
|
} catch (error) {
|
|
29
51
|
if (error.isHaltError) {
|
|
30
52
|
// HaltError means "discard this message, don't retry".
|
|
31
53
|
// Treat as success so SQS deletes it from the queue.
|
|
54
|
+
// Logged explicitly — silent discards made prod debugging
|
|
55
|
+
// extremely hard; keep this visible.
|
|
56
|
+
console.warn(`[Worker] record halted (discarded, no retry)`, {
|
|
57
|
+
messageId: record.messageId,
|
|
58
|
+
event: parsedEvent,
|
|
59
|
+
reason: error.message,
|
|
60
|
+
statusCode: error.statusCode,
|
|
61
|
+
});
|
|
32
62
|
continue;
|
|
33
63
|
}
|
|
34
64
|
console.error(`[Worker] Failed to process record ${record.messageId}:`, error);
|
|
@@ -36,6 +66,12 @@ class Worker {
|
|
|
36
66
|
}
|
|
37
67
|
}
|
|
38
68
|
|
|
69
|
+
if (batchItemFailures.length > 0) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`[Worker] run: returning ${batchItemFailures.length} batchItemFailure(s) of ${records.length}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
return { batchItemFailures };
|
|
40
76
|
}
|
|
41
77
|
|
package/core/create-handler.js
CHANGED
|
@@ -5,6 +5,45 @@
|
|
|
5
5
|
const { initDebugLog, flushDebugLog } = require('../logs');
|
|
6
6
|
const { secretsToEnv } = require('./secrets-to-env');
|
|
7
7
|
|
|
8
|
+
// Best-effort extraction of correlation identifiers from a Lambda event.
|
|
9
|
+
// For SQS: pulls messageIds + parsed event/processId/integrationId from each
|
|
10
|
+
// record body. For HTTP: pulls method+path. Never throws.
|
|
11
|
+
const summarizeLambdaEvent = (event) => {
|
|
12
|
+
if (!event) return {};
|
|
13
|
+
if (Array.isArray(event.Records)) {
|
|
14
|
+
return {
|
|
15
|
+
source: 'sqs',
|
|
16
|
+
records: event.Records.map((r) => {
|
|
17
|
+
let parsed = {};
|
|
18
|
+
try {
|
|
19
|
+
const body = JSON.parse(r.body);
|
|
20
|
+
parsed = {
|
|
21
|
+
event: body?.event,
|
|
22
|
+
processId: body?.data?.processId,
|
|
23
|
+
integrationId: body?.data?.integrationId,
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
// ignore unparseable bodies
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
messageId: r.messageId,
|
|
30
|
+
receiveCount: r.attributes?.ApproximateReceiveCount,
|
|
31
|
+
...parsed,
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (event.httpMethod || event.requestContext?.http) {
|
|
37
|
+
return {
|
|
38
|
+
source: 'http',
|
|
39
|
+
method:
|
|
40
|
+
event.httpMethod || event.requestContext?.http?.method,
|
|
41
|
+
path: event.path || event.rawPath,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { source: 'other' };
|
|
45
|
+
};
|
|
46
|
+
|
|
8
47
|
const createHandler = (optionByName = {}) => {
|
|
9
48
|
const {
|
|
10
49
|
eventName = 'Event',
|
|
@@ -17,6 +56,8 @@ const createHandler = (optionByName = {}) => {
|
|
|
17
56
|
}
|
|
18
57
|
|
|
19
58
|
return async (event, context) => {
|
|
59
|
+
const eventSummary = summarizeLambdaEvent(event);
|
|
60
|
+
|
|
20
61
|
try {
|
|
21
62
|
initDebugLog(eventName, event);
|
|
22
63
|
|
|
@@ -62,7 +103,21 @@ const createHandler = (optionByName = {}) => {
|
|
|
62
103
|
// Handle server-to-server responses.
|
|
63
104
|
|
|
64
105
|
// Halt errors are logged but suceed and won't be retried.
|
|
106
|
+
// Log explicitly — silent suppression here previously made stuck
|
|
107
|
+
// messages invisible to observability tooling. Include
|
|
108
|
+
// eventSummary so operators can correlate across concurrent
|
|
109
|
+
// invocations (processId / messageIds / HTTP path).
|
|
65
110
|
if (error.isHaltError === true) {
|
|
111
|
+
console.warn(
|
|
112
|
+
`[createHandler] ${eventName}: halt error suppressed (no retry)`,
|
|
113
|
+
{
|
|
114
|
+
eventName,
|
|
115
|
+
errorName: error.name,
|
|
116
|
+
errorMessage: error.message,
|
|
117
|
+
statusCode: error.statusCode,
|
|
118
|
+
...eventSummary,
|
|
119
|
+
}
|
|
120
|
+
);
|
|
66
121
|
return;
|
|
67
122
|
}
|
|
68
123
|
|
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
|
-----------------------------------------------------
|
|
@@ -141,8 +141,17 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
|
|
|
141
141
|
};
|
|
142
142
|
|
|
143
143
|
const createQueueWorker = (integrationClass) => {
|
|
144
|
+
const integrationName = integrationClass.Definition.name;
|
|
145
|
+
|
|
144
146
|
class QueueWorker extends Worker {
|
|
145
147
|
async _run(params, context) {
|
|
148
|
+
const logCtx = {
|
|
149
|
+
integration: integrationName,
|
|
150
|
+
event: params.event,
|
|
151
|
+
processId: params.data?.processId,
|
|
152
|
+
integrationId: params.data?.integrationId,
|
|
153
|
+
};
|
|
154
|
+
|
|
146
155
|
try {
|
|
147
156
|
let integrationInstance;
|
|
148
157
|
|
|
@@ -150,29 +159,46 @@ const createQueueWorker = (integrationClass) => {
|
|
|
150
159
|
// then integrationId (for ANY event type that needs hydration),
|
|
151
160
|
// fallback to unhydrated instance
|
|
152
161
|
if (params.data?.processId) {
|
|
162
|
+
console.log(
|
|
163
|
+
`[QueueWorker] hydrating by processId`,
|
|
164
|
+
logCtx
|
|
165
|
+
);
|
|
153
166
|
integrationInstance = await loadIntegrationForProcess(
|
|
154
167
|
params.data.processId,
|
|
155
168
|
integrationClass
|
|
156
169
|
);
|
|
157
|
-
|
|
170
|
+
console.log(`[QueueWorker] hydrated`, {
|
|
171
|
+
...logCtx,
|
|
172
|
+
integrationStatus: integrationInstance?.status,
|
|
173
|
+
hydratedIntegrationId: integrationInstance?.id,
|
|
174
|
+
});
|
|
175
|
+
if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
|
|
158
176
|
console.warn(
|
|
159
|
-
`[${
|
|
177
|
+
`[${integrationName}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
|
|
160
178
|
);
|
|
161
179
|
return;
|
|
162
180
|
}
|
|
163
181
|
} else if (params.data?.integrationId) {
|
|
182
|
+
console.log(
|
|
183
|
+
`[QueueWorker] hydrating by integrationId`,
|
|
184
|
+
logCtx
|
|
185
|
+
);
|
|
164
186
|
integrationInstance = await loadIntegrationForWebhook(
|
|
165
187
|
params.data.integrationId
|
|
166
188
|
);
|
|
167
189
|
if (!integrationInstance) {
|
|
168
190
|
console.warn(
|
|
169
|
-
`[${
|
|
191
|
+
`[${integrationName}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
|
|
170
192
|
);
|
|
171
193
|
return;
|
|
172
194
|
}
|
|
173
|
-
|
|
195
|
+
console.log(`[QueueWorker] hydrated`, {
|
|
196
|
+
...logCtx,
|
|
197
|
+
integrationStatus: integrationInstance?.status,
|
|
198
|
+
});
|
|
199
|
+
if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
|
|
174
200
|
console.warn(
|
|
175
|
-
`[${
|
|
201
|
+
`[${integrationName}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
|
|
176
202
|
);
|
|
177
203
|
return;
|
|
178
204
|
}
|
|
@@ -181,6 +207,10 @@ const createQueueWorker = (integrationClass) => {
|
|
|
181
207
|
// There will be cases where we need to use helpers that the api modules can export.
|
|
182
208
|
// Like for HubSpot, the answer is to do a reverse lookup for the integration by the entity external ID (HubSpot Portal ID),
|
|
183
209
|
// and then you'll have the integration ID available to hydrate from.
|
|
210
|
+
console.log(
|
|
211
|
+
`[QueueWorker] no processId/integrationId — running dry instance`,
|
|
212
|
+
logCtx
|
|
213
|
+
);
|
|
184
214
|
integrationInstance = new integrationClass();
|
|
185
215
|
}
|
|
186
216
|
|
|
@@ -188,14 +218,17 @@ const createQueueWorker = (integrationClass) => {
|
|
|
188
218
|
integrationInstance
|
|
189
219
|
);
|
|
190
220
|
|
|
191
|
-
|
|
221
|
+
console.log(`[QueueWorker] dispatching ${params.event}`, logCtx);
|
|
222
|
+
const result = await dispatcher.dispatchJob({
|
|
192
223
|
event: params.event,
|
|
193
224
|
data: params.data,
|
|
194
225
|
context: context,
|
|
195
226
|
});
|
|
227
|
+
console.log(`[QueueWorker] ${params.event} dispatched ok`, logCtx);
|
|
228
|
+
return result;
|
|
196
229
|
} catch (error) {
|
|
197
230
|
console.error(
|
|
198
|
-
`Error in ${params.event} for ${
|
|
231
|
+
`Error in ${params.event} for ${integrationName}:`,
|
|
199
232
|
error
|
|
200
233
|
);
|
|
201
234
|
|
|
@@ -207,7 +240,12 @@ const createQueueWorker = (integrationClass) => {
|
|
|
207
240
|
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
208
241
|
error.isHaltError = true;
|
|
209
242
|
console.warn(
|
|
210
|
-
`[${
|
|
243
|
+
`[${integrationName}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`,
|
|
244
|
+
{
|
|
245
|
+
...logCtx,
|
|
246
|
+
errorName: error.name,
|
|
247
|
+
errorMessage: error.message,
|
|
248
|
+
}
|
|
211
249
|
);
|
|
212
250
|
}
|
|
213
251
|
|
|
@@ -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--canary.
|
|
4
|
+
"version": "2.0.0--canary.578.e866a27.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.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
43
|
-
"@friggframework/test": "2.0.0--canary.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0--canary.578.e866a27.0",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0--canary.578.e866a27.0",
|
|
43
|
+
"@friggframework/test": "2.0.0--canary.578.e866a27.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": "
|
|
83
|
+
"gitHead": "e866a27a667db71b288d3798dfc3852138ad4e3e"
|
|
84
84
|
}
|
package/queues/queuer-util.js
CHANGED
|
@@ -18,13 +18,88 @@ const awsConfigOptions = () => {
|
|
|
18
18
|
|
|
19
19
|
const sqs = new SQSClient(awsConfigOptions());
|
|
20
20
|
|
|
21
|
+
// Best-effort extraction of the logical event/processId/integrationId from a
|
|
22
|
+
// JSON message body. Used only for log correlation — never throws.
|
|
23
|
+
const summarizeMessageBody = (bodyStr) => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(bodyStr);
|
|
26
|
+
return {
|
|
27
|
+
event: parsed?.event,
|
|
28
|
+
processId: parsed?.data?.processId,
|
|
29
|
+
integrationId: parsed?.data?.integrationId,
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Inspect SendMessageBatchResult for partial failures and log them.
|
|
37
|
+
// AWS SendMessageBatch can succeed at the HTTP level while individual entries
|
|
38
|
+
// are rejected (KMS errors, per-entry throttling, service errors). Callers that
|
|
39
|
+
// don't inspect result.Failed silently lose those messages. This logs the
|
|
40
|
+
// details — including the logical event/processId of the failed entry — so
|
|
41
|
+
// the loss is visible and correlatable in CloudWatch.
|
|
42
|
+
const inspectBatchResult = (result, queueUrl, buffer) => {
|
|
43
|
+
const bufferSize = buffer.length;
|
|
44
|
+
const failedCount = result?.Failed?.length ?? 0;
|
|
45
|
+
const successCount = result?.Successful?.length ?? 0;
|
|
46
|
+
|
|
47
|
+
// Index buffer by Id so we can attach event/processId to failures.
|
|
48
|
+
const bufferById = new Map(buffer.map((b) => [b.Id, b]));
|
|
49
|
+
|
|
50
|
+
if (failedCount > 0) {
|
|
51
|
+
console.error(
|
|
52
|
+
`[QueuerUtil] SendMessageBatch partial failure: ${failedCount}/${bufferSize} failed`,
|
|
53
|
+
{
|
|
54
|
+
queueUrl,
|
|
55
|
+
bufferSize,
|
|
56
|
+
successCount,
|
|
57
|
+
failedCount,
|
|
58
|
+
failed: result.Failed.map((f) => {
|
|
59
|
+
const bufEntry = bufferById.get(f.Id);
|
|
60
|
+
const summary = bufEntry
|
|
61
|
+
? summarizeMessageBody(bufEntry.MessageBody)
|
|
62
|
+
: {};
|
|
63
|
+
return {
|
|
64
|
+
Id: f.Id,
|
|
65
|
+
Code: f.Code,
|
|
66
|
+
SenderFault: f.SenderFault,
|
|
67
|
+
Message: f.Message,
|
|
68
|
+
...summary,
|
|
69
|
+
};
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
} else if (successCount > 0) {
|
|
74
|
+
// Include a compact per-entry summary so operators can correlate
|
|
75
|
+
// "which send contained which logical message" during incident triage.
|
|
76
|
+
const entries = result.Successful.map((s) => {
|
|
77
|
+
const bufEntry = bufferById.get(s.Id);
|
|
78
|
+
const summary = bufEntry
|
|
79
|
+
? summarizeMessageBody(bufEntry.MessageBody)
|
|
80
|
+
: {};
|
|
81
|
+
return { MessageId: s.MessageId, ...summary };
|
|
82
|
+
});
|
|
83
|
+
console.log(
|
|
84
|
+
`[QueuerUtil] SendMessageBatch ok: ${successCount}/${bufferSize} to ${queueUrl}`,
|
|
85
|
+
{ entries }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
};
|
|
91
|
+
|
|
21
92
|
const QueuerUtil = {
|
|
22
93
|
send: async (message, queueUrl) => {
|
|
23
94
|
const command = new SendMessageCommand({
|
|
24
95
|
MessageBody: JSON.stringify(message),
|
|
25
96
|
QueueUrl: queueUrl,
|
|
26
97
|
});
|
|
27
|
-
|
|
98
|
+
const result = await sqs.send(command);
|
|
99
|
+
console.log(
|
|
100
|
+
`[QueuerUtil] SendMessage ok: MessageId=${result?.MessageId} to ${queueUrl}`
|
|
101
|
+
);
|
|
102
|
+
return result;
|
|
28
103
|
},
|
|
29
104
|
|
|
30
105
|
batchSend: async (entries = [], queueUrl) => {
|
|
@@ -42,7 +117,8 @@ const QueuerUtil = {
|
|
|
42
117
|
Entries: buffer,
|
|
43
118
|
QueueUrl: queueUrl,
|
|
44
119
|
});
|
|
45
|
-
await sqs.send(command);
|
|
120
|
+
const result = await sqs.send(command);
|
|
121
|
+
inspectBatchResult(result, queueUrl, buffer);
|
|
46
122
|
// Purge the buffer
|
|
47
123
|
buffer.splice(0, buffer.length);
|
|
48
124
|
}
|
|
@@ -54,7 +130,8 @@ const QueuerUtil = {
|
|
|
54
130
|
Entries: buffer,
|
|
55
131
|
QueueUrl: queueUrl,
|
|
56
132
|
});
|
|
57
|
-
|
|
133
|
+
const result = await sqs.send(command);
|
|
134
|
+
return inspectBatchResult(result, queueUrl, buffer);
|
|
58
135
|
}
|
|
59
136
|
|
|
60
137
|
// If we're exact... just return an empty object for now
|