@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.
- package/core/Worker.js +36 -0
- package/core/create-handler.js +64 -0
- package/database/encryption/encryption-schema-registry.js +133 -0
- package/database/encryption/field-encryption-service.js +30 -2
- package/database/encryption/prisma-encryption-extension.js +10 -2
- package/handlers/backend-utils.js +50 -6
- package/handlers/routers/integration-defined-routers.js +42 -5
- package/handlers/workers/integration-defined-workers.js +7 -0
- package/integrations/EXTENSIONS.md +172 -0
- package/integrations/extension.js +233 -0
- package/integrations/index.js +8 -0
- package/integrations/integration-base.js +175 -6
- package/integrations/integration-router.js +25 -5
- package/integrations/use-cases/update-process-metrics.js +10 -1
- package/modules/module.js +16 -0
- package/modules/requester/requester.js +7 -2
- package/modules/use-cases/process-authorization-callback.js +71 -5
- package/package.json +5 -5
- package/queues/queuer-util.js +80 -3
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* const updateMetrics = new UpdateProcessMetrics({ processRepository, websocketService });
|
|
29
29
|
* await updateMetrics.execute(processId, {
|
|
30
30
|
* processed: 100,
|
|
31
|
-
* success:
|
|
31
|
+
* success: 92,
|
|
32
32
|
* errors: 5,
|
|
33
|
+
* skipped: 3,
|
|
33
34
|
* errorDetails: [{ contactId: 'abc', error: 'Missing email', timestamp: '...' }]
|
|
34
35
|
* });
|
|
35
36
|
*/
|
|
@@ -54,6 +55,11 @@ class UpdateProcessMetrics {
|
|
|
54
55
|
* @param {number} [metricsUpdate.processed=0] - Records processed in this batch
|
|
55
56
|
* @param {number} [metricsUpdate.success=0] - Successful records
|
|
56
57
|
* @param {number} [metricsUpdate.errors=0] - Failed records
|
|
58
|
+
* @param {number} [metricsUpdate.skipped=0] - Intentionally-skipped
|
|
59
|
+
* records (hash-match, dedupe, loop protection, etc.). Increments
|
|
60
|
+
* `results.aggregateData.totalSkipped`. Distinct from errors so the
|
|
61
|
+
* UI can show `processed = synced + failed + skipped` without
|
|
62
|
+
* conflating intentional skips with failures.
|
|
57
63
|
* @param {Array} [metricsUpdate.errorDetails=[]] - Error details array
|
|
58
64
|
* @returns {Promise<Object>} Updated process record
|
|
59
65
|
* @throws {Error} If process not found or update fails
|
|
@@ -71,9 +77,11 @@ class UpdateProcessMetrics {
|
|
|
71
77
|
const processed = metricsUpdate.processed || 0;
|
|
72
78
|
const success = metricsUpdate.success || 0;
|
|
73
79
|
const errors = metricsUpdate.errors || 0;
|
|
80
|
+
const skipped = metricsUpdate.skipped || 0;
|
|
74
81
|
if (processed) increment['context.processedRecords'] = processed;
|
|
75
82
|
if (success) increment['results.aggregateData.totalSynced'] = success;
|
|
76
83
|
if (errors) increment['results.aggregateData.totalFailed'] = errors;
|
|
84
|
+
if (skipped) increment['results.aggregateData.totalSkipped'] = skipped;
|
|
77
85
|
|
|
78
86
|
const pushSlice = {};
|
|
79
87
|
if (
|
|
@@ -191,6 +199,7 @@ class UpdateProcessMetrics {
|
|
|
191
199
|
total: context.totalRecords || 0,
|
|
192
200
|
successCount: aggregateData.totalSynced || 0,
|
|
193
201
|
errorCount: aggregateData.totalFailed || 0,
|
|
202
|
+
skippedCount: aggregateData.totalSkipped || 0,
|
|
194
203
|
recordsPerSecond: aggregateData.recordsPerSecond || 0,
|
|
195
204
|
estimatedCompletion: context.estimatedCompletion || null,
|
|
196
205
|
timestamp: new Date().toISOString(),
|
package/modules/module.js
CHANGED
|
@@ -41,6 +41,8 @@ class Module extends Delegate {
|
|
|
41
41
|
// Module → parent delegate (typically IntegrationBase) events
|
|
42
42
|
this.DLGT_CREDENTIAL_INVALIDATED = 'CREDENTIAL_INVALIDATED';
|
|
43
43
|
this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
|
|
44
|
+
this.DLGT_CREDENTIAL_VALIDATED = 'CREDENTIAL_VALIDATED';
|
|
45
|
+
this.delegateTypes.push(this.DLGT_CREDENTIAL_VALIDATED);
|
|
44
46
|
|
|
45
47
|
Object.assign(this, this.definition.requiredAuthMethods);
|
|
46
48
|
|
|
@@ -123,6 +125,20 @@ class Module extends Delegate {
|
|
|
123
125
|
credentialDetails
|
|
124
126
|
);
|
|
125
127
|
this.credential = persisted;
|
|
128
|
+
|
|
129
|
+
if (this.credential?.id) {
|
|
130
|
+
try {
|
|
131
|
+
await this.notify(this.DLGT_CREDENTIAL_VALIDATED, {
|
|
132
|
+
credentialId: this.credential.id,
|
|
133
|
+
moduleName: this.name,
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(
|
|
137
|
+
`[Frigg] Failed to propagate CREDENTIAL_VALIDATED for module ${this.name}:`,
|
|
138
|
+
err?.message || err
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
async receiveNotification(notifier, delegateString, object = null) {
|
|
@@ -151,9 +151,9 @@ class Requester extends Delegate {
|
|
|
151
151
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
152
152
|
return this._request(url, options, i + 1);
|
|
153
153
|
} else if (status === 401) {
|
|
154
|
-
if (!this.isRefreshable
|
|
154
|
+
if (!this.isRefreshable) {
|
|
155
155
|
await this.notify(this.DLGT_INVALID_AUTH);
|
|
156
|
-
} else {
|
|
156
|
+
} else if (this.refreshCount === 0) {
|
|
157
157
|
this.refreshCount++;
|
|
158
158
|
const refreshSucceeded = await this.refreshAuth();
|
|
159
159
|
if (refreshSucceeded) {
|
|
@@ -178,6 +178,11 @@ class Requester extends Delegate {
|
|
|
178
178
|
);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Successful response: reset the per-instance refresh budget so
|
|
182
|
+
// a later 401 in the same Requester lifetime can attempt refresh
|
|
183
|
+
// again instead of silently falling through.
|
|
184
|
+
this.refreshCount = 0;
|
|
185
|
+
|
|
181
186
|
// parsedBody consumes the response body stream. If the server
|
|
182
187
|
// stalls mid-stream the timer (still armed) aborts it.
|
|
183
188
|
return options.returnFullRes
|
|
@@ -28,6 +28,11 @@ class ProcessAuthorizationCallback {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
async execute(userId, entityType, params) {
|
|
31
|
+
const hasCode = Boolean(params && params.code);
|
|
32
|
+
console.log(
|
|
33
|
+
`[Frigg] processAuthorizationCallback start userId=${userId} entityType=${entityType} hasCode=${hasCode}`
|
|
34
|
+
);
|
|
35
|
+
|
|
31
36
|
const moduleDefinition = this.moduleDefinitions.find((def) => {
|
|
32
37
|
return entityType === def.moduleName;
|
|
33
38
|
});
|
|
@@ -38,12 +43,29 @@ class ProcessAuthorizationCallback {
|
|
|
38
43
|
);
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
//
|
|
42
|
-
|
|
46
|
+
// Bootstrap the Module with the existing entity (if any) so the API
|
|
47
|
+
// requester is preloaded with prior tokens. This enables a refresh
|
|
48
|
+
// fallback when callers lack a fresh OAuth code, and lets us match a
|
|
49
|
+
// re-auth back to the existing credential record by id.
|
|
50
|
+
const existingEntities =
|
|
51
|
+
await this.moduleRepository.findEntitiesByUserIdAndModuleName(
|
|
52
|
+
userId,
|
|
53
|
+
entityType
|
|
54
|
+
);
|
|
55
|
+
const existingEntity =
|
|
56
|
+
existingEntities && existingEntities.length > 0
|
|
57
|
+
? existingEntities[0]
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
if (existingEntity) {
|
|
61
|
+
console.log(
|
|
62
|
+
`[Frigg] processAuthorizationCallback found existing entity id=${existingEntity.id} credentialId=${existingEntity.credential?.id}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
43
65
|
|
|
44
66
|
const module = new Module({
|
|
45
67
|
userId,
|
|
46
|
-
entity,
|
|
68
|
+
entity: existingEntity,
|
|
47
69
|
definition: moduleDefinition,
|
|
48
70
|
});
|
|
49
71
|
|
|
@@ -53,6 +75,16 @@ class ProcessAuthorizationCallback {
|
|
|
53
75
|
module.api,
|
|
54
76
|
params
|
|
55
77
|
);
|
|
78
|
+
console.log(
|
|
79
|
+
`[Frigg] processAuthorizationCallback OAuth getToken complete userId=${userId} entityType=${entityType}`
|
|
80
|
+
);
|
|
81
|
+
// Belt-and-suspenders: persist tokens explicitly here rather than
|
|
82
|
+
// relying solely on the DLGT_TOKEN_UPDATE notification chain
|
|
83
|
+
// inside setTokens. The notification path remains in place but
|
|
84
|
+
// has been observed to no-op silently in some prod paths,
|
|
85
|
+
// leaving newly-issued tokens unsaved while the user-visible
|
|
86
|
+
// OAuth flow appears to succeed.
|
|
87
|
+
await this.onTokenUpdate(module, moduleDefinition, userId);
|
|
56
88
|
} else {
|
|
57
89
|
tokenResponse =
|
|
58
90
|
await moduleDefinition.requiredAuthMethods.setAuthParams(
|
|
@@ -62,6 +94,10 @@ class ProcessAuthorizationCallback {
|
|
|
62
94
|
await this.onTokenUpdate(module, moduleDefinition, userId);
|
|
63
95
|
}
|
|
64
96
|
|
|
97
|
+
console.log(
|
|
98
|
+
`[Frigg] processAuthorizationCallback credential persisted credentialId=${module.credential?.id} authIsValid=${module.credential?.authIsValid}`
|
|
99
|
+
);
|
|
100
|
+
|
|
65
101
|
const authRes = await module.testAuth();
|
|
66
102
|
if (!authRes) {
|
|
67
103
|
throw new Error('Authorization failed');
|
|
@@ -90,7 +126,12 @@ class ProcessAuthorizationCallback {
|
|
|
90
126
|
// credential + entity are already persisted. Operators can recover
|
|
91
127
|
// stuck integrations manually.
|
|
92
128
|
try {
|
|
93
|
-
await this.restoreIntegrationsForEntity(
|
|
129
|
+
const restoredCount = await this.restoreIntegrationsForEntity(
|
|
130
|
+
persistedEntity.id
|
|
131
|
+
);
|
|
132
|
+
console.log(
|
|
133
|
+
`[Frigg] processAuthorizationCallback restored ${restoredCount} integration(s) for entityId=${persistedEntity.id}`
|
|
134
|
+
);
|
|
94
135
|
} catch (err) {
|
|
95
136
|
console.error(
|
|
96
137
|
`[Frigg] Failed to restore integrations for entity ${persistedEntity.id} after successful re-auth — manual intervention may be needed`,
|
|
@@ -106,11 +147,12 @@ class ProcessAuthorizationCallback {
|
|
|
106
147
|
}
|
|
107
148
|
|
|
108
149
|
async restoreIntegrationsForEntity(entityId) {
|
|
109
|
-
if (!this.integrationRepository) return;
|
|
150
|
+
if (!this.integrationRepository) return 0;
|
|
110
151
|
const integrations =
|
|
111
152
|
await this.integrationRepository.findIntegrationsByEntityId(
|
|
112
153
|
entityId
|
|
113
154
|
);
|
|
155
|
+
let restored = 0;
|
|
114
156
|
for (const integration of integrations) {
|
|
115
157
|
if (STATUSES_RESET_ON_REAUTH.includes(integration.status)) {
|
|
116
158
|
console.log(
|
|
@@ -120,8 +162,10 @@ class ProcessAuthorizationCallback {
|
|
|
120
162
|
integration.id,
|
|
121
163
|
'ENABLED'
|
|
122
164
|
);
|
|
165
|
+
restored++;
|
|
123
166
|
}
|
|
124
167
|
}
|
|
168
|
+
return restored;
|
|
125
169
|
}
|
|
126
170
|
|
|
127
171
|
async onTokenUpdate(module, moduleDefinition, userId) {
|
|
@@ -162,6 +206,28 @@ class ProcessAuthorizationCallback {
|
|
|
162
206
|
});
|
|
163
207
|
|
|
164
208
|
if (existingEntity) {
|
|
209
|
+
// Repoint the entity's credentialId when re-auth produced a
|
|
210
|
+
// different credential than the one currently linked. This
|
|
211
|
+
// happens when the user re-authenticates against a different
|
|
212
|
+
// workspace/account of the same provider — `upsertCredential`
|
|
213
|
+
// matches/creates a credential keyed by externalId, but
|
|
214
|
+
// findEntity matches the entity by its own externalId, leaving
|
|
215
|
+
// the entity still linked to the prior workspace's credential
|
|
216
|
+
// unless we explicitly update the link.
|
|
217
|
+
const existingCredentialId = existingEntity.credential?.id;
|
|
218
|
+
if (
|
|
219
|
+
credentialId &&
|
|
220
|
+
String(existingCredentialId) !== String(credentialId)
|
|
221
|
+
) {
|
|
222
|
+
console.log(
|
|
223
|
+
`[Frigg] Repointing entity ${existingEntity.id} credentialId ${existingCredentialId} -> ${credentialId} after re-auth`
|
|
224
|
+
);
|
|
225
|
+
const updated = await this.moduleRepository.updateEntity(
|
|
226
|
+
existingEntity.id,
|
|
227
|
+
{ credential: credentialId }
|
|
228
|
+
);
|
|
229
|
+
if (updated) return updated;
|
|
230
|
+
}
|
|
165
231
|
return existingEntity;
|
|
166
232
|
}
|
|
167
233
|
|
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.590.094a71b.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.590.094a71b.0",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0--canary.590.094a71b.0",
|
|
43
|
+
"@friggframework/test": "2.0.0--canary.590.094a71b.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": "094a71baaac437d208e2ad749bc3c79a9b0c15bb"
|
|
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
|