@friggframework/admin-scripts 2.0.0--canary.545.b4ca16d.0 → 2.0.0--canary.517.ff03f2c.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 (45) hide show
  1. package/README.md +272 -0
  2. package/index.js +22 -19
  3. package/package.json +6 -6
  4. package/src/adapters/__tests__/aws-scheduler-adapter.test.js +106 -45
  5. package/src/adapters/__tests__/local-scheduler-adapter.test.js +24 -10
  6. package/src/adapters/__tests__/scheduler-adapter-factory.test.js +30 -9
  7. package/src/adapters/__tests__/scheduler-adapter.test.js +6 -2
  8. package/src/adapters/aws-scheduler-adapter.js +55 -28
  9. package/src/adapters/local-scheduler-adapter.js +2 -2
  10. package/src/adapters/scheduler-adapter-factory.js +3 -1
  11. package/src/adapters/scheduler-adapter.js +9 -3
  12. package/src/application/__tests__/admin-frigg-commands.test.js +73 -30
  13. package/src/application/__tests__/admin-script-base.test.js +23 -6
  14. package/src/application/__tests__/script-factory.test.js +30 -6
  15. package/src/application/__tests__/script-runner.test.js +113 -24
  16. package/src/application/__tests__/validate-script-input.test.js +54 -15
  17. package/src/application/admin-frigg-commands.js +21 -9
  18. package/src/application/admin-script-base.js +3 -2
  19. package/src/application/script-factory.js +3 -1
  20. package/src/application/script-runner.js +90 -48
  21. package/src/application/use-cases/__tests__/delete-schedule-use-case.test.js +16 -7
  22. package/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js +9 -4
  23. package/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js +21 -10
  24. package/src/application/use-cases/delete-schedule-use-case.js +6 -4
  25. package/src/application/use-cases/get-effective-schedule-use-case.js +3 -1
  26. package/src/application/use-cases/index.js +3 -1
  27. package/src/application/use-cases/upsert-schedule-use-case.js +30 -11
  28. package/src/application/validate-script-input.js +10 -3
  29. package/src/infrastructure/__tests__/admin-auth-middleware.test.js +9 -3
  30. package/src/infrastructure/__tests__/admin-script-router.test.js +125 -52
  31. package/src/infrastructure/admin-auth-middleware.js +3 -1
  32. package/src/infrastructure/admin-script-router.js +129 -14
  33. package/src/infrastructure/bootstrap.js +82 -0
  34. package/src/infrastructure/script-executor-handler.js +119 -52
  35. package/src/application/__tests__/dry-run-http-interceptor.test.js +0 -313
  36. package/src/application/__tests__/dry-run-repository-wrapper.test.js +0 -257
  37. package/src/application/__tests__/schedule-management-use-case.test.js +0 -276
  38. package/src/application/dry-run-http-interceptor.js +0 -296
  39. package/src/application/dry-run-repository-wrapper.js +0 -261
  40. package/src/application/schedule-management-use-case.js +0 -230
  41. package/src/builtins/__tests__/integration-health-check.test.js +0 -607
  42. package/src/builtins/__tests__/oauth-token-refresh.test.js +0 -354
  43. package/src/builtins/index.js +0 -28
  44. package/src/builtins/integration-health-check.js +0 -278
  45. package/src/builtins/oauth-token-refresh.js +0 -220
@@ -0,0 +1,82 @@
1
+ const { getScriptFactory } = require('../application/script-factory');
2
+
3
+ /**
4
+ * Admin Script Bootstrap
5
+ *
6
+ * Loads the host app's definition at Lambda runtime and registers its admin
7
+ * scripts (and the built-ins, when enabled) into the global ScriptFactory, so
8
+ * the router and SQS worker can resolve scripts by name. Also constructs the
9
+ * integrationFactory used by scripts that need hydrated integration instances.
10
+ *
11
+ * Runs once per process (memoized) and never throws — a missing/unloadable app
12
+ * definition is logged and leaves the factory empty rather than crashing the
13
+ * Lambda cold start.
14
+ */
15
+ let bootstrapped = false;
16
+ let integrationFactory = null;
17
+
18
+ function registerScripts(factory, scriptClasses) {
19
+ for (const ScriptClass of scriptClasses || []) {
20
+ const name = ScriptClass?.Definition?.name;
21
+ // Guard against re-registration (register() throws on name collision)
22
+ if (name && !factory.has(name)) {
23
+ factory.register(ScriptClass);
24
+ }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Build an integration factory backed by the framework's runtime hydration.
30
+ * Scripts call `context.instantiate(integrationId)` which delegates here.
31
+ * The require is lazy so the admin-scripts package has no load-time coupling
32
+ * to core's backend utilities (and unit tests never hit this path).
33
+ */
34
+ function createIntegrationFactory() {
35
+ return {
36
+ async getInstanceFromIntegrationId({ integrationId }) {
37
+ const {
38
+ loadIntegrationForWebhook,
39
+ } = require('@friggframework/core/handlers/backend-utils');
40
+ return loadIntegrationForWebhook(integrationId);
41
+ },
42
+ };
43
+ }
44
+
45
+ /**
46
+ * @returns {{ integrationFactory: object }}
47
+ */
48
+ function bootstrapAdminScripts() {
49
+ if (bootstrapped) {
50
+ return { integrationFactory };
51
+ }
52
+ bootstrapped = true;
53
+
54
+ try {
55
+ const {
56
+ loadAppDefinition,
57
+ } = require('@friggframework/core/handlers/app-definition-loader');
58
+ const { adminScripts = [] } = loadAppDefinition();
59
+
60
+ const factory = getScriptFactory();
61
+ registerScripts(factory, adminScripts);
62
+ } catch (error) {
63
+ console.error(
64
+ '[admin-scripts] bootstrap: could not load app definition:',
65
+ error.message
66
+ );
67
+ }
68
+
69
+ integrationFactory = createIntegrationFactory();
70
+ return { integrationFactory };
71
+ }
72
+
73
+ /** Test-only: reset memoized bootstrap state. */
74
+ function _resetBootstrapForTests() {
75
+ bootstrapped = false;
76
+ integrationFactory = null;
77
+ }
78
+
79
+ module.exports = {
80
+ bootstrapAdminScripts,
81
+ _resetBootstrapForTests,
82
+ };
@@ -1,69 +1,136 @@
1
1
  const { createScriptRunner } = require('../application/script-runner');
2
- const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
2
+ const {
3
+ createAdminScriptCommands,
4
+ } = require('@friggframework/core/application/commands/admin-script-commands');
5
+ const { bootstrapAdminScripts } = require('./bootstrap');
3
6
 
4
7
  /**
5
- * SQS Queue Worker Lambda Handler
6
- *
7
- * Processes script execution messages from AdminScriptQueue.
8
- * Thin adapter: parses SQS messages and delegates to ScriptRunner.
9
- * ScriptRunner handles execution tracking, error recording, and status updates.
8
+ * Run a single execution message through the ScriptRunner.
9
+ * @private
10
10
  */
11
- async function handler(event) {
12
- const results = [];
11
+ async function runMessage(message, integrationFactory) {
12
+ const { scriptName, executionId, trigger, params, parentExecutionId } =
13
+ message;
13
14
 
14
- for (const record of event.Records) {
15
- let scriptName;
16
- let executionId;
15
+ if (!scriptName) {
16
+ throw new Error('Invalid message: missing scriptName');
17
+ }
17
18
 
18
- try {
19
- const message = JSON.parse(record.body);
20
- ({ scriptName, executionId } = message);
21
- const { trigger, params } = message;
19
+ console.log(
20
+ `Processing script: ${scriptName}${
21
+ executionId ? `, executionId: ${executionId}` : ''
22
+ }`
23
+ );
22
24
 
23
- if (!scriptName || !executionId) {
24
- throw new Error(`Invalid SQS message: missing scriptName or executionId`);
25
- }
25
+ const runner = createScriptRunner({ integrationFactory });
26
+ const result = await runner.execute(scriptName, params, {
27
+ trigger: trigger || 'QUEUE',
28
+ mode: 'async',
29
+ // executionId is optional — when absent, ScriptRunner creates the record.
30
+ // Scheduled direct invokes and queueScript continuations have no id yet.
31
+ ...(executionId && { executionId }),
32
+ ...(parentExecutionId && { parentExecutionId }),
33
+ });
26
34
 
27
- console.log(`Processing script: ${scriptName}, executionId: ${executionId}`);
35
+ return {
36
+ scriptName,
37
+ status: result.status,
38
+ executionId: result.executionId,
39
+ };
40
+ }
28
41
 
29
- const runner = createScriptRunner();
30
- const result = await runner.execute(scriptName, params, {
31
- trigger: trigger || 'QUEUE',
32
- mode: 'async',
33
- executionId,
34
- });
42
+ /**
43
+ * Mark an admin process FAILED when the worker itself blows up (parse error,
44
+ * runner construction), so the record doesn't stay stuck in a non-terminal
45
+ * state. No-op when there is no execution id.
46
+ * @private
47
+ */
48
+ async function markFailed(executionId, error) {
49
+ if (!executionId) return;
50
+ try {
51
+ const commands = createAdminScriptCommands();
52
+ await commands.completeAdminProcess(executionId, {
53
+ state: 'FAILED',
54
+ error: {
55
+ name: error.name,
56
+ message: error.message,
57
+ stack: error.stack,
58
+ },
59
+ });
60
+ } catch (updateError) {
61
+ console.error(
62
+ `Failed to update execution ${executionId} state:`,
63
+ updateError
64
+ );
65
+ }
66
+ }
35
67
 
36
- console.log(`Script completed: ${scriptName}, status: ${result.status}`);
37
- results.push({
38
- scriptName,
39
- status: result.status,
40
- executionId: result.executionId,
41
- });
42
- } catch (error) {
43
- // Only reaches here for unexpected failures (message parse errors, runner construction).
44
- // Script execution errors are handled by ScriptRunner and returned as { status: 'FAILED' }.
45
- console.error(`Unexpected error processing record:`, error);
68
+ /**
69
+ * Admin Script Executor Lambda Handler
70
+ *
71
+ * Handles two invocation shapes:
72
+ * - SQS: `event.Records[]` — each record body is a JSON execution message
73
+ * (manual async executions and queueScript continuations).
74
+ * - EventBridge Scheduler direct invoke: the event itself is the message
75
+ * (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
76
+ *
77
+ * Thin adapter: parses the event and delegates to ScriptRunner, which owns
78
+ * execution tracking, error recording, and status updates.
79
+ */
80
+ async function handler(event) {
81
+ const { integrationFactory } = bootstrapAdminScripts();
46
82
 
47
- // If we have an executionId, mark the admin process as FAILED
48
- // so the record doesn't stay stuck in a non-terminal state.
49
- if (executionId) {
50
- try {
51
- const commands = createAdminScriptCommands();
52
- await commands.completeAdminProcess(executionId, {
53
- state: 'FAILED',
54
- error: {
55
- name: error.name,
56
- message: error.message,
57
- stack: error.stack,
83
+ // EventBridge Scheduler invokes the Lambda directly (no Records wrapper)
84
+ if (!event.Records) {
85
+ try {
86
+ const result = await runMessage(event, integrationFactory);
87
+ console.log(
88
+ `Script completed: ${result.scriptName}, status: ${result.status}`
89
+ );
90
+ return {
91
+ statusCode: 200,
92
+ body: JSON.stringify({ processed: 1, results: [result] }),
93
+ };
94
+ } catch (error) {
95
+ console.error(
96
+ 'Unexpected error processing scheduled invoke:',
97
+ error
98
+ );
99
+ await markFailed(event.executionId, error);
100
+ return {
101
+ statusCode: 200,
102
+ body: JSON.stringify({
103
+ processed: 1,
104
+ results: [
105
+ {
106
+ scriptName: event.scriptName || 'unknown',
107
+ status: 'FAILED',
108
+ error: error.message,
58
109
  },
59
- });
60
- } catch (updateError) {
61
- console.error(`Failed to update execution ${executionId} state:`, updateError);
62
- }
63
- }
110
+ ],
111
+ }),
112
+ };
113
+ }
114
+ }
64
115
 
116
+ const results = [];
117
+ for (const record of event.Records) {
118
+ let message = {};
119
+ try {
120
+ message = JSON.parse(record.body);
121
+ const result = await runMessage(message, integrationFactory);
122
+ console.log(
123
+ `Script completed: ${result.scriptName}, status: ${result.status}`
124
+ );
125
+ results.push(result);
126
+ } catch (error) {
127
+ // Only unexpected failures reach here (message parse errors, runner
128
+ // construction). Script execution errors are handled by ScriptRunner
129
+ // and returned as { status: 'FAILED' }.
130
+ console.error('Unexpected error processing record:', error);
131
+ await markFailed(message.executionId, error);
65
132
  results.push({
66
- scriptName: scriptName || 'unknown',
133
+ scriptName: message.scriptName || 'unknown',
67
134
  status: 'FAILED',
68
135
  error: error.message,
69
136
  });
@@ -1,313 +0,0 @@
1
- const {
2
- createDryRunHttpClient,
3
- injectDryRunHttpClient,
4
- sanitizeHeaders,
5
- sanitizeData,
6
- detectService,
7
- } = require('../dry-run-http-interceptor');
8
-
9
- describe('Dry-Run HTTP Interceptor', () => {
10
- describe('sanitizeHeaders', () => {
11
- test('should redact authorization headers', () => {
12
- const headers = {
13
- 'Content-Type': 'application/json',
14
- Authorization: 'Bearer secret-token',
15
- 'X-API-Key': 'api-key-123',
16
- 'User-Agent': 'frigg/1.0',
17
- };
18
-
19
- const sanitized = sanitizeHeaders(headers);
20
-
21
- expect(sanitized['Content-Type']).toBe('application/json');
22
- expect(sanitized['User-Agent']).toBe('frigg/1.0');
23
- expect(sanitized.Authorization).toBe('[REDACTED]');
24
- expect(sanitized['X-API-Key']).toBe('[REDACTED]');
25
- });
26
-
27
- test('should handle case variations', () => {
28
- const headers = {
29
- authorization: 'Bearer token',
30
- Authorization: 'Bearer token',
31
- 'x-api-key': 'key1',
32
- 'X-API-Key': 'key2',
33
- };
34
-
35
- const sanitized = sanitizeHeaders(headers);
36
-
37
- expect(sanitized.authorization).toBe('[REDACTED]');
38
- expect(sanitized.Authorization).toBe('[REDACTED]');
39
- expect(sanitized['x-api-key']).toBe('[REDACTED]');
40
- expect(sanitized['X-API-Key']).toBe('[REDACTED]');
41
- });
42
-
43
- test('should handle null/undefined', () => {
44
- expect(sanitizeHeaders(null)).toEqual({});
45
- expect(sanitizeHeaders(undefined)).toEqual({});
46
- expect(sanitizeHeaders({})).toEqual({});
47
- });
48
- });
49
-
50
- describe('detectService', () => {
51
- test('should detect CRM services', () => {
52
- expect(detectService('https://api.hubapi.com')).toBe('HubSpot');
53
- expect(detectService('https://login.salesforce.com')).toBe('Salesforce');
54
- expect(detectService('https://api.pipedrive.com')).toBe('Pipedrive');
55
- expect(detectService('https://api.attio.com')).toBe('Attio');
56
- });
57
-
58
- test('should detect communication services', () => {
59
- expect(detectService('https://slack.com/api')).toBe('Slack');
60
- expect(detectService('https://discord.com/api')).toBe('Discord');
61
- expect(detectService('https://graph.teams.microsoft.com')).toBe('Microsoft Teams');
62
- });
63
-
64
- test('should detect project management tools', () => {
65
- expect(detectService('https://app.asana.com/api')).toBe('Asana');
66
- expect(detectService('https://api.monday.com')).toBe('Monday.com');
67
- expect(detectService('https://api.trello.com')).toBe('Trello');
68
- });
69
-
70
- test('should return unknown for unrecognized services', () => {
71
- expect(detectService('https://example.com/api')).toBe('unknown');
72
- expect(detectService(null)).toBe('unknown');
73
- expect(detectService(undefined)).toBe('unknown');
74
- });
75
-
76
- test('should be case insensitive', () => {
77
- expect(detectService('HTTPS://API.HUBSPOT.COM')).toBe('HubSpot');
78
- expect(detectService('https://API.SLACK.COM')).toBe('Slack');
79
- });
80
- });
81
-
82
- describe('sanitizeData', () => {
83
- test('should redact sensitive fields', () => {
84
- const data = {
85
- name: 'Test User',
86
- email: 'test@example.com',
87
- password: 'secret123',
88
- apiToken: 'token-abc',
89
- authKey: 'key-xyz',
90
- };
91
-
92
- const sanitized = sanitizeData(data);
93
-
94
- expect(sanitized.name).toBe('Test User');
95
- expect(sanitized.email).toBe('test@example.com');
96
- expect(sanitized.password).toBe('[REDACTED]');
97
- expect(sanitized.apiToken).toBe('[REDACTED]');
98
- expect(sanitized.authKey).toBe('[REDACTED]');
99
- });
100
-
101
- test('should handle nested objects', () => {
102
- const data = {
103
- user: {
104
- name: 'Test',
105
- credentials: {
106
- password: 'secret',
107
- token: 'abc123',
108
- },
109
- },
110
- };
111
-
112
- const sanitized = sanitizeData(data);
113
-
114
- expect(sanitized.user.name).toBe('Test');
115
- expect(sanitized.user.credentials.password).toBe('[REDACTED]');
116
- expect(sanitized.user.credentials.token).toBe('[REDACTED]');
117
- });
118
-
119
- test('should handle arrays', () => {
120
- const data = [
121
- { id: '1', password: 'secret1' },
122
- { id: '2', apiKey: 'key2' },
123
- ];
124
-
125
- const sanitized = sanitizeData(data);
126
-
127
- expect(sanitized[0].id).toBe('1');
128
- expect(sanitized[0].password).toBe('[REDACTED]');
129
- expect(sanitized[1].apiKey).toBe('[REDACTED]');
130
- });
131
-
132
- test('should preserve primitives', () => {
133
- expect(sanitizeData('string')).toBe('string');
134
- expect(sanitizeData(123)).toBe(123);
135
- expect(sanitizeData(true)).toBe(true);
136
- expect(sanitizeData(null)).toBe(null);
137
- expect(sanitizeData(undefined)).toBe(undefined);
138
- });
139
- });
140
-
141
- describe('createDryRunHttpClient', () => {
142
- let operationLog;
143
-
144
- beforeEach(() => {
145
- operationLog = [];
146
- });
147
-
148
- test('should log GET requests', async () => {
149
- const client = createDryRunHttpClient(operationLog);
150
-
151
- const response = await client.get('/contacts', {
152
- baseURL: 'https://api.hubapi.com',
153
- headers: { Authorization: 'Bearer token' },
154
- });
155
-
156
- expect(operationLog).toHaveLength(1);
157
- expect(operationLog[0]).toMatchObject({
158
- operation: 'HTTP_REQUEST',
159
- method: 'GET',
160
- url: 'https://api.hubapi.com/contacts',
161
- service: 'HubSpot',
162
- });
163
-
164
- expect(operationLog[0].headers.Authorization).toBe('[REDACTED]');
165
- expect(response.data._dryRun).toBe(true);
166
- });
167
-
168
- test('should log POST requests with data', async () => {
169
- const client = createDryRunHttpClient(operationLog);
170
-
171
- const postData = {
172
- name: 'John Doe',
173
- email: 'john@example.com',
174
- password: 'secret123',
175
- };
176
-
177
- await client.post('/users', postData, {
178
- baseURL: 'https://api.example.com',
179
- });
180
-
181
- expect(operationLog).toHaveLength(1);
182
- expect(operationLog[0].method).toBe('POST');
183
- expect(operationLog[0].data.name).toBe('John Doe');
184
- expect(operationLog[0].data.email).toBe('john@example.com');
185
- expect(operationLog[0].data.password).toBe('[REDACTED]');
186
- });
187
-
188
- test('should log PUT requests', async () => {
189
- const client = createDryRunHttpClient(operationLog);
190
-
191
- await client.put('/users/123', { status: 'active' }, {
192
- baseURL: 'https://api.example.com',
193
- });
194
-
195
- expect(operationLog).toHaveLength(1);
196
- expect(operationLog[0].method).toBe('PUT');
197
- expect(operationLog[0].data.status).toBe('active');
198
- });
199
-
200
- test('should log PATCH requests', async () => {
201
- const client = createDryRunHttpClient(operationLog);
202
-
203
- await client.patch('/users/123', { name: 'Updated' });
204
-
205
- expect(operationLog).toHaveLength(1);
206
- expect(operationLog[0].method).toBe('PATCH');
207
- });
208
-
209
- test('should log DELETE requests', async () => {
210
- const client = createDryRunHttpClient(operationLog);
211
-
212
- await client.delete('/users/123', {
213
- baseURL: 'https://api.example.com',
214
- });
215
-
216
- expect(operationLog).toHaveLength(1);
217
- expect(operationLog[0].method).toBe('DELETE');
218
- });
219
-
220
- test('should return mock response', async () => {
221
- const client = createDryRunHttpClient(operationLog);
222
-
223
- const response = await client.get('/test');
224
-
225
- expect(response.status).toBe(200);
226
- expect(response.statusText).toContain('Dry-Run');
227
- expect(response.data._dryRun).toBe(true);
228
- expect(response.headers['x-dry-run']).toBe('true');
229
- });
230
-
231
- test('should include query params in log', async () => {
232
- const client = createDryRunHttpClient(operationLog);
233
-
234
- await client.get('/search', {
235
- baseURL: 'https://api.example.com',
236
- params: { q: 'test', limit: 10 },
237
- });
238
-
239
- expect(operationLog[0].params).toEqual({ q: 'test', limit: 10 });
240
- });
241
- });
242
-
243
- describe('injectDryRunHttpClient', () => {
244
- let operationLog;
245
- let dryRunClient;
246
-
247
- beforeEach(() => {
248
- operationLog = [];
249
- dryRunClient = createDryRunHttpClient(operationLog);
250
- });
251
-
252
- test('should inject into primary API module', () => {
253
- const integrationInstance = {
254
- primary: {
255
- api: {
256
- _httpClient: { get: jest.fn() },
257
- },
258
- },
259
- };
260
-
261
- injectDryRunHttpClient(integrationInstance, dryRunClient);
262
-
263
- expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient);
264
- });
265
-
266
- test('should inject into target API module', () => {
267
- const integrationInstance = {
268
- target: {
269
- api: {
270
- _httpClient: { get: jest.fn() },
271
- },
272
- },
273
- };
274
-
275
- injectDryRunHttpClient(integrationInstance, dryRunClient);
276
-
277
- expect(integrationInstance.target.api._httpClient).toBe(dryRunClient);
278
- });
279
-
280
- test('should inject into both primary and target', () => {
281
- const integrationInstance = {
282
- primary: {
283
- api: { _httpClient: { get: jest.fn() } },
284
- },
285
- target: {
286
- api: { _httpClient: { get: jest.fn() } },
287
- },
288
- };
289
-
290
- injectDryRunHttpClient(integrationInstance, dryRunClient);
291
-
292
- expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient);
293
- expect(integrationInstance.target.api._httpClient).toBe(dryRunClient);
294
- });
295
-
296
- test('should handle missing api modules gracefully', () => {
297
- const integrationInstance = {
298
- primary: {},
299
- target: null,
300
- };
301
-
302
- expect(() => {
303
- injectDryRunHttpClient(integrationInstance, dryRunClient);
304
- }).not.toThrow();
305
- });
306
-
307
- test('should handle null integration instance', () => {
308
- expect(() => {
309
- injectDryRunHttpClient(null, dryRunClient);
310
- }).not.toThrow();
311
- });
312
- });
313
- });