@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
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,7 +56,18 @@ const createHandler = (optionByName = {}) => {
|
|
|
17
56
|
}
|
|
18
57
|
|
|
19
58
|
return async (event, context) => {
|
|
59
|
+
const eventSummary = summarizeLambdaEvent(event);
|
|
60
|
+
|
|
20
61
|
try {
|
|
62
|
+
console.info(
|
|
63
|
+
`[createHandler] ${eventName}: handler entry`,
|
|
64
|
+
{
|
|
65
|
+
eventName,
|
|
66
|
+
awsRequestId: context?.awsRequestId,
|
|
67
|
+
...eventSummary,
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
21
71
|
initDebugLog(eventName, event);
|
|
22
72
|
|
|
23
73
|
const requestMethod = event.httpMethod;
|
|
@@ -62,7 +112,21 @@ const createHandler = (optionByName = {}) => {
|
|
|
62
112
|
// Handle server-to-server responses.
|
|
63
113
|
|
|
64
114
|
// Halt errors are logged but suceed and won't be retried.
|
|
115
|
+
// Log explicitly — silent suppression here previously made stuck
|
|
116
|
+
// messages invisible to observability tooling. Include
|
|
117
|
+
// eventSummary so operators can correlate across concurrent
|
|
118
|
+
// invocations (processId / messageIds / HTTP path).
|
|
65
119
|
if (error.isHaltError === true) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`[createHandler] ${eventName}: halt error suppressed (no retry)`,
|
|
122
|
+
{
|
|
123
|
+
eventName,
|
|
124
|
+
errorName: error.name,
|
|
125
|
+
errorMessage: error.message,
|
|
126
|
+
statusCode: error.statusCode,
|
|
127
|
+
...eventSummary,
|
|
128
|
+
}
|
|
129
|
+
);
|
|
66
130
|
return;
|
|
67
131
|
}
|
|
68
132
|
|
|
@@ -42,6 +42,17 @@ const CORE_ENCRYPTION_SCHEMA = {
|
|
|
42
42
|
|
|
43
43
|
let customSchema = {};
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Per-model write-side opt-out: fields registered here are NOT encrypted on
|
|
47
|
+
* write, but ARE still decrypted on read so legacy encrypted rows continue to
|
|
48
|
+
* deserialize. Lets apps migrate a model from encrypted to plain JSON without
|
|
49
|
+
* a data migration — touched rows naturally rewrite as plain on the next save,
|
|
50
|
+
* untouched rows stay encrypted-but-readable forever.
|
|
51
|
+
*
|
|
52
|
+
* Shape: `{ ModelName: ['field.path', ...] }`
|
|
53
|
+
*/
|
|
54
|
+
let encryptionOptOut = {};
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* Validates a custom encryption schema
|
|
47
58
|
* @returns {{valid: boolean, errors: string[]}}
|
|
@@ -218,6 +229,14 @@ function loadCustomEncryptionSchema() {
|
|
|
218
229
|
registerCustomSchema(customSchema);
|
|
219
230
|
}
|
|
220
231
|
|
|
232
|
+
// Load app-level encryption opt-out — apps can declare fields they
|
|
233
|
+
// don't want encrypted on write (decryption on read still works,
|
|
234
|
+
// so legacy data remains readable).
|
|
235
|
+
const disable = appDefinition.encryption?.disable;
|
|
236
|
+
if (disable && Object.keys(disable).length > 0) {
|
|
237
|
+
registerEncryptionOptOut(disable);
|
|
238
|
+
}
|
|
239
|
+
|
|
221
240
|
// Load module-level encryption schemas from integrations
|
|
222
241
|
const integrations = appDefinition.integrations;
|
|
223
242
|
if (integrations && Array.isArray(integrations)) {
|
|
@@ -240,6 +259,115 @@ function getEncryptedFields(modelName) {
|
|
|
240
259
|
return [...new Set(allFields)];
|
|
241
260
|
}
|
|
242
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Validates an encryption opt-out config.
|
|
264
|
+
*
|
|
265
|
+
* Unlike custom schema validation, opt-out IS allowed to target paths that
|
|
266
|
+
* already live in CORE_ENCRYPTION_SCHEMA — that's the entire point.
|
|
267
|
+
*
|
|
268
|
+
* @param {Object} optOut - Map of `{ ModelName: ['field.path', ...] }`
|
|
269
|
+
* @returns {{valid: boolean, errors: string[]}}
|
|
270
|
+
*/
|
|
271
|
+
function validateOptOut(optOut) {
|
|
272
|
+
const errors = [];
|
|
273
|
+
|
|
274
|
+
if (!optOut || typeof optOut !== 'object') {
|
|
275
|
+
errors.push('Encryption opt-out must be an object');
|
|
276
|
+
return { valid: false, errors };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const [modelName, fields] of Object.entries(optOut)) {
|
|
280
|
+
if (typeof modelName !== 'string' || !modelName) {
|
|
281
|
+
errors.push(`Invalid model name in opt-out: ${modelName}`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!Array.isArray(fields)) {
|
|
286
|
+
errors.push(
|
|
287
|
+
`Model "${modelName}" opt-out must be an array of field paths`
|
|
288
|
+
);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const fieldPath of fields) {
|
|
293
|
+
if (typeof fieldPath !== 'string' || !fieldPath) {
|
|
294
|
+
errors.push(
|
|
295
|
+
`Model "${modelName}" has invalid opt-out field path: ${fieldPath}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { valid: errors.length === 0, errors };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Registers an encryption opt-out config. Listed fields will be skipped during
|
|
306
|
+
* encryption on write while still being eligible for decryption on read (so
|
|
307
|
+
* legacy encrypted rows still deserialize correctly).
|
|
308
|
+
*
|
|
309
|
+
* Intended call site: `appDefinition.encryption.disable` via
|
|
310
|
+
* `loadCustomEncryptionSchema`.
|
|
311
|
+
*
|
|
312
|
+
* @param {Object} optOut - Map of `{ ModelName: ['field.path', ...] }`
|
|
313
|
+
* @throws {Error} If opt-out validation fails
|
|
314
|
+
*/
|
|
315
|
+
function registerEncryptionOptOut(optOut) {
|
|
316
|
+
if (!optOut || Object.keys(optOut).length === 0) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const validation = validateOptOut(optOut);
|
|
321
|
+
if (!validation.valid) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Invalid encryption opt-out:\n- ${validation.errors.join('\n- ')}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
encryptionOptOut = { ...optOut };
|
|
328
|
+
logger.info(
|
|
329
|
+
`Registered encryption opt-out for models: ${Object.keys(
|
|
330
|
+
encryptionOptOut
|
|
331
|
+
).join(', ')}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Returns the field paths that should be encrypted when writing the given
|
|
337
|
+
* model. This is `getEncryptedFields` minus any paths the app has opted out
|
|
338
|
+
* of via `registerEncryptionOptOut`.
|
|
339
|
+
*
|
|
340
|
+
* Use this in the encrypt-on-write path of the FieldEncryptionService.
|
|
341
|
+
*/
|
|
342
|
+
function getFieldsToEncryptOnWrite(modelName) {
|
|
343
|
+
const allFields = getEncryptedFields(modelName);
|
|
344
|
+
const optedOut = new Set(encryptionOptOut[modelName] || []);
|
|
345
|
+
if (optedOut.size === 0) return allFields;
|
|
346
|
+
return allFields.filter((path) => !optedOut.has(path));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Returns the field paths that should be checked for decryption when reading
|
|
351
|
+
* the given model. Always includes opted-out paths so legacy encrypted rows
|
|
352
|
+
* remain readable after an app opts a field out.
|
|
353
|
+
*
|
|
354
|
+
* `FieldEncryptionService._isEncrypted` already short-circuits for plain JSON
|
|
355
|
+
* values, so listing more fields than necessary here is harmless.
|
|
356
|
+
*
|
|
357
|
+
* Use this in the decrypt-on-read path of the FieldEncryptionService.
|
|
358
|
+
*/
|
|
359
|
+
function getFieldsToDecryptOnRead(modelName) {
|
|
360
|
+
return getEncryptedFields(modelName);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Clears any registered encryption opt-outs. Test-helper; not intended for
|
|
365
|
+
* runtime use.
|
|
366
|
+
*/
|
|
367
|
+
function resetEncryptionOptOut() {
|
|
368
|
+
encryptionOptOut = {};
|
|
369
|
+
}
|
|
370
|
+
|
|
243
371
|
function hasEncryptedFields(modelName) {
|
|
244
372
|
return getEncryptedFields(modelName).length > 0;
|
|
245
373
|
}
|
|
@@ -257,12 +385,17 @@ function resetCustomSchema() {
|
|
|
257
385
|
module.exports = {
|
|
258
386
|
CORE_ENCRYPTION_SCHEMA,
|
|
259
387
|
getEncryptedFields,
|
|
388
|
+
getFieldsToEncryptOnWrite,
|
|
389
|
+
getFieldsToDecryptOnRead,
|
|
260
390
|
hasEncryptedFields,
|
|
261
391
|
getEncryptedModels,
|
|
262
392
|
registerCustomSchema,
|
|
393
|
+
registerEncryptionOptOut,
|
|
263
394
|
loadCustomEncryptionSchema,
|
|
264
395
|
loadModuleEncryptionSchemas,
|
|
265
396
|
extractCredentialFieldsFromModules,
|
|
266
397
|
validateCustomSchema,
|
|
398
|
+
validateOptOut,
|
|
267
399
|
resetCustomSchema,
|
|
400
|
+
resetEncryptionOptOut,
|
|
268
401
|
};
|
|
@@ -17,12 +17,40 @@ class FieldEncryptionService {
|
|
|
17
17
|
this.schema = schema;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the field paths to encrypt on write. Prefers
|
|
22
|
+
* `schema.getFieldsToEncryptOnWrite` (which respects app opt-outs); falls
|
|
23
|
+
* back to `schema.getEncryptedFields` for backwards compatibility with
|
|
24
|
+
* older schema adapters.
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
_getWriteFields(modelName) {
|
|
28
|
+
if (typeof this.schema.getFieldsToEncryptOnWrite === 'function') {
|
|
29
|
+
return this.schema.getFieldsToEncryptOnWrite(modelName);
|
|
30
|
+
}
|
|
31
|
+
return this.schema.getEncryptedFields(modelName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the field paths to attempt decryption on read. Prefers
|
|
36
|
+
* `schema.getFieldsToDecryptOnRead` (which IGNORES opt-outs so legacy
|
|
37
|
+
* encrypted rows still deserialize); falls back to
|
|
38
|
+
* `schema.getEncryptedFields` for backwards compatibility.
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
_getReadFields(modelName) {
|
|
42
|
+
if (typeof this.schema.getFieldsToDecryptOnRead === 'function') {
|
|
43
|
+
return this.schema.getFieldsToDecryptOnRead(modelName);
|
|
44
|
+
}
|
|
45
|
+
return this.schema.getEncryptedFields(modelName);
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
async encryptFields(modelName, document) {
|
|
21
49
|
if (!document || typeof document !== 'object') {
|
|
22
50
|
return document;
|
|
23
51
|
}
|
|
24
52
|
|
|
25
|
-
const fields = this.
|
|
53
|
+
const fields = this._getWriteFields(modelName);
|
|
26
54
|
if (fields.length === 0) {
|
|
27
55
|
return document;
|
|
28
56
|
}
|
|
@@ -58,7 +86,7 @@ class FieldEncryptionService {
|
|
|
58
86
|
return document;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
const fields = this.
|
|
89
|
+
const fields = this._getReadFields(modelName);
|
|
62
90
|
if (fields.length === 0) {
|
|
63
91
|
return document;
|
|
64
92
|
}
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* Intercepts Prisma queries to encrypt on write and decrypt on read.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
getEncryptedFields,
|
|
8
|
+
getFieldsToEncryptOnWrite,
|
|
9
|
+
getFieldsToDecryptOnRead,
|
|
10
|
+
} = require('./encryption-schema-registry');
|
|
7
11
|
const { FieldEncryptionService } = require('./field-encryption-service');
|
|
8
12
|
|
|
9
13
|
function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
@@ -19,7 +23,11 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
|
19
23
|
|
|
20
24
|
const encryptionService = new FieldEncryptionService({
|
|
21
25
|
cryptor,
|
|
22
|
-
schema: {
|
|
26
|
+
schema: {
|
|
27
|
+
getEncryptedFields,
|
|
28
|
+
getFieldsToEncryptOnWrite,
|
|
29
|
+
getFieldsToDecryptOnRead,
|
|
30
|
+
},
|
|
23
31
|
});
|
|
24
32
|
|
|
25
33
|
return {
|
|
@@ -31,6 +31,9 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
|
|
|
31
31
|
router[method.toLowerCase()](path, async (req, res, next) => {
|
|
32
32
|
try {
|
|
33
33
|
const integrationInstance = new IntegrationClass();
|
|
34
|
+
// initialize() registers dynamic user actions AND merges any Tier 3
|
|
35
|
+
// Integration Extension events into instance.events before dispatch.
|
|
36
|
+
await integrationInstance.initialize();
|
|
34
37
|
const dispatcher = new IntegrationEventDispatcher(
|
|
35
38
|
integrationInstance
|
|
36
39
|
);
|
|
@@ -141,8 +144,17 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
|
|
|
141
144
|
};
|
|
142
145
|
|
|
143
146
|
const createQueueWorker = (integrationClass) => {
|
|
147
|
+
const integrationName = integrationClass.Definition.name;
|
|
148
|
+
|
|
144
149
|
class QueueWorker extends Worker {
|
|
145
150
|
async _run(params, context) {
|
|
151
|
+
const logCtx = {
|
|
152
|
+
integration: integrationName,
|
|
153
|
+
event: params.event,
|
|
154
|
+
processId: params.data?.processId,
|
|
155
|
+
integrationId: params.data?.integrationId,
|
|
156
|
+
};
|
|
157
|
+
|
|
146
158
|
try {
|
|
147
159
|
let integrationInstance;
|
|
148
160
|
|
|
@@ -150,29 +162,46 @@ const createQueueWorker = (integrationClass) => {
|
|
|
150
162
|
// then integrationId (for ANY event type that needs hydration),
|
|
151
163
|
// fallback to unhydrated instance
|
|
152
164
|
if (params.data?.processId) {
|
|
165
|
+
console.log(
|
|
166
|
+
`[QueueWorker] hydrating by processId`,
|
|
167
|
+
logCtx
|
|
168
|
+
);
|
|
153
169
|
integrationInstance = await loadIntegrationForProcess(
|
|
154
170
|
params.data.processId,
|
|
155
171
|
integrationClass
|
|
156
172
|
);
|
|
173
|
+
console.log(`[QueueWorker] hydrated`, {
|
|
174
|
+
...logCtx,
|
|
175
|
+
integrationStatus: integrationInstance?.status,
|
|
176
|
+
hydratedIntegrationId: integrationInstance?.id,
|
|
177
|
+
});
|
|
157
178
|
if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
|
|
158
179
|
console.warn(
|
|
159
|
-
`[${
|
|
180
|
+
`[${integrationName}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
|
|
160
181
|
);
|
|
161
182
|
return;
|
|
162
183
|
}
|
|
163
184
|
} else if (params.data?.integrationId) {
|
|
185
|
+
console.log(
|
|
186
|
+
`[QueueWorker] hydrating by integrationId`,
|
|
187
|
+
logCtx
|
|
188
|
+
);
|
|
164
189
|
integrationInstance = await loadIntegrationForWebhook(
|
|
165
190
|
params.data.integrationId
|
|
166
191
|
);
|
|
167
192
|
if (!integrationInstance) {
|
|
168
193
|
console.warn(
|
|
169
|
-
`[${
|
|
194
|
+
`[${integrationName}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
|
|
170
195
|
);
|
|
171
196
|
return;
|
|
172
197
|
}
|
|
198
|
+
console.log(`[QueueWorker] hydrated`, {
|
|
199
|
+
...logCtx,
|
|
200
|
+
integrationStatus: integrationInstance?.status,
|
|
201
|
+
});
|
|
173
202
|
if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
|
|
174
203
|
console.warn(
|
|
175
|
-
`[${
|
|
204
|
+
`[${integrationName}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
|
|
176
205
|
);
|
|
177
206
|
return;
|
|
178
207
|
}
|
|
@@ -181,21 +210,31 @@ const createQueueWorker = (integrationClass) => {
|
|
|
181
210
|
// There will be cases where we need to use helpers that the api modules can export.
|
|
182
211
|
// Like for HubSpot, the answer is to do a reverse lookup for the integration by the entity external ID (HubSpot Portal ID),
|
|
183
212
|
// and then you'll have the integration ID available to hydrate from.
|
|
213
|
+
console.log(
|
|
214
|
+
`[QueueWorker] no processId/integrationId — running dry instance`,
|
|
215
|
+
logCtx
|
|
216
|
+
);
|
|
184
217
|
integrationInstance = new integrationClass();
|
|
218
|
+
// Merge Tier 3 Integration Extension events into instance.events
|
|
219
|
+
// so extension-contributed queue events can be dispatched.
|
|
220
|
+
await integrationInstance.initialize();
|
|
185
221
|
}
|
|
186
222
|
|
|
187
223
|
const dispatcher = new IntegrationEventDispatcher(
|
|
188
224
|
integrationInstance
|
|
189
225
|
);
|
|
190
226
|
|
|
191
|
-
|
|
227
|
+
console.log(`[QueueWorker] dispatching ${params.event}`, logCtx);
|
|
228
|
+
const result = await dispatcher.dispatchJob({
|
|
192
229
|
event: params.event,
|
|
193
230
|
data: params.data,
|
|
194
231
|
context: context,
|
|
195
232
|
});
|
|
233
|
+
console.log(`[QueueWorker] ${params.event} dispatched ok`, logCtx);
|
|
234
|
+
return result;
|
|
196
235
|
} catch (error) {
|
|
197
236
|
console.error(
|
|
198
|
-
`Error in ${params.event} for ${
|
|
237
|
+
`Error in ${params.event} for ${integrationName}:`,
|
|
199
238
|
error
|
|
200
239
|
);
|
|
201
240
|
|
|
@@ -207,7 +246,12 @@ const createQueueWorker = (integrationClass) => {
|
|
|
207
246
|
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
208
247
|
error.isHaltError = true;
|
|
209
248
|
console.warn(
|
|
210
|
-
`[${
|
|
249
|
+
`[${integrationName}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`,
|
|
250
|
+
{
|
|
251
|
+
...logCtx,
|
|
252
|
+
errorName: error.name,
|
|
253
|
+
errorMessage: error.message,
|
|
254
|
+
}
|
|
211
255
|
);
|
|
212
256
|
}
|
|
213
257
|
|
|
@@ -2,24 +2,47 @@ const { createAppHandler } = require('./../app-handler-helpers');
|
|
|
2
2
|
const {
|
|
3
3
|
loadAppDefinition,
|
|
4
4
|
} = require('../app-definition-loader');
|
|
5
|
-
const
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const { Router } = express;
|
|
6
7
|
const { loadRouterFromObject } = require('../backend-utils');
|
|
8
|
+
const { getExtensionRoutes } = require('../../integrations/extension');
|
|
7
9
|
|
|
8
10
|
const handlers = {};
|
|
9
11
|
const { integrations: integrationClasses } = loadAppDefinition();
|
|
10
12
|
|
|
13
|
+
const routeKey = (method, path) => `${(method || 'ANY').toUpperCase()} ${path}`;
|
|
14
|
+
|
|
11
15
|
//todo: this should be in a use case class
|
|
12
16
|
for (const IntegrationClass of integrationClasses) {
|
|
13
17
|
const router = Router();
|
|
14
18
|
const basePath = `/api/${IntegrationClass.Definition.name}-integration`;
|
|
19
|
+
// Track (method, path) tuples to fail fast on conflicts between Definition.routes,
|
|
20
|
+
// extension routes, or two extensions claiming the same path.
|
|
21
|
+
const claimedRoutes = new Map();
|
|
22
|
+
const claim = (method, path, source) => {
|
|
23
|
+
const key = routeKey(method, path);
|
|
24
|
+
if (claimedRoutes.has(key)) {
|
|
25
|
+
const prev = claimedRoutes.get(key);
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Integration "${IntegrationClass.Definition.name}" route conflict: ` +
|
|
28
|
+
`${key} declared by ${prev} and ${source}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
claimedRoutes.set(key, source);
|
|
32
|
+
};
|
|
15
33
|
|
|
16
34
|
console.log(`\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:`);
|
|
17
35
|
|
|
18
|
-
|
|
36
|
+
const routes = IntegrationClass.Definition.routes || [];
|
|
37
|
+
for (const routeDef of routes) {
|
|
19
38
|
if (typeof routeDef === 'function') {
|
|
20
39
|
router.use(basePath, routeDef(IntegrationClass));
|
|
21
40
|
console.log(`│ ANY ${basePath}/* (function handler)`);
|
|
41
|
+
} else if (routeDef instanceof express.Router) {
|
|
42
|
+
router.use(basePath, routeDef);
|
|
43
|
+
console.log(`│ ANY ${basePath}/* (express router)`);
|
|
22
44
|
} else if (typeof routeDef === 'object') {
|
|
45
|
+
claim(routeDef.method, routeDef.path, 'Definition.routes');
|
|
23
46
|
router.use(
|
|
24
47
|
basePath,
|
|
25
48
|
loadRouterFromObject(IntegrationClass, routeDef)
|
|
@@ -27,11 +50,25 @@ for (const IntegrationClass of integrationClasses) {
|
|
|
27
50
|
const method = (routeDef.method || 'ANY').toUpperCase();
|
|
28
51
|
const fullPath = `${basePath}${routeDef.path}`;
|
|
29
52
|
console.log(`│ ${method} ${fullPath}`);
|
|
30
|
-
} else if (routeDef instanceof express.Router) {
|
|
31
|
-
router.use(basePath, routeDef);
|
|
32
|
-
console.log(`│ ANY ${basePath}/* (express router)`);
|
|
33
53
|
}
|
|
34
54
|
}
|
|
55
|
+
|
|
56
|
+
// Tier 3 Integration Extension routes — see EXTENSIONS.md
|
|
57
|
+
for (const extRoute of getExtensionRoutes(IntegrationClass)) {
|
|
58
|
+
claim(
|
|
59
|
+
extRoute.method,
|
|
60
|
+
extRoute.path,
|
|
61
|
+
`extension "${extRoute.extensionName}" (binding "${extRoute.bindingName}")`
|
|
62
|
+
);
|
|
63
|
+
router.use(
|
|
64
|
+
basePath,
|
|
65
|
+
loadRouterFromObject(IntegrationClass, extRoute)
|
|
66
|
+
);
|
|
67
|
+
const method = extRoute.method.toUpperCase();
|
|
68
|
+
console.log(
|
|
69
|
+
`│ ${method} ${basePath}${extRoute.path} (extension: ${extRoute.extensionName})`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
35
72
|
console.log('│');
|
|
36
73
|
|
|
37
74
|
handlers[`${IntegrationClass.Definition.name}`] = {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
const { createHandler } = require('@friggframework/core');
|
|
2
2
|
const { loadAppDefinition } = require('../app-definition-loader');
|
|
3
3
|
const { createQueueWorker } = require('../backend-utils');
|
|
4
|
+
// TODO(Phase 2): mount extension-declared workers in addition to the per-integration
|
|
5
|
+
// default queue worker. Today, getExtensionWorkers(IntegrationClass) returns the
|
|
6
|
+
// declared workers but they are not yet bound to dedicated SQS sources. Extension-
|
|
7
|
+
// contributed *events* still flow through the default queue worker below because
|
|
8
|
+
// _mergeExtensions() registers them in instance.events, so end-to-end webhook
|
|
9
|
+
// delivery for Tier 3 extensions works without this Phase 2 work.
|
|
10
|
+
// const { getExtensionWorkers } = require('../../integrations/extension');
|
|
4
11
|
|
|
5
12
|
const handlers = {};
|
|
6
13
|
const { integrations: integrationClasses } = loadAppDefinition();
|