@friggframework/admin-scripts 2.0.0--canary.545.b4ca16d.0 → 2.0.0--canary.517.8eaf5df.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 +277 -0
  2. package/index.js +23 -6
  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/builtins/__tests__/integration-health-check.test.js +232 -127
  30. package/src/builtins/__tests__/oauth-token-refresh.test.js +128 -75
  31. package/src/builtins/index.js +1 -4
  32. package/src/builtins/integration-health-check.js +63 -30
  33. package/src/builtins/oauth-token-refresh.js +64 -29
  34. package/src/infrastructure/__tests__/admin-auth-middleware.test.js +9 -3
  35. package/src/infrastructure/__tests__/admin-script-router.test.js +125 -52
  36. package/src/infrastructure/admin-auth-middleware.js +3 -1
  37. package/src/infrastructure/admin-script-router.js +129 -14
  38. package/src/infrastructure/bootstrap.js +87 -0
  39. package/src/infrastructure/script-executor-handler.js +119 -52
  40. package/src/application/__tests__/dry-run-http-interceptor.test.js +0 -313
  41. package/src/application/__tests__/dry-run-repository-wrapper.test.js +0 -257
  42. package/src/application/__tests__/schedule-management-use-case.test.js +0 -276
  43. package/src/application/dry-run-http-interceptor.js +0 -296
  44. package/src/application/dry-run-repository-wrapper.js +0 -261
  45. package/src/application/schedule-management-use-case.js +0 -230
@@ -3,10 +3,18 @@ const serverless = require('serverless-http');
3
3
  const { validateAdminApiKey } = require('./admin-auth-middleware');
4
4
  const { getScriptFactory } = require('../application/script-factory');
5
5
  const { createScriptRunner } = require('../application/script-runner');
6
- const { validateScriptInput } = require('../application/validate-script-input');
7
- const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
6
+ const {
7
+ validateScriptInput,
8
+ validateParams,
9
+ } = require('../application/validate-script-input');
10
+ const {
11
+ createAdminScriptCommands,
12
+ } = require('@friggframework/core/application/commands/admin-script-commands');
8
13
  const { QueuerUtil } = require('@friggframework/core/queues');
9
- const { createSchedulerAdapter } = require('../adapters/scheduler-adapter-factory');
14
+ const {
15
+ createSchedulerAdapter,
16
+ } = require('../adapters/scheduler-adapter-factory');
17
+ const { bootstrapAdminScripts } = require('./bootstrap');
10
18
  const {
11
19
  GetEffectiveScheduleUseCase,
12
20
  UpsertScheduleUseCase,
@@ -18,14 +26,51 @@ const router = express.Router();
18
26
  // Apply auth middleware to all admin routes
19
27
  router.use(validateAdminApiKey);
20
28
 
29
+ // Register the host app's admin scripts (and built-ins) before handling requests.
30
+ // Memoized, so this only does work on the first request per process.
31
+ router.use((_req, _res, next) => {
32
+ bootstrapAdminScripts();
33
+ next();
34
+ });
35
+
36
+ /**
37
+ * Build audit metadata for an execution from the request.
38
+ * @private
39
+ */
40
+ function buildAudit(req) {
41
+ const apiKey = req.headers['x-frigg-admin-api-key'];
42
+ const forwardedFor = (req.headers['x-forwarded-for'] || '')
43
+ .split(',')[0]
44
+ .trim();
45
+ return {
46
+ ipAddress: forwardedFor || req.ip || null,
47
+ apiKeyLast4: apiKey ? String(apiKey).slice(-4) : null,
48
+ };
49
+ }
50
+
21
51
  /**
22
52
  * Create schedule use case instances
23
53
  * @private
24
54
  */
25
55
  function createScheduleUseCases() {
26
56
  const commands = createAdminScriptCommands();
57
+
58
+ // The local adapter is in-memory only (schedules vanish on cold start), so it
59
+ // must never be the silent default in a deployed Lambda. Require an explicit
60
+ // provider when running on AWS; fall back to 'local' only for local dev/tests.
61
+ const schedulerType =
62
+ process.env.SCHEDULER_PROVIDER ||
63
+ (process.env.AWS_LAMBDA_FUNCTION_NAME ? null : 'local');
64
+ if (!schedulerType) {
65
+ const error = new Error(
66
+ 'SCHEDULER_PROVIDER is not configured. Set it (e.g. "aws") via appDefinition.admin.enableScheduling.'
67
+ );
68
+ error.code = 'SCHEDULER_NOT_CONFIGURED';
69
+ throw error;
70
+ }
71
+
27
72
  const schedulerAdapter = createSchedulerAdapter({
28
- type: process.env.SCHEDULER_PROVIDER || 'local',
73
+ type: schedulerType,
29
74
  targetLambdaArn: process.env.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN,
30
75
  scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
31
76
  roleArn: process.env.SCHEDULER_ROLE_ARN,
@@ -33,9 +78,20 @@ function createScheduleUseCases() {
33
78
  const scriptFactory = getScriptFactory();
34
79
 
35
80
  return {
36
- getEffectiveSchedule: new GetEffectiveScheduleUseCase({ commands, scriptFactory }),
37
- upsertSchedule: new UpsertScheduleUseCase({ commands, schedulerAdapter, scriptFactory }),
38
- deleteSchedule: new DeleteScheduleUseCase({ commands, schedulerAdapter, scriptFactory }),
81
+ getEffectiveSchedule: new GetEffectiveScheduleUseCase({
82
+ commands,
83
+ scriptFactory,
84
+ }),
85
+ upsertSchedule: new UpsertScheduleUseCase({
86
+ commands,
87
+ schedulerAdapter,
88
+ scriptFactory,
89
+ }),
90
+ deleteSchedule: new DeleteScheduleUseCase({
91
+ commands,
92
+ schedulerAdapter,
93
+ scriptFactory,
94
+ }),
39
95
  };
40
96
  }
41
97
 
@@ -142,11 +198,40 @@ router.post('/scripts/:scriptName', async (req, res) => {
142
198
  });
143
199
  }
144
200
 
201
+ const definition = factory.get(scriptName).Definition;
202
+
203
+ // Fail fast on invalid input instead of starting an execution that will error
204
+ const validation = validateParams(definition, params);
205
+ if (!validation.valid) {
206
+ return res.status(400).json({
207
+ error: `Invalid input: ${validation.errors.join(', ')}`,
208
+ code: 'INVALID_INPUT',
209
+ details: validation.errors,
210
+ });
211
+ }
212
+
213
+ const audit = buildAudit(req);
214
+
145
215
  if (mode === 'sync') {
146
- const runner = createScriptRunner();
216
+ // Sync runs inside the 30s API Lambda; long scripts must use async
217
+ const timeout = definition.config?.timeout;
218
+ if (
219
+ process.env.AWS_LAMBDA_FUNCTION_NAME &&
220
+ timeout &&
221
+ timeout > 25000
222
+ ) {
223
+ return res.status(400).json({
224
+ error: `Script "${scriptName}" timeout (${timeout}ms) exceeds the sync API limit. Use mode: "async".`,
225
+ code: 'SYNC_TIMEOUT_TOO_LONG',
226
+ });
227
+ }
228
+
229
+ const { integrationFactory } = bootstrapAdminScripts();
230
+ const runner = createScriptRunner({ integrationFactory });
147
231
  const result = await runner.execute(scriptName, params, {
148
232
  trigger: 'MANUAL',
149
233
  mode: 'sync',
234
+ audit,
150
235
  });
151
236
  return res.json(result);
152
237
  }
@@ -163,12 +248,21 @@ router.post('/scripts/:scriptName', async (req, res) => {
163
248
  const commands = createAdminScriptCommands();
164
249
  const execution = await commands.createAdminProcess({
165
250
  scriptName,
166
- scriptVersion: factory.get(scriptName).Definition.version,
251
+ scriptVersion: definition.version,
167
252
  trigger: 'MANUAL',
168
253
  mode: 'async',
169
254
  input: params,
255
+ audit,
170
256
  });
171
257
 
258
+ // Commands return an error object (never throw) — don't queue a broken execution
259
+ if (execution.error) {
260
+ return res.status(execution.error).json({
261
+ error: execution.reason || 'Failed to create execution record',
262
+ code: execution.code,
263
+ });
264
+ }
265
+
172
266
  // Queue the execution
173
267
  await QueuerUtil.send(
174
268
  {
@@ -226,10 +320,14 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
226
320
  const { status, limit = 50 } = req.query;
227
321
  const commands = createAdminScriptCommands();
228
322
 
229
- const executions = await commands.findRecentExecutions({
230
- scriptName,
231
- status,
232
- limit: Number.parseInt(limit, 10),
323
+ const parsedLimit = Number.parseInt(limit, 10);
324
+ const safeLimit = Number.isNaN(parsedLimit)
325
+ ? 50
326
+ : Math.min(Math.max(parsedLimit, 1), 200);
327
+
328
+ const executions = await commands.findAdminProcessesByName(scriptName, {
329
+ limit: safeLimit,
330
+ ...(status && { state: status }),
233
331
  });
234
332
 
235
333
  res.json({ executions });
@@ -262,6 +360,11 @@ router.get('/scripts/:scriptName/schedule', async (req, res) => {
262
360
  code: error.code,
263
361
  });
264
362
  }
363
+ if (error.code === 'SCHEDULER_NOT_CONFIGURED') {
364
+ return res
365
+ .status(503)
366
+ .json({ error: error.message, code: error.code });
367
+ }
265
368
  console.error('Error getting schedule:', error);
266
369
  res.status(500).json({ error: 'Failed to get schedule' });
267
370
  }
@@ -289,7 +392,9 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
289
392
  source: 'database',
290
393
  ...result.schedule,
291
394
  },
292
- ...(result.schedulerWarning && { schedulerWarning: result.schedulerWarning }),
395
+ ...(result.schedulerWarning && {
396
+ schedulerWarning: result.schedulerWarning,
397
+ }),
293
398
  });
294
399
  } catch (error) {
295
400
  if (error.code === 'SCRIPT_NOT_FOUND') {
@@ -304,6 +409,11 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
304
409
  code: error.code,
305
410
  });
306
411
  }
412
+ if (error.code === 'SCHEDULER_NOT_CONFIGURED') {
413
+ return res
414
+ .status(503)
415
+ .json({ error: error.message, code: error.code });
416
+ }
307
417
  console.error('Error updating schedule:', error);
308
418
  res.status(500).json({ error: 'Failed to update schedule' });
309
419
  }
@@ -328,6 +438,11 @@ router.delete('/scripts/:scriptName/schedule', async (req, res) => {
328
438
  code: error.code,
329
439
  });
330
440
  }
441
+ if (error.code === 'SCHEDULER_NOT_CONFIGURED') {
442
+ return res
443
+ .status(503)
444
+ .json({ error: error.message, code: error.code });
445
+ }
331
446
  console.error('Error deleting schedule:', error);
332
447
  res.status(500).json({ error: 'Failed to delete schedule' });
333
448
  }
@@ -0,0 +1,87 @@
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 = [], admin = {} } = loadAppDefinition();
59
+
60
+ const factory = getScriptFactory();
61
+ registerScripts(factory, adminScripts);
62
+
63
+ if (admin.includeBuiltinScripts) {
64
+ const { builtinScripts } = require('../builtins');
65
+ registerScripts(factory, builtinScripts);
66
+ }
67
+ } catch (error) {
68
+ console.error(
69
+ '[admin-scripts] bootstrap: could not load app definition:',
70
+ error.message
71
+ );
72
+ }
73
+
74
+ integrationFactory = createIntegrationFactory();
75
+ return { integrationFactory };
76
+ }
77
+
78
+ /** Test-only: reset memoized bootstrap state. */
79
+ function _resetBootstrapForTests() {
80
+ bootstrapped = false;
81
+ integrationFactory = null;
82
+ }
83
+
84
+ module.exports = {
85
+ bootstrapAdminScripts,
86
+ _resetBootstrapForTests,
87
+ };
@@ -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
  });