@friggframework/admin-scripts 2.0.0--canary.517.41839c5.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.
Files changed (35) hide show
  1. package/LICENSE.md +9 -0
  2. package/index.js +66 -0
  3. package/package.json +53 -0
  4. package/src/adapters/__tests__/aws-scheduler-adapter.test.js +322 -0
  5. package/src/adapters/__tests__/local-scheduler-adapter.test.js +325 -0
  6. package/src/adapters/__tests__/scheduler-adapter-factory.test.js +257 -0
  7. package/src/adapters/__tests__/scheduler-adapter.test.js +103 -0
  8. package/src/adapters/aws-scheduler-adapter.js +138 -0
  9. package/src/adapters/local-scheduler-adapter.js +103 -0
  10. package/src/adapters/scheduler-adapter-factory.js +69 -0
  11. package/src/adapters/scheduler-adapter.js +64 -0
  12. package/src/application/__tests__/admin-frigg-commands.test.js +643 -0
  13. package/src/application/__tests__/admin-script-base.test.js +273 -0
  14. package/src/application/__tests__/dry-run-http-interceptor.test.js +313 -0
  15. package/src/application/__tests__/dry-run-repository-wrapper.test.js +257 -0
  16. package/src/application/__tests__/schedule-management-use-case.test.js +276 -0
  17. package/src/application/__tests__/script-factory.test.js +381 -0
  18. package/src/application/__tests__/script-runner.test.js +202 -0
  19. package/src/application/admin-frigg-commands.js +242 -0
  20. package/src/application/admin-script-base.js +138 -0
  21. package/src/application/dry-run-http-interceptor.js +296 -0
  22. package/src/application/dry-run-repository-wrapper.js +261 -0
  23. package/src/application/schedule-management-use-case.js +230 -0
  24. package/src/application/script-factory.js +161 -0
  25. package/src/application/script-runner.js +254 -0
  26. package/src/builtins/__tests__/integration-health-check.test.js +598 -0
  27. package/src/builtins/__tests__/oauth-token-refresh.test.js +344 -0
  28. package/src/builtins/index.js +28 -0
  29. package/src/builtins/integration-health-check.js +279 -0
  30. package/src/builtins/oauth-token-refresh.js +221 -0
  31. package/src/infrastructure/__tests__/admin-auth-middleware.test.js +148 -0
  32. package/src/infrastructure/__tests__/admin-script-router.test.js +701 -0
  33. package/src/infrastructure/admin-auth-middleware.js +49 -0
  34. package/src/infrastructure/admin-script-router.js +311 -0
  35. package/src/infrastructure/script-executor-handler.js +75 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Dry-Run Repository Wrapper
3
+ *
4
+ * Wraps any repository to intercept write operations.
5
+ * - READ operations pass through unchanged
6
+ * - WRITE operations are logged but not executed
7
+ *
8
+ * Uses Proxy pattern for dynamic method interception
9
+ */
10
+
11
+ /**
12
+ * Create a dry-run wrapper for any repository
13
+ *
14
+ * @param {Object} repository - The real repository to wrap
15
+ * @param {Array} operationLog - Array to append logged operations
16
+ * @param {string} modelName - Name of the model (for logging)
17
+ * @returns {Proxy} Wrapped repository that logs write operations
18
+ */
19
+ function createDryRunWrapper(repository, operationLog, modelName) {
20
+ return new Proxy(repository, {
21
+ get(target, prop) {
22
+ const value = target[prop];
23
+
24
+ // Return non-function properties as-is
25
+ if (typeof value !== 'function') {
26
+ return value;
27
+ }
28
+
29
+ // Identify write operations by name pattern
30
+ const writePatterns = /^(create|update|delete|upsert|append|remove|insert|save)/i;
31
+ const isWrite = writePatterns.test(prop);
32
+
33
+ // Pass through read operations
34
+ if (!isWrite) {
35
+ return value.bind(target);
36
+ }
37
+
38
+ // Wrap write operation
39
+ return async (...args) => {
40
+ // Log the operation that WOULD have been performed
41
+ operationLog.push({
42
+ operation: prop.toUpperCase(),
43
+ model: modelName,
44
+ method: prop,
45
+ args: sanitizeArgs(args),
46
+ timestamp: new Date().toISOString(),
47
+ wouldExecute: `${modelName}.${prop}()`,
48
+ });
49
+
50
+ // For write operations, try to return existing data or mock data
51
+ // This helps scripts continue executing without errors
52
+
53
+ // For updates, try to return existing data
54
+ if (prop.includes('update') || prop.includes('upsert')) {
55
+ // Try to extract ID from first argument
56
+ const possibleId = args[0];
57
+ let existing = null;
58
+
59
+ if (possibleId && typeof possibleId === 'string') {
60
+ // Try to find existing record
61
+ const findMethod = getFindMethod(target, prop);
62
+ if (findMethod) {
63
+ try {
64
+ existing = await findMethod.call(target, possibleId);
65
+ } catch (err) {
66
+ // Ignore errors, continue to mock
67
+ }
68
+ }
69
+ }
70
+
71
+ // Return merged data
72
+ if (existing) {
73
+ // Merge update data with existing
74
+ return { ...existing, ...args[1], _dryRun: true };
75
+ }
76
+
77
+ // No existing data, return mock
78
+ if (args[1]) {
79
+ return { id: possibleId, ...args[1], _dryRun: true };
80
+ }
81
+
82
+ return { id: possibleId, _dryRun: true };
83
+ }
84
+
85
+ // For creates, return mock object with the data
86
+ if (prop.includes('create') || prop.includes('insert')) {
87
+ const data = args[0] || {};
88
+ return {
89
+ id: `dry-run-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
90
+ ...data,
91
+ _dryRun: true,
92
+ createdAt: new Date().toISOString(),
93
+ };
94
+ }
95
+
96
+ // For deletes, return success indication
97
+ if (prop.includes('delete') || prop.includes('remove')) {
98
+ return { deletedCount: 1, _dryRun: true };
99
+ }
100
+
101
+ // Default: return mock success
102
+ return { success: true, _dryRun: true };
103
+ };
104
+ },
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Try to find a corresponding find method for an update operation
110
+ * @param {Object} target - Repository target
111
+ * @param {string} updateMethod - Update method name
112
+ * @returns {Function|null} Find method or null
113
+ */
114
+ function getFindMethod(target, updateMethod) {
115
+ // Common patterns: updateIntegration -> findIntegrationById
116
+ const patterns = [
117
+ () => {
118
+ const match = updateMethod.match(/update(\w+)/i);
119
+ return match ? `find${match[1]}ById` : null;
120
+ },
121
+ () => {
122
+ const match = updateMethod.match(/update(\w+)/i);
123
+ return match ? `get${match[1]}ById` : null;
124
+ },
125
+ () => 'findById',
126
+ () => 'getById',
127
+ ];
128
+
129
+ for (const pattern of patterns) {
130
+ const methodName = pattern();
131
+ if (methodName && typeof target[methodName] === 'function') {
132
+ return target[methodName];
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Sanitize arguments for logging (remove sensitive data)
141
+ * @param {Array} args - Function arguments
142
+ * @returns {Array} Sanitized arguments
143
+ */
144
+ function sanitizeArgs(args) {
145
+ return args.map((arg) => {
146
+ if (arg === null || arg === undefined) {
147
+ return arg;
148
+ }
149
+
150
+ if (typeof arg !== 'object') {
151
+ return arg;
152
+ }
153
+
154
+ if (Array.isArray(arg)) {
155
+ return arg.map((item) => sanitizeArgs([item])[0]);
156
+ }
157
+
158
+ // Sanitize object - remove sensitive fields
159
+ const sanitized = {};
160
+ for (const [key, value] of Object.entries(arg)) {
161
+ const lowerKey = key.toLowerCase();
162
+
163
+ // Skip sensitive fields
164
+ if (
165
+ lowerKey.includes('password') ||
166
+ lowerKey.includes('token') ||
167
+ lowerKey.includes('secret') ||
168
+ lowerKey.includes('key') ||
169
+ lowerKey.includes('auth')
170
+ ) {
171
+ sanitized[key] = '[REDACTED]';
172
+ continue;
173
+ }
174
+
175
+ // Recursively sanitize nested objects
176
+ if (typeof value === 'object' && value !== null) {
177
+ sanitized[key] = sanitizeArgs([value])[0];
178
+ } else {
179
+ sanitized[key] = value;
180
+ }
181
+ }
182
+
183
+ return sanitized;
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Wrap AdminFriggCommands for dry-run mode
189
+ *
190
+ * @param {Object} realCommands - Real AdminFriggCommands instance
191
+ * @param {Array} operationLog - Array to append logged operations
192
+ * @returns {Object} Wrapped commands with dry-run repository wrappers
193
+ */
194
+ function wrapAdminFriggCommandsForDryRun(realCommands, operationLog) {
195
+ return new Proxy(realCommands, {
196
+ get(target, prop) {
197
+ const value = target[prop];
198
+
199
+ // Pass through non-functions
200
+ if (typeof value !== 'function') {
201
+ // For lazy-loaded repositories, wrap them
202
+ if (prop.endsWith('Repository') && value && typeof value === 'object') {
203
+ const modelName = prop.replace('Repository', '');
204
+ return createDryRunWrapper(
205
+ value,
206
+ operationLog,
207
+ modelName.charAt(0).toUpperCase() + modelName.slice(1)
208
+ );
209
+ }
210
+ return value;
211
+ }
212
+
213
+ // Identify write operations on the commands themselves
214
+ const writePatterns = /^(update|create|delete|append)/i;
215
+ const isWrite = writePatterns.test(prop);
216
+
217
+ if (!isWrite) {
218
+ // Read operations pass through
219
+ return value.bind(target);
220
+ }
221
+
222
+ // Wrap write operations
223
+ return async (...args) => {
224
+ operationLog.push({
225
+ operation: prop.toUpperCase(),
226
+ source: 'AdminFriggCommands',
227
+ method: prop,
228
+ args: sanitizeArgs(args),
229
+ timestamp: new Date().toISOString(),
230
+ });
231
+
232
+ // For specific known methods, try to return sensible mocks
233
+ if (prop === 'updateIntegrationConfig') {
234
+ const [integrationId] = args;
235
+ const existing = await target.findIntegrationById(integrationId);
236
+ return existing;
237
+ }
238
+
239
+ if (prop === 'updateIntegrationStatus') {
240
+ const [integrationId] = args;
241
+ const existing = await target.findIntegrationById(integrationId);
242
+ return existing;
243
+ }
244
+
245
+ if (prop === 'updateCredential') {
246
+ const [credentialId, updates] = args;
247
+ return { id: credentialId, ...updates, _dryRun: true };
248
+ }
249
+
250
+ // Default mock
251
+ return { success: true, _dryRun: true };
252
+ };
253
+ },
254
+ });
255
+ }
256
+
257
+ module.exports = {
258
+ createDryRunWrapper,
259
+ wrapAdminFriggCommandsForDryRun,
260
+ sanitizeArgs,
261
+ };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Schedule Management Use Case
3
+ *
4
+ * Application Layer - Hexagonal Architecture
5
+ *
6
+ * Orchestrates schedule management operations:
7
+ * - Get effective schedule (DB override > Definition > none)
8
+ * - Upsert schedule with EventBridge provisioning
9
+ * - Delete schedule with EventBridge cleanup
10
+ *
11
+ * This use case encapsulates the business logic that was previously
12
+ * embedded in the router, reducing cognitive complexity and improving testability.
13
+ */
14
+ class ScheduleManagementUseCase {
15
+ constructor({ commands, schedulerAdapter, scriptFactory }) {
16
+ this.commands = commands;
17
+ this.schedulerAdapter = schedulerAdapter;
18
+ this.scriptFactory = scriptFactory;
19
+ }
20
+
21
+ /**
22
+ * Validate that a script exists
23
+ * @private
24
+ */
25
+ _validateScriptExists(scriptName) {
26
+ if (!this.scriptFactory.has(scriptName)) {
27
+ const error = new Error(`Script "${scriptName}" not found`);
28
+ error.code = 'SCRIPT_NOT_FOUND';
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Get the definition schedule from a script class
35
+ * @private
36
+ */
37
+ _getDefinitionSchedule(scriptName) {
38
+ const scriptClass = this.scriptFactory.get(scriptName);
39
+ return scriptClass.Definition?.schedule || null;
40
+ }
41
+
42
+ /**
43
+ * Get effective schedule (DB override > Definition default > none)
44
+ */
45
+ async getEffectiveSchedule(scriptName) {
46
+ this._validateScriptExists(scriptName);
47
+
48
+ // Check database override first
49
+ const dbSchedule = await this.commands.getScheduleByScriptName(scriptName);
50
+ if (dbSchedule) {
51
+ return {
52
+ source: 'database',
53
+ schedule: dbSchedule,
54
+ };
55
+ }
56
+
57
+ // Check definition default
58
+ const definitionSchedule = this._getDefinitionSchedule(scriptName);
59
+ if (definitionSchedule?.enabled) {
60
+ return {
61
+ source: 'definition',
62
+ schedule: {
63
+ scriptName,
64
+ enabled: definitionSchedule.enabled,
65
+ cronExpression: definitionSchedule.cronExpression,
66
+ timezone: definitionSchedule.timezone || 'UTC',
67
+ },
68
+ };
69
+ }
70
+
71
+ // No schedule configured
72
+ return {
73
+ source: 'none',
74
+ schedule: {
75
+ scriptName,
76
+ enabled: false,
77
+ },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Create or update schedule with EventBridge provisioning
83
+ */
84
+ async upsertSchedule(scriptName, { enabled, cronExpression, timezone }) {
85
+ this._validateScriptExists(scriptName);
86
+ this._validateScheduleInput(enabled, cronExpression);
87
+
88
+ // Save to database
89
+ const schedule = await this.commands.upsertSchedule({
90
+ scriptName,
91
+ enabled,
92
+ cronExpression: cronExpression || null,
93
+ timezone: timezone || 'UTC',
94
+ });
95
+
96
+ // Provision/deprovision EventBridge
97
+ const schedulerResult = await this._syncEventBridgeSchedule(
98
+ scriptName,
99
+ enabled,
100
+ cronExpression,
101
+ timezone,
102
+ schedule.awsScheduleArn
103
+ );
104
+
105
+ return {
106
+ success: true,
107
+ schedule: {
108
+ ...schedule,
109
+ awsScheduleArn: schedulerResult.awsScheduleArn || schedule.awsScheduleArn,
110
+ awsScheduleName: schedulerResult.awsScheduleName || schedule.awsScheduleName,
111
+ },
112
+ ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Validate schedule input
118
+ * @private
119
+ */
120
+ _validateScheduleInput(enabled, cronExpression) {
121
+ if (typeof enabled !== 'boolean') {
122
+ const error = new Error('enabled must be a boolean');
123
+ error.code = 'INVALID_INPUT';
124
+ throw error;
125
+ }
126
+
127
+ if (enabled && !cronExpression) {
128
+ const error = new Error('cronExpression is required when enabled is true');
129
+ error.code = 'INVALID_INPUT';
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Sync EventBridge schedule based on enabled state
136
+ * @private
137
+ */
138
+ async _syncEventBridgeSchedule(scriptName, enabled, cronExpression, timezone, existingArn) {
139
+ const result = { awsScheduleArn: null, awsScheduleName: null, warning: null };
140
+
141
+ try {
142
+ if (enabled && cronExpression) {
143
+ // Create/update EventBridge schedule
144
+ const awsInfo = await this.schedulerAdapter.createSchedule({
145
+ scriptName,
146
+ cronExpression,
147
+ timezone: timezone || 'UTC',
148
+ });
149
+
150
+ if (awsInfo?.scheduleArn) {
151
+ await this.commands.updateScheduleAwsInfo(scriptName, {
152
+ awsScheduleArn: awsInfo.scheduleArn,
153
+ awsScheduleName: awsInfo.scheduleName,
154
+ });
155
+ result.awsScheduleArn = awsInfo.scheduleArn;
156
+ result.awsScheduleName = awsInfo.scheduleName;
157
+ }
158
+ } else if (!enabled && existingArn) {
159
+ // Delete EventBridge schedule
160
+ await this.schedulerAdapter.deleteSchedule(scriptName);
161
+ await this.commands.updateScheduleAwsInfo(scriptName, {
162
+ awsScheduleArn: null,
163
+ awsScheduleName: null,
164
+ });
165
+ }
166
+ } catch (error) {
167
+ // Non-fatal: DB schedule is saved, AWS can be retried
168
+ result.warning = error.message;
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * Delete schedule override and cleanup EventBridge
176
+ */
177
+ async deleteSchedule(scriptName) {
178
+ this._validateScriptExists(scriptName);
179
+
180
+ // Delete from database
181
+ const deleteResult = await this.commands.deleteSchedule(scriptName);
182
+
183
+ // Cleanup EventBridge if needed
184
+ const schedulerWarning = await this._cleanupEventBridgeSchedule(
185
+ scriptName,
186
+ deleteResult.deleted?.awsScheduleArn
187
+ );
188
+
189
+ // Get effective schedule after deletion
190
+ const definitionSchedule = this._getDefinitionSchedule(scriptName);
191
+ const effectiveSchedule = definitionSchedule?.enabled
192
+ ? {
193
+ source: 'definition',
194
+ enabled: definitionSchedule.enabled,
195
+ cronExpression: definitionSchedule.cronExpression,
196
+ timezone: definitionSchedule.timezone || 'UTC',
197
+ }
198
+ : { source: 'none', enabled: false };
199
+
200
+ return {
201
+ success: true,
202
+ deletedCount: deleteResult.deletedCount,
203
+ message: deleteResult.deletedCount > 0
204
+ ? 'Schedule override removed'
205
+ : 'No schedule override found',
206
+ effectiveSchedule,
207
+ ...(schedulerWarning && { schedulerWarning }),
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Cleanup EventBridge schedule if it exists
213
+ * @private
214
+ */
215
+ async _cleanupEventBridgeSchedule(scriptName, awsScheduleArn) {
216
+ if (!awsScheduleArn) {
217
+ return null;
218
+ }
219
+
220
+ try {
221
+ await this.schedulerAdapter.deleteSchedule(scriptName);
222
+ return null;
223
+ } catch (error) {
224
+ // Non-fatal: DB is cleaned up, AWS can be retried
225
+ return error.message;
226
+ }
227
+ }
228
+ }
229
+
230
+ module.exports = { ScheduleManagementUseCase };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Script Factory
3
+ *
4
+ * Registry and factory for admin scripts.
5
+ * Manages script registration, validation, and instantiation.
6
+ *
7
+ * Usage:
8
+ * ```javascript
9
+ * const factory = new ScriptFactory();
10
+ * factory.register(MyScript);
11
+ * const script = factory.createInstance('my-script', { executionId: '123' });
12
+ * ```
13
+ */
14
+ class ScriptFactory {
15
+ constructor(scripts = []) {
16
+ this.registry = new Map();
17
+
18
+ // Register initial scripts
19
+ scripts.forEach((ScriptClass) => this.register(ScriptClass));
20
+ }
21
+
22
+ /**
23
+ * Register a script class
24
+ * @param {Function} ScriptClass - Script class extending AdminScriptBase
25
+ * @throws {Error} If script invalid or name collision
26
+ */
27
+ register(ScriptClass) {
28
+ if (!ScriptClass || !ScriptClass.Definition) {
29
+ throw new Error('Script class must have a static Definition property');
30
+ }
31
+
32
+ const definition = ScriptClass.Definition;
33
+ const name = definition.name;
34
+
35
+ if (!name) {
36
+ throw new Error('Script Definition must have a name');
37
+ }
38
+
39
+ if (this.registry.has(name)) {
40
+ throw new Error(`Script "${name}" is already registered`);
41
+ }
42
+
43
+ this.registry.set(name, ScriptClass);
44
+ }
45
+
46
+ /**
47
+ * Register multiple scripts at once
48
+ * @param {Array} scriptClasses - Array of script classes
49
+ */
50
+ registerAll(scriptClasses) {
51
+ scriptClasses.forEach((ScriptClass) => this.register(ScriptClass));
52
+ }
53
+
54
+ /**
55
+ * Check if script is registered
56
+ * @param {string} name - Script name
57
+ * @returns {boolean} True if registered
58
+ */
59
+ has(name) {
60
+ return this.registry.has(name);
61
+ }
62
+
63
+ /**
64
+ * Get script class by name
65
+ * @param {string} name - Script name
66
+ * @returns {Function} Script class
67
+ * @throws {Error} If script not found
68
+ */
69
+ get(name) {
70
+ const ScriptClass = this.registry.get(name);
71
+ if (!ScriptClass) {
72
+ throw new Error(`Script "${name}" not found`);
73
+ }
74
+ return ScriptClass;
75
+ }
76
+
77
+ /**
78
+ * Get array of all registered script names
79
+ * @returns {Array<string>} Array of script names
80
+ */
81
+ getNames() {
82
+ return Array.from(this.registry.keys());
83
+ }
84
+
85
+ /**
86
+ * Get all registered scripts
87
+ * @returns {Array} Array of { name, definition, class }
88
+ */
89
+ getAll() {
90
+ const scripts = [];
91
+ for (const [name, ScriptClass] of this.registry.entries()) {
92
+ scripts.push({
93
+ name,
94
+ definition: ScriptClass.Definition,
95
+ class: ScriptClass,
96
+ });
97
+ }
98
+ return scripts;
99
+ }
100
+
101
+ /**
102
+ * Create script instance
103
+ * @param {string} name - Script name
104
+ * @param {Object} params - Constructor parameters
105
+ * @returns {Object} Script instance
106
+ * @throws {Error} If script not found
107
+ */
108
+ createInstance(name, params = {}) {
109
+ const ScriptClass = this.get(name);
110
+ return new ScriptClass(params);
111
+ }
112
+
113
+ /**
114
+ * Remove script from registry
115
+ * @param {string} name - Script name
116
+ * @returns {boolean} True if removed
117
+ */
118
+ unregister(name) {
119
+ return this.registry.delete(name);
120
+ }
121
+
122
+ /**
123
+ * Clear all registered scripts
124
+ */
125
+ clear() {
126
+ this.registry.clear();
127
+ }
128
+
129
+ /**
130
+ * Get count of registered scripts
131
+ * @returns {number} Count
132
+ */
133
+ get size() {
134
+ return this.registry.size;
135
+ }
136
+ }
137
+
138
+ // Singleton instance for global use
139
+ let globalFactory = null;
140
+
141
+ /**
142
+ * Get global script factory instance
143
+ * @returns {ScriptFactory} Global factory
144
+ */
145
+ function getScriptFactory() {
146
+ if (!globalFactory) {
147
+ globalFactory = new ScriptFactory();
148
+ }
149
+ return globalFactory;
150
+ }
151
+
152
+ /**
153
+ * Create a new script factory instance
154
+ * @param {Array} scripts - Initial scripts to register
155
+ * @returns {ScriptFactory} New factory
156
+ */
157
+ function createScriptFactory(scripts = []) {
158
+ return new ScriptFactory(scripts);
159
+ }
160
+
161
+ module.exports = { ScriptFactory, getScriptFactory, createScriptFactory };