@friggframework/core 2.0.0-next.111 → 2.0.0-next.113
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/.eslintrc.json +18 -1
- package/CLAUDE.md +101 -13
- package/README.md +19 -22
- package/core/CLAUDE.md +28 -13
- package/core/Worker.js +32 -37
- package/core/create-handler.js +97 -197
- package/core/index.js +8 -1
- package/core/invocation-scope.js +225 -0
- package/core/secrets-to-env.js +7 -6
- package/database/config.js +0 -7
- package/database/documentdb-encryption-service.js +6 -2
- package/database/documentdb-utils.js +2 -1
- package/database/encryption/README.md +25 -1
- package/database/encryption/encryption-schema-registry.js +3 -0
- package/database/encryption/integration-mapping-encryption.js +97 -0
- package/database/encryption/logger.js +5 -2
- package/database/encryption/prisma-encryption-extension.js +20 -9
- package/errors/fetch-error.js +48 -65
- package/handlers/app-definition-loader.js +38 -2
- package/handlers/app-handler-helpers.js +18 -4
- package/handlers/backend-utils.js +10 -13
- package/handlers/database-migration-handler.js +10 -1
- package/handlers/integration-event-dispatcher.js +2 -2
- package/handlers/routers/integration-webhook-routers.js +13 -6
- package/handlers/routers/websocket.js +27 -7
- package/handlers/workers/db-migration.js +12 -1
- package/handlers/workers/dlq-processor.js +64 -38
- package/index.js +19 -1
- package/integrations/integration-base.js +56 -38
- package/integrations/integration-router.js +21 -20
- package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
- package/integrations/repositories/integration-mapping-query.js +193 -0
- package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
- package/integrations/use-cases/delete-integration-for-user.js +24 -13
- package/jest.config.js +2 -0
- package/logs/.eslintrc.json +26 -1
- package/logs/__fixtures__/events.js +82 -0
- package/logs/__fixtures__/matchers.js +45 -0
- package/logs/__fixtures__/secrets.js +59 -0
- package/logs/__fixtures__/vectors.js +329 -0
- package/logs/__fixtures__/with-env.js +45 -0
- package/logs/context.js +109 -0
- package/logs/contract-keys.js +67 -0
- package/logs/debug-shims.js +46 -0
- package/logs/denied-keys.js +21 -0
- package/logs/index.js +14 -3
- package/logs/jest-logger-setup.js +25 -0
- package/logs/levels.js +71 -0
- package/logs/logger-runtime.js +137 -0
- package/logs/logger.js +134 -42
- package/logs/record.js +276 -0
- package/logs/redact.js +328 -0
- package/logs/serialize.js +329 -0
- package/logs/sinks.js +93 -0
- package/logs/summarize-event.js +163 -0
- package/modules/module.js +32 -19
- package/modules/requester/oauth-2.js +28 -22
- package/modules/requester/requester.js +69 -6
- package/package.json +5 -6
- package/queues/queue-delivery.js +41 -0
- package/queues/queuer-util.js +1 -15
- package/telemetry/bind-telemetry-context.js +4 -0
- package/telemetry/instrument-handler.js +19 -2
- package/telemetry/no-op-telemetry.js +4 -0
- package/telemetry/otel-telemetry.js +25 -6
- package/telemetry/telemetry-context.js +19 -8
- package/telemetry/telemetry-runtime.js +17 -0
- package/telemetry/telemetry-service.js +1 -0
- package/types/errors/index.d.ts +24 -10
- package/types/logs/index.d.ts +61 -2
- package/user/repositories/user-repository-documentdb.js +10 -42
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const SEGMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2
|
+
const MAX_TAKE = 500;
|
|
3
|
+
const MAX_IN_VALUES = 500;
|
|
4
|
+
const MAX_CONDITIONS = 20;
|
|
5
|
+
const DIRECTIONS = ['asc', 'desc'];
|
|
6
|
+
|
|
7
|
+
const OPERATORS = {
|
|
8
|
+
exists: { fields: ['mapping'] },
|
|
9
|
+
notExists: { fields: ['mapping'] },
|
|
10
|
+
in: { fields: ['mapping'], value: toStringList },
|
|
11
|
+
notStartsWith: { fields: ['sourceId'], value: toPrefix },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Checks a queryMappings query and normalizes it for an adapter.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
18
|
+
* @returns {{where: Array<Object>, orderBy: ({path: string[], direction: 'asc'|'desc'}|null), skip: number, take: number, omit: string[]}}
|
|
19
|
+
* @throws {Error} When the query does not fit that shape
|
|
20
|
+
*/
|
|
21
|
+
function validateMappingQuery(query) {
|
|
22
|
+
if (!isPlainObject(query)) {
|
|
23
|
+
throw new Error('queryMappings: query must be an object');
|
|
24
|
+
}
|
|
25
|
+
const { where = [], orderBy, skip = 0, take, omit = [] } = query;
|
|
26
|
+
if (!Array.isArray(where)) {
|
|
27
|
+
throw new Error('queryMappings: where must be an array');
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isInteger(take) || take < 1 || take > MAX_TAKE) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`queryMappings: take must be an integer between 1 and ${MAX_TAKE}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isSafeInteger(skip) || skip < 0) {
|
|
35
|
+
throw new Error('queryMappings: skip must be a non-negative integer');
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
!Array.isArray(omit) ||
|
|
39
|
+
!omit.every((key) => typeof key === 'string' && SEGMENT_REGEX.test(key))
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`queryMappings: omit must be an array of top-level mapping keys matching ${SEGMENT_REGEX}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const whereEntries = where.map(toWhereEntry);
|
|
47
|
+
const conditionCount = whereEntries.reduce(
|
|
48
|
+
(count, entry) => count + (entry.anyOf ? entry.anyOf.length : 1),
|
|
49
|
+
0
|
|
50
|
+
);
|
|
51
|
+
if (conditionCount > MAX_CONDITIONS) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`queryMappings: where must have at most ${MAX_CONDITIONS} conditions, anyOf members included`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
where: whereEntries,
|
|
59
|
+
orderBy: orderBy === undefined ? null : toOrderBy(orderBy),
|
|
60
|
+
skip,
|
|
61
|
+
take,
|
|
62
|
+
omit,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toOrderBy(orderBy) {
|
|
67
|
+
if (!isPlainObject(orderBy)) {
|
|
68
|
+
throw new Error('queryMappings: orderBy must be an object');
|
|
69
|
+
}
|
|
70
|
+
const { field, segments } = parsePath(orderBy.path);
|
|
71
|
+
if (field !== 'mapping') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"queryMappings: orderBy.path must be a mapping path ('mapping.<segment>...')"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!DIRECTIONS.includes(orderBy.direction)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"queryMappings: orderBy.direction must be 'asc' or 'desc'"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return { path: segments, direction: orderBy.direction };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toWhereEntry(entry) {
|
|
85
|
+
assertWhereEntryShape(entry);
|
|
86
|
+
if (!('anyOf' in entry)) return toCondition(entry);
|
|
87
|
+
|
|
88
|
+
const { anyOf } = entry;
|
|
89
|
+
if (!Array.isArray(anyOf) || anyOf.length === 0) {
|
|
90
|
+
throw new Error('queryMappings: anyOf must be a non-empty array');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
anyOf: anyOf.map((condition) => {
|
|
94
|
+
assertWhereEntryShape(condition);
|
|
95
|
+
if ('anyOf' in condition) {
|
|
96
|
+
throw new Error('queryMappings: nested anyOf is not supported');
|
|
97
|
+
}
|
|
98
|
+
return toCondition(condition);
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertWhereEntryShape(entry) {
|
|
104
|
+
if (!isPlainObject(entry)) {
|
|
105
|
+
throw new Error('queryMappings: each where entry must be an object');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isPlainObject(value) {
|
|
110
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `{ path: 'mapping.outbound.status', op: 'in', value: ['failed'] }` →
|
|
115
|
+
* `{ field: 'mapping', path: ['outbound', 'status'], op: 'in', value: ['failed'] }`.
|
|
116
|
+
* A `sourceId` condition has no `path`; an op without a value has no `value`.
|
|
117
|
+
*/
|
|
118
|
+
function toCondition({ path, op, value }) {
|
|
119
|
+
const { field, segments } = parsePath(path);
|
|
120
|
+
const operator = Object.hasOwn(OPERATORS, op) ? OPERATORS[op] : null;
|
|
121
|
+
if (!operator?.fields.includes(field)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`queryMappings: op ${JSON.stringify(
|
|
124
|
+
op
|
|
125
|
+
)} is not allowed on '${path}' (allowed: ${operatorsOn(field).join(
|
|
126
|
+
', '
|
|
127
|
+
)})`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
field,
|
|
132
|
+
...(segments.length > 0 && { path: segments }),
|
|
133
|
+
op,
|
|
134
|
+
...(operator.value && { value: operator.value(value, path) }),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function operatorsOn(field) {
|
|
139
|
+
return Object.keys(OPERATORS).filter((op) =>
|
|
140
|
+
OPERATORS[op].fields.includes(field)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toStringList(value, path) {
|
|
145
|
+
if (
|
|
146
|
+
!Array.isArray(value) ||
|
|
147
|
+
value.length === 0 ||
|
|
148
|
+
!value.every((v) => typeof v === 'string')
|
|
149
|
+
) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`queryMappings: 'in' value must be a non-empty array of strings on '${path}'`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (value.length > MAX_IN_VALUES) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`queryMappings: 'in' value must have at most ${MAX_IN_VALUES} strings on '${path}'`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toPrefix(value, path) {
|
|
163
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`queryMappings: 'notStartsWith' value must be a non-empty string on '${path}'`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `'mapping.outbound.status'` → `{ field: 'mapping', segments: ['outbound', 'status'] }`,
|
|
173
|
+
* `'sourceId'` → `{ field: 'sourceId', segments: [] }`.
|
|
174
|
+
*/
|
|
175
|
+
function parsePath(path) {
|
|
176
|
+
const [field, ...segments] =
|
|
177
|
+
typeof path === 'string' ? path.split('.') : [];
|
|
178
|
+
const valid =
|
|
179
|
+
(field === 'sourceId' && segments.length === 0) ||
|
|
180
|
+
(field === 'mapping' &&
|
|
181
|
+
segments.length > 0 &&
|
|
182
|
+
segments.every((segment) => SEGMENT_REGEX.test(segment)));
|
|
183
|
+
if (!valid) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`queryMappings: invalid path ${JSON.stringify(
|
|
186
|
+
path
|
|
187
|
+
)} (must be 'sourceId' or 'mapping.<segment>...' with segments matching ${SEGMENT_REGEX})`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return { field, segments };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { validateMappingQuery };
|
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
updateOne,
|
|
9
9
|
deleteOne,
|
|
10
10
|
deleteMany,
|
|
11
|
+
aggregate,
|
|
11
12
|
aggregateDrained,
|
|
12
13
|
} = require('../../database/documentdb-utils');
|
|
13
14
|
const {
|
|
@@ -16,6 +17,20 @@ const {
|
|
|
16
17
|
const {
|
|
17
18
|
DocumentDBEncryptionService,
|
|
18
19
|
} = require('../../database/documentdb-encryption-service');
|
|
20
|
+
const {
|
|
21
|
+
assertMappingWrittenUnencrypted,
|
|
22
|
+
} = require('../../database/encryption/integration-mapping-encryption');
|
|
23
|
+
const { validateMappingQuery } = require('./integration-mapping-query');
|
|
24
|
+
const {
|
|
25
|
+
buildMappingQueryStages,
|
|
26
|
+
} = require('./integration-mapping-query-pipeline');
|
|
27
|
+
|
|
28
|
+
function storedIntegrationId(id) {
|
|
29
|
+
if (!['string', 'number'].includes(typeof id) || id === '') {
|
|
30
|
+
throw new TypeError(`Invalid ID: ${id}`);
|
|
31
|
+
}
|
|
32
|
+
return String(id);
|
|
33
|
+
}
|
|
19
34
|
|
|
20
35
|
class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositoryInterface {
|
|
21
36
|
constructor() {
|
|
@@ -179,6 +194,44 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
|
|
|
179
194
|
return decryptedDocs.map((doc) => this._mapMapping(doc));
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} integrationId
|
|
199
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
200
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>}
|
|
201
|
+
*/
|
|
202
|
+
async queryMappings(integrationId, query) {
|
|
203
|
+
const validated = validateMappingQuery(query);
|
|
204
|
+
const stored = storedIntegrationId(integrationId);
|
|
205
|
+
assertMappingWrittenUnencrypted();
|
|
206
|
+
const { match, page, sort } = buildMappingQueryStages(
|
|
207
|
+
stored,
|
|
208
|
+
validated
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const [docs, counts] = await Promise.all([
|
|
212
|
+
aggregateDrained(
|
|
213
|
+
this.prisma,
|
|
214
|
+
'IntegrationMapping',
|
|
215
|
+
[match, ...page, sort],
|
|
216
|
+
{ allowDiskUse: true }
|
|
217
|
+
),
|
|
218
|
+
aggregate(this.prisma, 'IntegrationMapping', [
|
|
219
|
+
match,
|
|
220
|
+
{ $count: 'total' },
|
|
221
|
+
]),
|
|
222
|
+
]);
|
|
223
|
+
const decryptedDocs = await Promise.all(
|
|
224
|
+
docs.map((doc) =>
|
|
225
|
+
this.encryptionService.decryptFields('IntegrationMapping', doc)
|
|
226
|
+
)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
mappings: decryptedDocs.map((doc) => this._mapMapping(doc)),
|
|
231
|
+
total: counts[0]?.total ?? 0,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
182
235
|
async deleteMapping(integrationId, sourceId) {
|
|
183
236
|
const filter = this._compositeFilter(integrationId, sourceId);
|
|
184
237
|
const result = await deleteOne(
|
|
@@ -52,6 +52,54 @@ class IntegrationMappingRepositoryInterface {
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Query one filtered, ordered page of an integration's mappings without
|
|
57
|
+
* loading every row. Rows have the same shape as findMappingsByIntegration
|
|
58
|
+
* and are decrypted the same way.
|
|
59
|
+
*
|
|
60
|
+
* Paths address the `mapping` JSON by identifier-only segments
|
|
61
|
+
* (`'mapping.outbound.status'`), or the `sourceId` column. Conditions:
|
|
62
|
+
* - `{ path: 'mapping.…', op: 'exists' | 'notExists' }` — JSON null counts
|
|
63
|
+
* as absent; notExists is the exact negation of exists.
|
|
64
|
+
* - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
|
|
65
|
+
* strings; 1–500 values.
|
|
66
|
+
* - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
|
|
67
|
+
* sourceId matches.
|
|
68
|
+
*
|
|
69
|
+
* Only rows whose `mapping` is a JSON object can match, so rows whose
|
|
70
|
+
* whole `mapping` is still ciphertext from before an encryption opt-out
|
|
71
|
+
* never do. A nested path still encrypted from before its opt-out comes
|
|
72
|
+
* back plain, but conditions and orderBy on it see the ciphertext.
|
|
73
|
+
* Adapters refuse to run while field-level encryption is enabled
|
|
74
|
+
* and still encrypts `mapping`, or a path inside it, on write; opt out with
|
|
75
|
+
* `appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }`
|
|
76
|
+
* plus any nested `mapping.…` path a custom schema encrypts.
|
|
77
|
+
*
|
|
78
|
+
* @param {string|number} integrationId - The integration ID
|
|
79
|
+
* @param {Object} query
|
|
80
|
+
* @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
|
|
81
|
+
* entry may be `{ anyOf: Condition[] }` (one level, ORed). At most 20
|
|
82
|
+
* conditions, anyOf members included.
|
|
83
|
+
* @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
|
|
84
|
+
* mapping path; nulls last, ties broken by id in the same direction.
|
|
85
|
+
* Values order string < number < boolean < array < object. Strings
|
|
86
|
+
* compare by the database collation on PostgreSQL and by code point on
|
|
87
|
+
* MongoDB and DocumentDB; arrays and objects order among themselves
|
|
88
|
+
* only on PostgreSQL. Without it rows are ordered by id ascending.
|
|
89
|
+
* @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
|
|
90
|
+
* @param {number} query.take - Page size (integer 1–500)
|
|
91
|
+
* @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
|
|
92
|
+
* the returned rows; such projected rows must not be written back
|
|
93
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
|
|
94
|
+
* the number of rows matching `where` (counted by a separate command on
|
|
95
|
+
* DocumentDB, so not from the page's snapshot)
|
|
96
|
+
*/
|
|
97
|
+
async queryMappings(integrationId, query) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'queryMappings is not supported by this database adapter yet'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
55
103
|
/**
|
|
56
104
|
* Delete a specific mapping
|
|
57
105
|
*
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
assertMappingWrittenUnencrypted,
|
|
4
|
+
decryptQueriedMappings,
|
|
5
|
+
} = require('../../database/encryption/integration-mapping-encryption');
|
|
2
6
|
const {
|
|
3
7
|
IntegrationMappingRepositoryInterface,
|
|
4
8
|
} = require('./integration-mapping-repository-interface');
|
|
9
|
+
const { validateMappingQuery } = require('./integration-mapping-query');
|
|
10
|
+
const {
|
|
11
|
+
buildMappingQueryStages,
|
|
12
|
+
} = require('./integration-mapping-query-pipeline');
|
|
13
|
+
|
|
14
|
+
const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/;
|
|
15
|
+
|
|
16
|
+
const fromRawDate = (raw) => new Date(raw.$date);
|
|
17
|
+
|
|
18
|
+
function strictObjectId(id) {
|
|
19
|
+
if (typeof id !== 'string' || !OBJECT_ID_REGEX.test(id)) {
|
|
20
|
+
throw new TypeError(`Invalid ID: ${id} is not an ObjectId`);
|
|
21
|
+
}
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
5
24
|
|
|
6
25
|
/**
|
|
7
26
|
* MongoDB Integration Mapping Repository Adapter
|
|
@@ -155,6 +174,59 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
|
|
|
155
174
|
return counts;
|
|
156
175
|
}
|
|
157
176
|
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} integrationId
|
|
179
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
180
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>}
|
|
181
|
+
*/
|
|
182
|
+
async queryMappings(integrationId, query) {
|
|
183
|
+
const validated = validateMappingQuery(query);
|
|
184
|
+
const objectId = strictObjectId(integrationId);
|
|
185
|
+
assertMappingWrittenUnencrypted();
|
|
186
|
+
const { match, page } = buildMappingQueryStages(
|
|
187
|
+
{ $oid: objectId },
|
|
188
|
+
validated
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const result = await this.prisma.$runCommandRaw({
|
|
192
|
+
aggregate: 'IntegrationMapping',
|
|
193
|
+
pipeline: [
|
|
194
|
+
match,
|
|
195
|
+
{
|
|
196
|
+
$facet: {
|
|
197
|
+
mappings: page,
|
|
198
|
+
total: [{ $count: 'total' }],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
cursor: {},
|
|
203
|
+
allowDiskUse: true,
|
|
204
|
+
});
|
|
205
|
+
const [{ mappings, total }] = result.cursor.firstBatch;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
mappings: await decryptQueriedMappings(
|
|
209
|
+
mappings.map((doc) => this._fromRawMapping(doc))
|
|
210
|
+
),
|
|
211
|
+
total: total[0]?.total ?? 0,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A raw aggregate document, in the shape findMappingsByIntegration returns.
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
_fromRawMapping(doc) {
|
|
220
|
+
return {
|
|
221
|
+
id: doc._id.$oid,
|
|
222
|
+
integrationId: doc.integrationId.$oid,
|
|
223
|
+
sourceId: doc.sourceId ?? null,
|
|
224
|
+
mapping: doc.mapping ?? null,
|
|
225
|
+
createdAt: fromRawDate(doc.createdAt),
|
|
226
|
+
updatedAt: fromRawDate(doc.updatedAt),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
158
230
|
/**
|
|
159
231
|
* Find mapping by ID
|
|
160
232
|
* @param {string} id - Mapping ID
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
assertMappingWrittenUnencrypted,
|
|
4
|
+
decryptQueriedMappings,
|
|
5
|
+
} = require('../../database/encryption/integration-mapping-encryption');
|
|
2
6
|
const {
|
|
3
7
|
IntegrationMappingRepositoryInterface,
|
|
4
8
|
} = require('./integration-mapping-repository-interface');
|
|
5
9
|
const { strictIntId } = require('./report-id');
|
|
10
|
+
const { validateMappingQuery } = require('./integration-mapping-query');
|
|
11
|
+
|
|
12
|
+
const COLUMNS = { mapping: '"mapping"', sourceId: '"sourceId"' };
|
|
13
|
+
const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
|
|
14
|
+
|
|
15
|
+
const jsonPathOperand = (column, path) => ({
|
|
16
|
+
json: `${column} #> ${path}::text[]`,
|
|
17
|
+
text: `${column} #>> ${path}::text[]`,
|
|
18
|
+
});
|
|
19
|
+
const jsonType = (json) => `COALESCE(jsonb_typeof(${json}), 'null')`;
|
|
20
|
+
|
|
21
|
+
const CONDITION_SQL = {
|
|
22
|
+
exists: ({ json }) => `${jsonType(json)} <> 'null'`,
|
|
23
|
+
notExists: ({ json }) => `${jsonType(json)} = 'null'`,
|
|
24
|
+
in: ({ json, text, value }) =>
|
|
25
|
+
`(jsonb_typeof(${json}) = 'string' AND ${text} = ANY(${value}::text[]))`,
|
|
26
|
+
notStartsWith: ({ text, value }) =>
|
|
27
|
+
`(${text} IS NULL OR NOT starts_with(${text}, ${value}::text))`,
|
|
28
|
+
};
|
|
6
29
|
|
|
7
30
|
/**
|
|
8
31
|
* PostgreSQL Integration Mapping Repository Adapter
|
|
@@ -216,6 +239,101 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
|
|
|
216
239
|
return counts;
|
|
217
240
|
}
|
|
218
241
|
|
|
242
|
+
/**
|
|
243
|
+
* @param {string} integrationId
|
|
244
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
245
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>}
|
|
246
|
+
*/
|
|
247
|
+
async queryMappings(integrationId, query) {
|
|
248
|
+
const { where, orderBy, skip, take, omit } =
|
|
249
|
+
validateMappingQuery(query);
|
|
250
|
+
const intIntegrationId = strictIntId(integrationId);
|
|
251
|
+
assertMappingWrittenUnencrypted();
|
|
252
|
+
|
|
253
|
+
const params = [];
|
|
254
|
+
const bind = (v) => {
|
|
255
|
+
params.push(v);
|
|
256
|
+
return `$${params.length}`;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const whereSql = [
|
|
260
|
+
`"integrationId" = ${bind(intIntegrationId)}::int`,
|
|
261
|
+
`jsonb_typeof("mapping") = 'object'`,
|
|
262
|
+
...where.map((entry) => this._whereEntrySql(entry, bind)),
|
|
263
|
+
].join(' AND ');
|
|
264
|
+
const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
|
|
265
|
+
const mappingSql =
|
|
266
|
+
omit.length > 0
|
|
267
|
+
? `"mapping" - ${bind(omit)}::text[] AS "mapping"`
|
|
268
|
+
: `"mapping"`;
|
|
269
|
+
|
|
270
|
+
const sql = `
|
|
271
|
+
WITH "matched" AS (
|
|
272
|
+
SELECT "id", "integrationId", "sourceId", "mapping", "createdAt", "updatedAt"
|
|
273
|
+
FROM "IntegrationMapping"
|
|
274
|
+
WHERE ${whereSql}
|
|
275
|
+
),
|
|
276
|
+
"page" AS (
|
|
277
|
+
SELECT * FROM "matched"
|
|
278
|
+
ORDER BY ${orderSql}
|
|
279
|
+
OFFSET ${bind(skip)}::bigint
|
|
280
|
+
LIMIT ${bind(take)}::int
|
|
281
|
+
)
|
|
282
|
+
SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt",
|
|
283
|
+
(SELECT COUNT(*)::int FROM "matched") AS "__total"
|
|
284
|
+
FROM (VALUES (1)) AS "one"
|
|
285
|
+
LEFT JOIN "page" ON true
|
|
286
|
+
ORDER BY ${orderSql}
|
|
287
|
+
`;
|
|
288
|
+
const rows = await this.prisma.$queryRawUnsafe(sql, ...params);
|
|
289
|
+
const mappings = await decryptQueriedMappings(
|
|
290
|
+
rows
|
|
291
|
+
.filter((row) => row.id !== null)
|
|
292
|
+
.map(({ __total, ...row }) => this._convertMappingIds(row))
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return { mappings, total: rows[0].__total };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One where entry: a condition, or an anyOf group as a parenthesized OR.
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
_whereEntrySql(entry, bind) {
|
|
303
|
+
if (!entry.anyOf) return this._conditionSql(entry, bind);
|
|
304
|
+
const alternatives = entry.anyOf.map((condition) =>
|
|
305
|
+
this._conditionSql(condition, bind)
|
|
306
|
+
);
|
|
307
|
+
return `(${alternatives.join(' OR ')})`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* SQL predicate for one validated queryMappings condition. `exists`
|
|
312
|
+
* treats JSON null as absent, and `notExists` is its exact negation.
|
|
313
|
+
* @private
|
|
314
|
+
*/
|
|
315
|
+
_conditionSql({ field, path, op, value }, bind) {
|
|
316
|
+
const column = COLUMNS[field];
|
|
317
|
+
const operand = path
|
|
318
|
+
? jsonPathOperand(column, bind(path))
|
|
319
|
+
: { text: column };
|
|
320
|
+
return CONDITION_SQL[op]({
|
|
321
|
+
...operand,
|
|
322
|
+
value: value === undefined ? undefined : bind(value),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* NULLIF folds JSON null into SQL NULL, so both sort after every value.
|
|
328
|
+
* @private
|
|
329
|
+
*/
|
|
330
|
+
_orderSql({ path, direction }, bind) {
|
|
331
|
+
const { json } = jsonPathOperand(COLUMNS.mapping, bind(path));
|
|
332
|
+
const value = `NULLIF(${json}, 'null'::jsonb)`;
|
|
333
|
+
const sqlDirection = SQL_DIRECTIONS[direction];
|
|
334
|
+
return `${value} ${sqlDirection} NULLS LAST, "id" ${sqlDirection}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
219
337
|
/**
|
|
220
338
|
* Find mapping by ID
|
|
221
339
|
* @param {string} id - Mapping ID (string from application layer)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { getLogger } = require('../../logs');
|
|
3
|
+
|
|
4
|
+
const log = getLogger('frigg.integrations');
|
|
2
5
|
// Removed Integration wrapper - using IntegrationBase directly
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -65,17 +68,25 @@ class DeleteIntegrationForUser {
|
|
|
65
68
|
);
|
|
66
69
|
modules.push(moduleInstance);
|
|
67
70
|
} catch (error) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
log.warn('Failed to load module for deletion', {
|
|
72
|
+
eventName: 'frigg.integrations.delete_module_load_failed',
|
|
73
|
+
integrationId,
|
|
74
|
+
entityId,
|
|
75
|
+
error,
|
|
76
|
+
});
|
|
72
77
|
failedModuleLoads.push({ entityId, error: error.message });
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
if (failedModuleLoads.length > 0) {
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
log.warn(
|
|
83
|
+
'Some modules failed to load. Webhooks for these modules may require manual cleanup.',
|
|
84
|
+
{
|
|
85
|
+
eventName: 'frigg.integrations.delete_modules_incomplete',
|
|
86
|
+
integrationId,
|
|
87
|
+
failedCount: failedModuleLoads.length,
|
|
88
|
+
totalCount: integrationRecord.entitiesIds.length,
|
|
89
|
+
}
|
|
79
90
|
);
|
|
80
91
|
}
|
|
81
92
|
|
|
@@ -97,19 +108,19 @@ class DeleteIntegrationForUser {
|
|
|
97
108
|
try {
|
|
98
109
|
await integrationInstance.send('ON_DELETE');
|
|
99
110
|
} catch (error) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`[Integration Deletion] onDelete failed for integration ${integrationId}, leaving it IN_DELETION:`,
|
|
103
|
-
reason
|
|
104
|
-
);
|
|
111
|
+
// The error text can echo a request with its credentials and
|
|
112
|
+
// messages reach end users; the boundary logs the cause once.
|
|
105
113
|
await integrationInstance.updateIntegrationMessages.execute(
|
|
106
114
|
integrationId,
|
|
107
115
|
'errors',
|
|
108
116
|
'Integration Deletion Error',
|
|
109
|
-
|
|
117
|
+
'Deletion did not complete. Contact support.',
|
|
110
118
|
Date.now()
|
|
111
119
|
);
|
|
112
|
-
throw
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Integration ${integrationId} deletion did not complete`,
|
|
122
|
+
{ cause: error }
|
|
123
|
+
);
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
await this.integrationRepository.deleteIntegrationById(integrationId);
|
package/jest.config.js
CHANGED
package/logs/.eslintrc.json
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "@friggframework/eslint-config"
|
|
2
|
+
"extends": "@friggframework/eslint-config",
|
|
3
|
+
"rules": {
|
|
4
|
+
"no-console": "error",
|
|
5
|
+
"no-restricted-syntax": [
|
|
6
|
+
"error",
|
|
7
|
+
{
|
|
8
|
+
"selector": "CallExpression[callee.object.object.name='process'][callee.object.property.name=/^(stdout|stderr)$/][callee.property.name='write']",
|
|
9
|
+
"message": "Write through the logger; only logs/sinks.js writes to fd 1"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"overrides": [
|
|
14
|
+
{
|
|
15
|
+
"files": ["*.test.js", "__fixtures__/**", "jest-logger-setup.js"],
|
|
16
|
+
"rules": {
|
|
17
|
+
"no-console": "off",
|
|
18
|
+
"no-restricted-syntax": "off"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"files": ["sinks.js"],
|
|
23
|
+
"rules": {
|
|
24
|
+
"no-restricted-syntax": "off"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
3
28
|
}
|