@friggframework/core 2.0.0-next.104 → 2.0.0-next.106
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/admin-scripts/repositories/admin-script-execution-repository-interface.js +7 -2
- package/admin-scripts/repositories/admin-script-execution-repository-mongo.js +16 -6
- package/admin-scripts/repositories/admin-script-execution-repository-postgres.js +16 -6
- package/application/commands/admin-script-commands.js +2 -1
- package/application/commands/credential-commands.js +17 -0
- package/application/commands/integration-commands.js +14 -1
- package/application/commands/integration-mapping-commands.js +25 -0
- package/application/commands/report-commands.js +188 -0
- package/artifacts/repositories/artifact-repository-factory.js +19 -0
- package/artifacts/repositories/artifact-repository-interface.js +27 -0
- package/artifacts/repositories/artifact-repository-local.js +42 -0
- package/artifacts/repositories/artifact-repository-s3.js +61 -0
- package/credential/repositories/credential-active-type.js +32 -0
- package/credential/repositories/credential-repository-documentdb.js +51 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +25 -0
- package/credential/repositories/credential-repository-postgres.js +25 -0
- package/database/documentdb-utils.js +56 -0
- package/handlers/app-definition-loader.js +3 -2
- package/index.js +8 -4
- package/integrations/integration-base.js +43 -5
- package/integrations/repositories/integration-mapping-repository-documentdb.js +23 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +14 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +22 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +28 -0
- package/integrations/repositories/integration-repository-documentdb.js +38 -0
- package/integrations/repositories/integration-repository-interface.js +16 -0
- package/integrations/repositories/integration-repository-mongo.js +36 -0
- package/integrations/repositories/integration-repository-postgres.js +40 -0
- package/integrations/repositories/report-id.js +13 -0
- package/modules/requester/oauth-2.js +19 -4
- package/package.json +7 -5
- package/reporting/README.md +109 -48
- package/reporting/builtin-reports.js +6 -0
- package/reporting/index.js +9 -13
- package/reporting/report-base.js +49 -0
- package/reporting/{use-cases/list-integrations-report.js → reports/integrations-report.js} +69 -37
- package/handlers/routers/reporting.js +0 -9
- package/reporting/reporting-router.js +0 -84
- package/reporting/repositories/reporting-repository-documentdb.js +0 -127
- package/reporting/repositories/reporting-repository-factory.js +0 -35
- package/reporting/repositories/reporting-repository-interface.js +0 -16
- package/reporting/repositories/reporting-repository-mongo.js +0 -54
- package/reporting/repositories/reporting-repository-postgres.js +0 -70
- package/reporting/use-cases/index.js +0 -6
|
@@ -34,7 +34,7 @@ class AdminScriptExecutionRepositoryInterface {
|
|
|
34
34
|
* @param {string} [params.context.audit.apiKeyName] - Name of API key used
|
|
35
35
|
* @param {string} [params.context.audit.apiKeyLast4] - Last 4 chars of API key
|
|
36
36
|
* @param {string} [params.context.audit.ipAddress] - IP address of requester
|
|
37
|
-
* @param {string|number} [params.parentExecutionId] - ID of the execution that queued this one
|
|
37
|
+
* @param {string|number} [params.parentExecutionId] - ID of the execution that queued this one, for the parent/child hierarchy
|
|
38
38
|
* @returns {Promise<Object>} The created process record
|
|
39
39
|
* @abstract
|
|
40
40
|
*/
|
|
@@ -65,6 +65,9 @@ class AdminScriptExecutionRepositoryInterface {
|
|
|
65
65
|
* @param {string} [options.sortBy] - Field to sort by
|
|
66
66
|
* @param {string} [options.sortOrder] - Sort order ('asc' or 'desc')
|
|
67
67
|
* @param {string} [options.state] - Optional state filter ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')
|
|
68
|
+
* @param {string} [options.type] - Optional type filter ('ADMIN_SCRIPT', 'REPORT', 'DB_MIGRATION')
|
|
69
|
+
* @param {Date} [options.from] - Optional lower bound (inclusive) on createdAt
|
|
70
|
+
* @param {Date} [options.to] - Optional upper bound (inclusive) on createdAt
|
|
68
71
|
* @returns {Promise<Array>} Array of process records
|
|
69
72
|
* @abstract
|
|
70
73
|
*/
|
|
@@ -153,10 +156,12 @@ class AdminScriptExecutionRepositoryInterface {
|
|
|
153
156
|
* Used for cleanup and retention policies
|
|
154
157
|
*
|
|
155
158
|
* @param {Date} date - Delete processes older than this date
|
|
159
|
+
* @param {Object} [options] - Deletion options
|
|
160
|
+
* @param {string} [options.type] - Optional type filter, so report vs script retention can diverge
|
|
156
161
|
* @returns {Promise<Object>} Deletion result with count
|
|
157
162
|
* @abstract
|
|
158
163
|
*/
|
|
159
|
-
async deleteExecutionsOlderThan(date) {
|
|
164
|
+
async deleteExecutionsOlderThan(date, options = {}) {
|
|
160
165
|
throw new Error(
|
|
161
166
|
'Method deleteExecutionsOlderThan must be implemented by subclass'
|
|
162
167
|
);
|
|
@@ -75,10 +75,19 @@ class AdminScriptExecutionRepositoryMongo extends AdminScriptExecutionRepository
|
|
|
75
75
|
sortBy = 'createdAt',
|
|
76
76
|
sortOrder = 'desc',
|
|
77
77
|
state,
|
|
78
|
+
type,
|
|
79
|
+
from,
|
|
80
|
+
to,
|
|
78
81
|
} = options;
|
|
79
82
|
|
|
80
83
|
const where = { name };
|
|
81
84
|
if (state) where.state = state;
|
|
85
|
+
if (type) where.type = type;
|
|
86
|
+
if (from || to) {
|
|
87
|
+
where.createdAt = {};
|
|
88
|
+
if (from) where.createdAt.gte = from;
|
|
89
|
+
if (to) where.createdAt.lte = to;
|
|
90
|
+
}
|
|
82
91
|
|
|
83
92
|
const processes = await this.prisma.adminScriptExecution.findMany({
|
|
84
93
|
where,
|
|
@@ -207,15 +216,16 @@ class AdminScriptExecutionRepositoryMongo extends AdminScriptExecutionRepository
|
|
|
207
216
|
* Used for cleanup and retention policies
|
|
208
217
|
*
|
|
209
218
|
* @param {Date} date - Delete processes older than this date
|
|
219
|
+
* @param {Object} [options] - Deletion options
|
|
220
|
+
* @param {string} [options.type] - Optional type filter
|
|
210
221
|
* @returns {Promise<Object>} Deletion result with count
|
|
211
222
|
*/
|
|
212
|
-
async deleteExecutionsOlderThan(date) {
|
|
223
|
+
async deleteExecutionsOlderThan(date, { type } = {}) {
|
|
224
|
+
const where = { createdAt: { lt: date } };
|
|
225
|
+
if (type) where.type = type;
|
|
226
|
+
|
|
213
227
|
const result = await this.prisma.adminScriptExecution.deleteMany({
|
|
214
|
-
where
|
|
215
|
-
createdAt: {
|
|
216
|
-
lt: date,
|
|
217
|
-
},
|
|
218
|
-
},
|
|
228
|
+
where,
|
|
219
229
|
});
|
|
220
230
|
|
|
221
231
|
return {
|
|
@@ -110,10 +110,19 @@ class AdminScriptExecutionRepositoryPostgres extends AdminScriptExecutionReposit
|
|
|
110
110
|
sortBy = 'createdAt',
|
|
111
111
|
sortOrder = 'desc',
|
|
112
112
|
state,
|
|
113
|
+
type,
|
|
114
|
+
from,
|
|
115
|
+
to,
|
|
113
116
|
} = options;
|
|
114
117
|
|
|
115
118
|
const where = { name };
|
|
116
119
|
if (state) where.state = state;
|
|
120
|
+
if (type) where.type = type;
|
|
121
|
+
if (from || to) {
|
|
122
|
+
where.createdAt = {};
|
|
123
|
+
if (from) where.createdAt.gte = from;
|
|
124
|
+
if (to) where.createdAt.lte = to;
|
|
125
|
+
}
|
|
117
126
|
|
|
118
127
|
const processes = await this.prisma.adminScriptExecution.findMany({
|
|
119
128
|
where,
|
|
@@ -247,15 +256,16 @@ class AdminScriptExecutionRepositoryPostgres extends AdminScriptExecutionReposit
|
|
|
247
256
|
* Used for cleanup and retention policies
|
|
248
257
|
*
|
|
249
258
|
* @param {Date} date - Delete processes older than this date
|
|
259
|
+
* @param {Object} [options] - Deletion options
|
|
260
|
+
* @param {string} [options.type] - Optional type filter
|
|
250
261
|
* @returns {Promise<Object>} Deletion result with count
|
|
251
262
|
*/
|
|
252
|
-
async deleteExecutionsOlderThan(date) {
|
|
263
|
+
async deleteExecutionsOlderThan(date, { type } = {}) {
|
|
264
|
+
const where = { createdAt: { lt: date } };
|
|
265
|
+
if (type) where.type = type;
|
|
266
|
+
|
|
253
267
|
const result = await this.prisma.adminScriptExecution.deleteMany({
|
|
254
|
-
where
|
|
255
|
-
createdAt: {
|
|
256
|
-
lt: date,
|
|
257
|
-
},
|
|
258
|
-
},
|
|
268
|
+
where,
|
|
259
269
|
});
|
|
260
270
|
|
|
261
271
|
return {
|
|
@@ -105,7 +105,8 @@ function createAdminScriptCommands() {
|
|
|
105
105
|
const process = await adminScriptExecutionRepository.findExecutionById(
|
|
106
106
|
processId
|
|
107
107
|
);
|
|
108
|
-
|
|
108
|
+
// Scripts and reports share one store; exclude REPORT rows so the two never read each other's executions.
|
|
109
|
+
if (!process || process.type === 'REPORT') {
|
|
109
110
|
const error = new Error(`Execution ${processId} not found`);
|
|
110
111
|
error.code = 'EXECUTION_NOT_FOUND';
|
|
111
112
|
return mapErrorToResponse(error);
|
|
@@ -216,6 +216,23 @@ function createCredentialCommands() {
|
|
|
216
216
|
}
|
|
217
217
|
},
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Count credentials active (updatedAt >= since) grouped by integration
|
|
221
|
+
* type, derived from the related Entity.moduleName. Returns a non-secret
|
|
222
|
+
* projection only — never reads or decrypts credential secrets.
|
|
223
|
+
*
|
|
224
|
+
* @param {Object} params
|
|
225
|
+
* @param {Date} [params.since] - Lower bound on updatedAt
|
|
226
|
+
* @returns {Promise<Array<{ integrationType: string, count: number }>>}
|
|
227
|
+
*/
|
|
228
|
+
async countActiveByType({ since } = {}) {
|
|
229
|
+
try {
|
|
230
|
+
return await credRepo.countActiveByType({ since });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return mapErrorToResponse(error);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
|
|
219
236
|
/**
|
|
220
237
|
* Delete a credential by ID (alias for deleteCredential)
|
|
221
238
|
* @param {string} credentialId - Credential ID to delete
|
|
@@ -89,9 +89,21 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Report-shaped projection (derived counters + timestamps) — the
|
|
94
|
+
* cross-integration read that reports (ADR-010) consume via commands.
|
|
95
|
+
*/
|
|
96
|
+
async function listForReport(filter = {}) {
|
|
97
|
+
try {
|
|
98
|
+
return await integrationRepository.findAllForReport(filter);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return mapErrorToResponse(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
// The remaining commands hydrate/modify integrations for a specific class.
|
|
93
105
|
if (!integrationClass) {
|
|
94
|
-
return { findIntegrationById, listIntegrations };
|
|
106
|
+
return { findIntegrationById, listIntegrations, listForReport };
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
const moduleRepository = createModuleRepository();
|
|
@@ -154,6 +166,7 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
154
166
|
return {
|
|
155
167
|
findIntegrationById,
|
|
156
168
|
listIntegrations,
|
|
169
|
+
listForReport,
|
|
157
170
|
|
|
158
171
|
/**
|
|
159
172
|
* Find integration context by external entity ID and type
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createIntegrationMappingRepository,
|
|
3
|
+
} = require('../../integrations/repositories/integration-mapping-repository-factory');
|
|
4
|
+
|
|
5
|
+
function mapErrorToResponse(error) {
|
|
6
|
+
return { error: 500, reason: error?.message, code: error?.code };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Kept separate from integration-commands so the mapping and integration domains stay decoupled.
|
|
10
|
+
function createIntegrationMappingCommands() {
|
|
11
|
+
const mappingRepository = createIntegrationMappingRepository();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
// Returns a Map of integrationId → count, or an error object on failure.
|
|
15
|
+
async countByIntegrationIds(ids = []) {
|
|
16
|
+
try {
|
|
17
|
+
return await mappingRepository.countByIntegrationIds(ids);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return mapErrorToResponse(error);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { createIntegrationMappingCommands };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const ERROR_CODE_MAP = {
|
|
2
|
+
EXECUTION_NOT_FOUND: 404,
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
function mapErrorToResponse(error) {
|
|
6
|
+
const status = ERROR_CODE_MAP[error?.code] || 500;
|
|
7
|
+
return { error: status, reason: error?.message, code: error?.code };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Report commands share the AdminScriptExecution store, discriminated by
|
|
12
|
+
* type:'REPORT'. That store has no user/integration FK, and findExecutionById
|
|
13
|
+
* rejects non-REPORT rows, so a report lookup can never return another
|
|
14
|
+
* operation type's record (ADR-010 Decision 3).
|
|
15
|
+
*/
|
|
16
|
+
function createReportCommands({ artifactRepository } = {}) {
|
|
17
|
+
const {
|
|
18
|
+
createAdminScriptExecutionRepository,
|
|
19
|
+
} = require('../../admin-scripts/repositories/admin-script-execution-repository-factory');
|
|
20
|
+
|
|
21
|
+
const executionRepository = createAdminScriptExecutionRepository();
|
|
22
|
+
|
|
23
|
+
let artifactRepo = artifactRepository || null;
|
|
24
|
+
function getArtifactRepository() {
|
|
25
|
+
if (!artifactRepo) {
|
|
26
|
+
const {
|
|
27
|
+
createArtifactRepository,
|
|
28
|
+
} = require('../../artifacts/repositories/artifact-repository-factory');
|
|
29
|
+
artifactRepo = createArtifactRepository();
|
|
30
|
+
}
|
|
31
|
+
return artifactRepo;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Resolve to null rather than throw: a read must not fail because signing did.
|
|
35
|
+
async function signArtifact(ref) {
|
|
36
|
+
if (!ref) return null;
|
|
37
|
+
try {
|
|
38
|
+
return await getArtifactRepository().signedUrl(ref);
|
|
39
|
+
} catch (_error) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async createExecution({
|
|
46
|
+
reportName,
|
|
47
|
+
reportVersion,
|
|
48
|
+
trigger,
|
|
49
|
+
mode,
|
|
50
|
+
input,
|
|
51
|
+
audit,
|
|
52
|
+
seriesName,
|
|
53
|
+
parentExecutionId,
|
|
54
|
+
}) {
|
|
55
|
+
try {
|
|
56
|
+
return await executionRepository.createExecution({
|
|
57
|
+
name: reportName,
|
|
58
|
+
type: 'REPORT',
|
|
59
|
+
parentExecutionId,
|
|
60
|
+
context: {
|
|
61
|
+
reportVersion,
|
|
62
|
+
trigger,
|
|
63
|
+
mode: mode || 'recorded',
|
|
64
|
+
input,
|
|
65
|
+
audit,
|
|
66
|
+
...(seriesName ? { seriesName } : {}),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return mapErrorToResponse(error);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async findExecutionById(id) {
|
|
75
|
+
try {
|
|
76
|
+
const record = await executionRepository.findExecutionById(id);
|
|
77
|
+
if (!record || record.type !== 'REPORT') {
|
|
78
|
+
const error = new Error(`Report execution ${id} not found`);
|
|
79
|
+
error.code = 'EXECUTION_NOT_FOUND';
|
|
80
|
+
return mapErrorToResponse(error);
|
|
81
|
+
}
|
|
82
|
+
// The stored artifact ref is not retrievable on its own; sign it on read.
|
|
83
|
+
if (record.results?.artifact) {
|
|
84
|
+
record.results = {
|
|
85
|
+
...record.results,
|
|
86
|
+
artifactUrl: await signArtifact(record.results.artifact),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return record;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return mapErrorToResponse(error);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Never-throws: returns [] on error (non-critical read).
|
|
96
|
+
async listExecutionsByName(reportName, { limit, offset, state } = {}) {
|
|
97
|
+
try {
|
|
98
|
+
return await executionRepository.findExecutionsByName(
|
|
99
|
+
reportName,
|
|
100
|
+
{ type: 'REPORT', limit, offset, state }
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// No mode index in the store, so fetch by name/window and filter snapshots
|
|
108
|
+
// in JS. Never-throws: returns [] on error.
|
|
109
|
+
async findSnapshotSeries(reportName, { from, to, limit } = {}) {
|
|
110
|
+
try {
|
|
111
|
+
const rows = await executionRepository.findExecutionsByName(
|
|
112
|
+
reportName,
|
|
113
|
+
{
|
|
114
|
+
type: 'REPORT',
|
|
115
|
+
from,
|
|
116
|
+
to,
|
|
117
|
+
sortBy: 'createdAt',
|
|
118
|
+
sortOrder: 'asc',
|
|
119
|
+
limit,
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
const snapshots = rows.filter(
|
|
123
|
+
(row) => row.context?.mode === 'snapshot'
|
|
124
|
+
);
|
|
125
|
+
return Promise.all(
|
|
126
|
+
snapshots.map(async (row) => ({
|
|
127
|
+
executionId: row.id,
|
|
128
|
+
capturedAt: row.createdAt,
|
|
129
|
+
summary:
|
|
130
|
+
row.results?.output?.summary ??
|
|
131
|
+
row.results?.summary ??
|
|
132
|
+
null,
|
|
133
|
+
artifactUrl: await signArtifact(row.results?.artifact),
|
|
134
|
+
}))
|
|
135
|
+
);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async updateExecutionState(id, state) {
|
|
142
|
+
try {
|
|
143
|
+
return await executionRepository.updateExecutionState(id, state);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return mapErrorToResponse(error);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async appendExecutionLog(id, logEntry) {
|
|
150
|
+
try {
|
|
151
|
+
return await executionRepository.appendExecutionLog(id, logEntry);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return mapErrorToResponse(error);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
// results.output is the inline JSON payload; results.summary + results.artifact
|
|
158
|
+
// are a large/binary payload's summary + object-store reference.
|
|
159
|
+
async completeExecution(
|
|
160
|
+
id,
|
|
161
|
+
{ state, output, summary, artifact, error, metrics, logs } = {}
|
|
162
|
+
) {
|
|
163
|
+
try {
|
|
164
|
+
if (state) {
|
|
165
|
+
await executionRepository.updateExecutionState(id, state);
|
|
166
|
+
}
|
|
167
|
+
const resultsUpdate = {};
|
|
168
|
+
if (output !== undefined) resultsUpdate.output = output;
|
|
169
|
+
if (summary !== undefined) resultsUpdate.summary = summary;
|
|
170
|
+
if (artifact !== undefined) resultsUpdate.artifact = artifact;
|
|
171
|
+
if (error) resultsUpdate.error = error;
|
|
172
|
+
if (metrics) resultsUpdate.metrics = metrics;
|
|
173
|
+
if (logs) resultsUpdate.logs = logs;
|
|
174
|
+
if (Object.keys(resultsUpdate).length > 0) {
|
|
175
|
+
await executionRepository.updateExecutionResults(
|
|
176
|
+
id,
|
|
177
|
+
resultsUpdate
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return { success: true };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return mapErrorToResponse(err);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { createReportCommands };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A configured bucket always wins, even in dev (unlike the encryption stage-bypass):
|
|
2
|
+
// a deployed Lambda writes artifacts and the router reads them in different containers,
|
|
3
|
+
// so routing dev to ephemeral /tmp would silently lose them.
|
|
4
|
+
const { ArtifactRepositoryS3 } = require('./artifact-repository-s3');
|
|
5
|
+
const { ArtifactRepositoryLocal } = require('./artifact-repository-local');
|
|
6
|
+
|
|
7
|
+
function createArtifactRepository() {
|
|
8
|
+
const bucket = process.env.REPORT_ARTIFACT_BUCKET;
|
|
9
|
+
if (bucket) {
|
|
10
|
+
return new ArtifactRepositoryS3({ bucket });
|
|
11
|
+
}
|
|
12
|
+
return new ArtifactRepositoryLocal();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
createArtifactRepository,
|
|
17
|
+
ArtifactRepositoryS3,
|
|
18
|
+
ArtifactRepositoryLocal,
|
|
19
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port for report artifacts (non-JSON output like CSV, PDF). Implementations
|
|
3
|
+
* write bytes to a durable store and return a location reference the caller
|
|
4
|
+
* persists; signed URLs are minted on read.
|
|
5
|
+
*/
|
|
6
|
+
class ArtifactRepositoryInterface {
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} key - Caller-supplied, e.g. `reports/{executionId}/{name}-{stamp}.{ext}`.
|
|
9
|
+
* @returns {Promise<{bucket: string, key: string}>} Location reference.
|
|
10
|
+
*/
|
|
11
|
+
async put(key, body, contentType) {
|
|
12
|
+
throw new Error('ArtifactRepositoryInterface.put not implemented');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{bucket: string, key: string}|string} ref - Reference from put(), or a bare key.
|
|
17
|
+
*/
|
|
18
|
+
async signedUrl(ref, { expiresIn } = {}) {
|
|
19
|
+
throw new Error('ArtifactRepositoryInterface.signedUrl not implemented');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async get(key) {
|
|
23
|
+
throw new Error('ArtifactRepositoryInterface.get not implemented');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { ArtifactRepositoryInterface };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev/test artifact adapter: writes to a local directory and returns file:// URLs.
|
|
3
|
+
* Selected by the factory only when no object store is configured (no REPORT_ARTIFACT_BUCKET).
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const {
|
|
9
|
+
ArtifactRepositoryInterface,
|
|
10
|
+
} = require('./artifact-repository-interface');
|
|
11
|
+
|
|
12
|
+
class ArtifactRepositoryLocal extends ArtifactRepositoryInterface {
|
|
13
|
+
constructor({ baseDir } = {}) {
|
|
14
|
+
super();
|
|
15
|
+
this.baseDir =
|
|
16
|
+
baseDir ||
|
|
17
|
+
process.env.REPORT_ARTIFACT_DIR ||
|
|
18
|
+
path.join(os.tmpdir(), 'frigg-report-artifacts');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_pathFor(key) {
|
|
22
|
+
return path.join(this.baseDir, key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async put(key, body, _contentType) {
|
|
26
|
+
const filePath = this._pathFor(key);
|
|
27
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
28
|
+
await fs.promises.writeFile(filePath, body);
|
|
29
|
+
return { bucket: this.baseDir, key };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async signedUrl(ref, _options = {}) {
|
|
33
|
+
const key = ref?.key || ref;
|
|
34
|
+
return `file://${this._pathFor(key)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get(key) {
|
|
38
|
+
return fs.promises.readFile(this._pathFor(key), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { ArtifactRepositoryLocal };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// aws-sdk modules are lazy-required so importing this file (and the factory that
|
|
2
|
+
// references it) never forces the SDK to load for contexts that only need the local adapter.
|
|
3
|
+
const {
|
|
4
|
+
ArtifactRepositoryInterface,
|
|
5
|
+
} = require('./artifact-repository-interface');
|
|
6
|
+
|
|
7
|
+
class ArtifactRepositoryS3 extends ArtifactRepositoryInterface {
|
|
8
|
+
constructor({ bucket, region, s3Client } = {}) {
|
|
9
|
+
super();
|
|
10
|
+
this.bucket = bucket || process.env.REPORT_ARTIFACT_BUCKET;
|
|
11
|
+
this.region = region || process.env.AWS_REGION || 'us-east-1';
|
|
12
|
+
this._s3Client = s3Client || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_getClient() {
|
|
16
|
+
if (!this._s3Client) {
|
|
17
|
+
const { S3Client } = require('@aws-sdk/client-s3');
|
|
18
|
+
this._s3Client = new S3Client({ region: this.region });
|
|
19
|
+
}
|
|
20
|
+
return this._s3Client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async put(key, body, contentType) {
|
|
24
|
+
if (!this.bucket) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'REPORT_ARTIFACT_BUCKET is not configured; cannot store report artifact'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const { PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
30
|
+
// No ACL set: the bucket blocks public access, so objects stay private.
|
|
31
|
+
await this._getClient().send(
|
|
32
|
+
new PutObjectCommand({
|
|
33
|
+
Bucket: this.bucket,
|
|
34
|
+
Key: key,
|
|
35
|
+
Body: body,
|
|
36
|
+
ContentType: contentType,
|
|
37
|
+
ServerSideEncryption: 'AES256',
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
return { bucket: this.bucket, key };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async signedUrl(ref, { expiresIn = 3600 } = {}) {
|
|
44
|
+
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
45
|
+
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
46
|
+
const bucket = ref?.bucket || this.bucket;
|
|
47
|
+
const key = ref?.key || ref;
|
|
48
|
+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
|
49
|
+
return getSignedUrl(this._getClient(), command, { expiresIn });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async get(key) {
|
|
53
|
+
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
54
|
+
const response = await this._getClient().send(
|
|
55
|
+
new GetObjectCommand({ Bucket: this.bucket, Key: key })
|
|
56
|
+
);
|
|
57
|
+
return response.Body.transformToString();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { ArtifactRepositoryS3 };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const UNKNOWN_TYPE = 'unknown';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type is derived from the related Entity.moduleName since Credential carries
|
|
5
|
+
* no type column. Counted once per distinct linked moduleName; none → `unknown`.
|
|
6
|
+
* Reads only the non-encrypted projection — never `data`/secrets.
|
|
7
|
+
*/
|
|
8
|
+
function tallyActiveCredentialsByType(credentials = []) {
|
|
9
|
+
const counts = new Map();
|
|
10
|
+
|
|
11
|
+
for (const credential of credentials) {
|
|
12
|
+
const modules = new Set(
|
|
13
|
+
(credential?.entities || [])
|
|
14
|
+
.map((entity) => entity?.moduleName)
|
|
15
|
+
.filter(
|
|
16
|
+
(moduleName) => moduleName != null && moduleName !== ''
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
const types = modules.size > 0 ? [...modules] : [UNKNOWN_TYPE];
|
|
20
|
+
|
|
21
|
+
for (const type of types) {
|
|
22
|
+
counts.set(type, (counts.get(type) || 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return [...counts].map(([integrationType, count]) => ({
|
|
27
|
+
integrationType,
|
|
28
|
+
count,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { tallyActiveCredentialsByType, UNKNOWN_TYPE };
|
|
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const {
|
|
3
3
|
toObjectId,
|
|
4
4
|
fromObjectId,
|
|
5
|
+
findManyDrained,
|
|
5
6
|
findOne,
|
|
6
7
|
insertOne,
|
|
7
8
|
updateOne,
|
|
@@ -10,6 +11,7 @@ const {
|
|
|
10
11
|
const {
|
|
11
12
|
CredentialRepositoryInterface,
|
|
12
13
|
} = require('./credential-repository-interface');
|
|
14
|
+
const { tallyActiveCredentialsByType } = require('./credential-active-type');
|
|
13
15
|
const {
|
|
14
16
|
DocumentDBEncryptionService,
|
|
15
17
|
} = require('../../database/documentdb-encryption-service');
|
|
@@ -238,6 +240,55 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
|
|
|
238
240
|
return this._mapCredential(decryptedCredential);
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Count credentials active since a timestamp, grouped by integration type.
|
|
245
|
+
* Projected raw reads only; the encrypted `data` is never fetched, so secrets are never decrypted for this read.
|
|
246
|
+
* @returns {Promise<Array<{ integrationType: string, count: number }>>}
|
|
247
|
+
*/
|
|
248
|
+
async countActiveByType({ since } = {}) {
|
|
249
|
+
const filter = {};
|
|
250
|
+
// Coerce to Date: a raw string never matches the BSON date $gte (type bracketing).
|
|
251
|
+
if (since) filter.updatedAt = { $gte: new Date(since) };
|
|
252
|
+
|
|
253
|
+
// Drained: a deployment-wide scan must not truncate at DocumentDB's ~101-doc first batch.
|
|
254
|
+
const activeCredentials = await findManyDrained(
|
|
255
|
+
this.prisma,
|
|
256
|
+
'Credential',
|
|
257
|
+
filter,
|
|
258
|
+
{ projection: { _id: 1 } }
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const credentialIds = activeCredentials
|
|
262
|
+
.map((doc) => toObjectId(doc._id))
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
|
|
265
|
+
const entities = credentialIds.length
|
|
266
|
+
? await findManyDrained(
|
|
267
|
+
this.prisma,
|
|
268
|
+
'Entity',
|
|
269
|
+
{ credentialId: { $in: credentialIds } },
|
|
270
|
+
{ projection: { credentialId: 1, moduleName: 1 } }
|
|
271
|
+
)
|
|
272
|
+
: [];
|
|
273
|
+
|
|
274
|
+
const entitiesByCredential = new Map();
|
|
275
|
+
for (const entity of entities) {
|
|
276
|
+
const key = fromObjectId(entity.credentialId);
|
|
277
|
+
if (!entitiesByCredential.has(key)) {
|
|
278
|
+
entitiesByCredential.set(key, []);
|
|
279
|
+
}
|
|
280
|
+
entitiesByCredential
|
|
281
|
+
.get(key)
|
|
282
|
+
.push({ moduleName: entity.moduleName });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const credentials = activeCredentials.map((doc) => ({
|
|
286
|
+
entities: entitiesByCredential.get(fromObjectId(doc._id)) || [],
|
|
287
|
+
}));
|
|
288
|
+
|
|
289
|
+
return tallyActiveCredentialsByType(credentials);
|
|
290
|
+
}
|
|
291
|
+
|
|
241
292
|
_buildIdentifierFilter(identifiers) {
|
|
242
293
|
const filter = {};
|
|
243
294
|
if (identifiers._id || identifiers.id) {
|
|
@@ -93,6 +93,21 @@ class CredentialRepositoryInterface {
|
|
|
93
93
|
'Method updateCredential must be implemented by subclass'
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Count credentials active (updatedAt >= since) grouped by integration type.
|
|
99
|
+
* Reads ONLY non-encrypted fields — never the encrypted `data` JSON.
|
|
100
|
+
*
|
|
101
|
+
* @param {Object} params
|
|
102
|
+
* @param {Date} [params.since] - Lower bound on updatedAt
|
|
103
|
+
* @returns {Promise<Array<{ integrationType: string, count: number }>>}
|
|
104
|
+
* @abstract
|
|
105
|
+
*/
|
|
106
|
+
async countActiveByType(/* { since } */) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'Method countActiveByType must be implemented by subclass'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
module.exports = { CredentialRepositoryInterface };
|