@friggframework/devtools 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.
@@ -23,7 +23,15 @@ class AdminScriptBuilder extends InfrastructureBuilder {
23
23
  }
24
24
 
25
25
  shouldExecute(appDefinition) {
26
- return Array.isArray(appDefinition.adminScripts) && appDefinition.adminScripts.length > 0;
26
+ const hasScripts =
27
+ Array.isArray(appDefinition.adminScripts) &&
28
+ appDefinition.adminScripts.length > 0;
29
+ const hasReports =
30
+ Array.isArray(appDefinition.reports) &&
31
+ appDefinition.reports.length > 0;
32
+ const hasBuiltinReports =
33
+ appDefinition.admin?.includeBuiltinReports === true;
34
+ return hasScripts || hasReports || hasBuiltinReports;
27
35
  }
28
36
 
29
37
  getDependencies() {
@@ -33,31 +41,53 @@ class AdminScriptBuilder extends InfrastructureBuilder {
33
41
  validate(appDefinition) {
34
42
  const result = new ValidationResult();
35
43
 
36
- if (!appDefinition.adminScripts) {
37
- return result; // Not an error, just no scripts
38
- }
39
-
40
- if (!Array.isArray(appDefinition.adminScripts)) {
41
- result.addError('adminScripts must be an array');
42
- return result;
44
+ if (appDefinition.adminScripts !== undefined) {
45
+ if (!Array.isArray(appDefinition.adminScripts)) {
46
+ result.addError('adminScripts must be an array');
47
+ } else {
48
+ appDefinition.adminScripts.forEach((script, index) => {
49
+ if (!script?.Definition?.name) {
50
+ result.addError(`Admin script at index ${index} is missing Definition or name`);
51
+ }
52
+ });
53
+ }
43
54
  }
44
55
 
45
- // Validate each script
46
- appDefinition.adminScripts.forEach((script, index) => {
47
- if (!script?.Definition?.name) {
48
- result.addError(`Admin script at index ${index} is missing Definition or name`);
56
+ if (appDefinition.reports !== undefined) {
57
+ if (!Array.isArray(appDefinition.reports)) {
58
+ result.addError('reports must be an array');
59
+ } else {
60
+ appDefinition.reports.forEach((report, index) => {
61
+ if (!report?.Definition?.name) {
62
+ result.addError(`Report at index ${index} is missing Definition or name`);
63
+ }
64
+ });
49
65
  }
50
- });
66
+ }
51
67
 
52
68
  return result;
53
69
  }
54
70
 
55
71
  async build(appDefinition, discoveredResources) {
56
- console.log(`\n[${this.name}] Configuring admin scripts...`);
57
- console.log(` Processing ${appDefinition.adminScripts.length} scripts...`);
72
+ console.log(`\n[${this.name}] Configuring admin operations...`);
58
73
 
59
74
  const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false;
60
75
  const adminConfig = appDefinition.admin || {};
76
+ const adminScripts = Array.isArray(appDefinition.adminScripts)
77
+ ? appDefinition.adminScripts
78
+ : [];
79
+ const reports = Array.isArray(appDefinition.reports)
80
+ ? appDefinition.reports
81
+ : [];
82
+ const hasReports =
83
+ reports.length > 0 || adminConfig.includeBuiltinReports === true;
84
+
85
+ // Only non-JSON report output is stored in S3, so provision the bucket
86
+ // only for that. Built-in reports emit JSON, so they don't trigger it.
87
+ const reportsNeedArtifacts = reports.some((report) => {
88
+ const format = report?.Definition?.output?.format;
89
+ return Boolean(format) && format !== 'json';
90
+ });
61
91
 
62
92
  const result = {
63
93
  functions: {},
@@ -67,27 +97,45 @@ class AdminScriptBuilder extends InfrastructureBuilder {
67
97
  iamStatements: [],
68
98
  };
69
99
 
70
- // Create admin script queue
71
- this.createAdminScriptQueue(result, appDefinition);
72
-
73
- // Create Lambda function for script execution
74
- this.createScriptExecutorFunction(appDefinition, result, usePrismaLayer);
100
+ if (adminScripts.length > 0) {
101
+ console.log(` Processing ${adminScripts.length} scripts...`);
102
+ this.createAdminScriptQueue(result, appDefinition);
103
+ this.createScriptExecutorFunction(appDefinition, result, usePrismaLayer);
104
+ this.createAdminScriptRoutes(appDefinition, result, usePrismaLayer);
75
105
 
76
- // Create API routes for script management
77
- this.createAdminScriptRoutes(appDefinition, result, usePrismaLayer);
106
+ adminScripts.forEach(script => {
107
+ const name = script.Definition?.name || 'unknown';
108
+ console.log(` ✓ Registered script: ${name}`);
109
+ });
110
+ }
78
111
 
79
- // Phase 2: Create EventBridge Scheduler resources
80
- if (adminConfig.enableScheduling) {
81
- this.createSchedulerResources(appDefinition, result);
112
+ if (hasReports) {
113
+ this.createReportQueue(result, appDefinition);
114
+ this.createReportExecutorFunction(appDefinition, result, usePrismaLayer);
115
+ this.createReportRoutes(appDefinition, result, usePrismaLayer);
116
+ if (reportsNeedArtifacts) {
117
+ this.createReportArtifactBucket(result, appDefinition);
118
+ }
119
+ reports.forEach(report => {
120
+ const name = report.Definition?.name || 'unknown';
121
+ console.log(` ✓ Registered report: ${name}`);
122
+ });
123
+ if (adminConfig.includeBuiltinReports) {
124
+ console.log(' ✓ Built-in reports enabled');
125
+ }
82
126
  }
83
127
 
84
- // Log registered scripts
85
- appDefinition.adminScripts.forEach(script => {
86
- const name = script.Definition?.name || 'unknown';
87
- console.log(` ✓ Registered: ${name}`);
88
- });
128
+ if (
129
+ adminConfig.enableScheduling &&
130
+ (adminScripts.length > 0 || hasReports)
131
+ ) {
132
+ this.createSchedulerResources(appDefinition, result, {
133
+ scriptsPresent: adminScripts.length > 0,
134
+ hasReports,
135
+ });
136
+ }
89
137
 
90
- console.log(`[${this.name}] ✅ Admin script configuration completed`);
138
+ console.log(`[${this.name}] ✅ Admin operations configuration completed`);
91
139
  return result;
92
140
  }
93
141
 
@@ -193,6 +241,151 @@ class AdminScriptBuilder extends InfrastructureBuilder {
193
241
  console.log(' ✓ Created adminScriptRouter function');
194
242
  }
195
243
 
244
+ createReportRoutes(appDefinition, result, usePrismaLayer) {
245
+ result.functions.reportRouter = {
246
+ handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-router.handler',
247
+ skipEsbuild: true,
248
+ package: this.skipEsbuildPackageConfig(appDefinition, usePrismaLayer),
249
+ ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
250
+ timeout: 30,
251
+ events: [
252
+ { httpApi: { path: '/api/v2/reports', method: 'GET' } },
253
+ // Definition detail, snapshots, executions, schedule, back-compat alias
254
+ { httpApi: { path: '/api/v2/reports/{proxy+}', method: 'GET' } },
255
+ // Run a report ({name}/run)
256
+ { httpApi: { path: '/api/v2/reports/{proxy+}', method: 'POST' } },
257
+ // Schedule management (PUT/DELETE {name}/schedule)
258
+ { httpApi: { path: '/api/v2/reports/{proxy+}', method: 'PUT' } },
259
+ { httpApi: { path: '/api/v2/reports/{proxy+}', method: 'DELETE' } },
260
+ ],
261
+ };
262
+ console.log(' ✓ Created reportRouter function');
263
+ }
264
+
265
+ createReportQueue(result, appDefinition) {
266
+ result.resources.ReportQueue = {
267
+ Type: 'AWS::SQS::Queue',
268
+ Properties: {
269
+ QueueName: '${self:service}-${self:provider.stage}-ReportQueue',
270
+ MessageRetentionPeriod: 86400, // 1 day
271
+ VisibilityTimeout: 900, // 15 minutes (Lambda max)
272
+ RedrivePolicy: {
273
+ maxReceiveCount: 3,
274
+ deadLetterTargetArn: {
275
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
276
+ },
277
+ },
278
+ },
279
+ };
280
+
281
+ if (isScopedEnvironmentActive(appDefinition)) {
282
+ // Only the report functions read this queue URL
283
+ result.functionEnvironments = result.functionEnvironments || {};
284
+ for (const fnName of ['reportRouter', 'reportExecutor']) {
285
+ result.functionEnvironments[fnName] = {
286
+ ...result.functionEnvironments[fnName],
287
+ REPORT_QUEUE_URL: { Ref: 'ReportQueue' },
288
+ };
289
+ }
290
+ } else {
291
+ result.environment.REPORT_QUEUE_URL = { Ref: 'ReportQueue' };
292
+ }
293
+
294
+ // The report router enqueues async recorded/snapshot runs. The base
295
+ // role's wildcard does not cover this queue's name, so grant
296
+ // SendMessage explicitly.
297
+ result.iamStatements.push({
298
+ Effect: 'Allow',
299
+ Action: [
300
+ 'sqs:SendMessage',
301
+ 'sqs:SendMessageBatch',
302
+ 'sqs:GetQueueUrl',
303
+ 'sqs:GetQueueAttributes',
304
+ ],
305
+ Resource: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
306
+ });
307
+
308
+ console.log(' ✓ Created ReportQueue');
309
+ }
310
+
311
+ createReportExecutorFunction(appDefinition, result, usePrismaLayer) {
312
+ result.functions.reportExecutor = {
313
+ handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-executor-handler.handler',
314
+ skipEsbuild: true,
315
+ package: this.skipEsbuildPackageConfig(appDefinition, usePrismaLayer),
316
+ ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
317
+ timeout: 900, // 15 minutes max
318
+ memorySize: 1024,
319
+ events: [
320
+ {
321
+ sqs: {
322
+ arn: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
323
+ batchSize: 1,
324
+ },
325
+ },
326
+ ],
327
+ };
328
+ console.log(' ✓ Created reportExecutor function');
329
+ }
330
+
331
+ // Non-JSON report output; the router mints short-lived presigned URLs for reads.
332
+ createReportArtifactBucket(result, appDefinition) {
333
+ result.resources.ReportArtifactBucket = {
334
+ Type: 'AWS::S3::Bucket',
335
+ Properties: {
336
+ BucketName:
337
+ '${self:service}-${self:provider.stage}-report-artifacts',
338
+ BucketEncryption: {
339
+ ServerSideEncryptionConfiguration: [
340
+ {
341
+ ServerSideEncryptionByDefault: {
342
+ SSEAlgorithm: 'AES256',
343
+ },
344
+ },
345
+ ],
346
+ },
347
+ PublicAccessBlockConfiguration: {
348
+ BlockPublicAcls: true,
349
+ BlockPublicPolicy: true,
350
+ IgnorePublicAcls: true,
351
+ RestrictPublicBuckets: true,
352
+ },
353
+ },
354
+ };
355
+
356
+ if (isScopedEnvironmentActive(appDefinition)) {
357
+ result.functionEnvironments = result.functionEnvironments || {};
358
+ for (const fnName of ['reportRouter', 'reportExecutor']) {
359
+ result.functionEnvironments[fnName] = {
360
+ ...result.functionEnvironments[fnName],
361
+ REPORT_ARTIFACT_BUCKET: { Ref: 'ReportArtifactBucket' },
362
+ };
363
+ }
364
+ } else {
365
+ result.environment.REPORT_ARTIFACT_BUCKET = {
366
+ Ref: 'ReportArtifactBucket',
367
+ };
368
+ }
369
+
370
+ // Executor writes and router reads (via presign); scope to this bucket's objects.
371
+ result.iamStatements.push({
372
+ Effect: 'Allow',
373
+ Action: ['s3:PutObject', 's3:GetObject'],
374
+ Resource: {
375
+ 'Fn::Sub': [
376
+ '${BucketArn}/*',
377
+ {
378
+ BucketArn: {
379
+ 'Fn::GetAtt': ['ReportArtifactBucket', 'Arn'],
380
+ },
381
+ },
382
+ ],
383
+ },
384
+ });
385
+
386
+ console.log(' ✓ Created ReportArtifactBucket');
387
+ }
388
+
196
389
  // Without this, the skipEsbuild functions package the whole node_modules
197
390
  // closure (aws-sdk, Prisma, dev deps) and blow past Lambda's 250 MB limit.
198
391
  // Mirrors the exclusions the framework's other node_modules handlers use.
@@ -248,13 +441,25 @@ class AdminScriptBuilder extends InfrastructureBuilder {
248
441
  };
249
442
  }
250
443
 
251
- createSchedulerResources(appDefinition, result) {
252
- // Constructed ARN, not Fn::GetAtt: a GetAtt edge to the executor closes a
444
+ createSchedulerResources(
445
+ appDefinition,
446
+ result,
447
+ { scriptsPresent = true, hasReports = false } = {}
448
+ ) {
449
+ // Constructed ARNs, not Fn::GetAtt: a GetAtt edge to an executor closes a
253
450
  // CloudFormation circular dependency via the shared Lambda execution role.
254
- const executorArn = {
255
- 'Fn::Sub':
256
- 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-adminScriptExecutor',
257
- };
451
+ const fnArn = (logicalName) => ({
452
+ 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:\${self:service}-\${self:provider.stage}-${logicalName}`,
453
+ });
454
+ const scriptExecutorArn = fnArn('adminScriptExecutor');
455
+ const reportExecutorArn = fnArn('reportExecutor');
456
+
457
+ const invokeResources = [
458
+ ...(scriptsPresent ? [scriptExecutorArn] : []),
459
+ ...(hasReports ? [reportExecutorArn] : []),
460
+ ];
461
+ const invokeResource =
462
+ invokeResources.length === 1 ? invokeResources[0] : invokeResources;
258
463
 
259
464
  // Create IAM role for EventBridge Scheduler
260
465
  result.resources.AdminScriptSchedulerRole = {
@@ -276,7 +481,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
276
481
  Statement: [{
277
482
  Effect: 'Allow',
278
483
  Action: 'lambda:InvokeFunction',
279
- Resource: executorArn,
484
+ Resource: invokeResource,
280
485
  }],
281
486
  },
282
487
  }],
@@ -293,18 +498,35 @@ class AdminScriptBuilder extends InfrastructureBuilder {
293
498
 
294
499
  // Router-scoped, not shared provider env. Two reasons: broadcasting the
295
500
  // resource references to every function creates CloudFormation circular
296
- // deps; and SCHEDULER_PROVIDER='aws' is only valid for the admin-script
297
- // adapter (the router's sole consumer) — core's scheduler factory, used
298
- // by integration Lambdas, rejects 'aws', so it must not leak app-wide.
299
- result.functions.adminScriptRouter.environment = {
300
- ...(result.functions.adminScriptRouter.environment || {}),
301
- SCHEDULER_PROVIDER: 'aws',
302
- SCHEDULER_ROLE_ARN: {
303
- 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
304
- },
305
- ADMIN_SCRIPT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
306
- ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN: executorArn,
307
- };
501
+ // deps; and SCHEDULER_PROVIDER='aws' is only valid for the admin-scripts
502
+ // adapter (the routers are its sole consumers) — core's scheduler
503
+ // factory, used by integration Lambdas, rejects 'aws', so it must not
504
+ // leak app-wide.
505
+ if (scriptsPresent) {
506
+ result.functions.adminScriptRouter.environment = {
507
+ ...(result.functions.adminScriptRouter.environment || {}),
508
+ SCHEDULER_PROVIDER: 'aws',
509
+ SCHEDULER_ROLE_ARN: {
510
+ 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
511
+ },
512
+ ADMIN_SCRIPT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
513
+ ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN: scriptExecutorArn,
514
+ };
515
+ }
516
+
517
+ // Report schedules reuse the shared role/group but must target the
518
+ // report executor, not the script one (REPORT_EXECUTOR_LAMBDA_ARN).
519
+ if (hasReports) {
520
+ result.functions.reportRouter.environment = {
521
+ ...(result.functions.reportRouter.environment || {}),
522
+ SCHEDULER_PROVIDER: 'aws',
523
+ SCHEDULER_ROLE_ARN: {
524
+ 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
525
+ },
526
+ REPORT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
527
+ REPORT_EXECUTOR_LAMBDA_ARN: reportExecutorArn,
528
+ };
529
+ }
308
530
 
309
531
  // The router manages schedules through the AWS scheduler adapter, so it
310
532
  // needs scheduler:* on this group plus iam:PassRole for the role it hands
@@ -620,6 +620,319 @@ describe('AdminScriptBuilder', () => {
620
620
  });
621
621
  });
622
622
 
623
+ describe('reports (ReportQueue + reportExecutor)', () => {
624
+ it('creates ReportQueue + reportExecutor and wires REPORT_QUEUE_URL when reports are present', async () => {
625
+ const appDefinition = {
626
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
627
+ };
628
+
629
+ const result = await adminScriptBuilder.build(appDefinition, {});
630
+
631
+ // Queue
632
+ expect(result.resources.ReportQueue).toBeDefined();
633
+ expect(result.resources.ReportQueue.Type).toBe('AWS::SQS::Queue');
634
+ expect(
635
+ result.resources.ReportQueue.Properties.MessageRetentionPeriod
636
+ ).toBe(86400);
637
+ expect(
638
+ result.resources.ReportQueue.Properties.VisibilityTimeout
639
+ ).toBe(900);
640
+ expect(
641
+ result.resources.ReportQueue.Properties.RedrivePolicy
642
+ ).toEqual({
643
+ maxReceiveCount: 3,
644
+ deadLetterTargetArn: {
645
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
646
+ },
647
+ });
648
+
649
+ // Executor function
650
+ expect(result.functions.reportExecutor).toBeDefined();
651
+ expect(result.functions.reportExecutor.handler).toBe(
652
+ 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-executor-handler.handler'
653
+ );
654
+ expect(result.functions.reportExecutor.timeout).toBe(900);
655
+ expect(result.functions.reportExecutor.memorySize).toBe(1024);
656
+ expect(result.functions.reportExecutor.events).toEqual([
657
+ {
658
+ sqs: {
659
+ arn: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
660
+ batchSize: 1,
661
+ },
662
+ },
663
+ ]);
664
+ expect(result.functions.reportExecutor.skipEsbuild).toBe(true);
665
+ expect(result.functions.reportExecutor.layers).toEqual([
666
+ { Ref: 'PrismaLambdaLayer' },
667
+ ]);
668
+
669
+ // Env wired app-wide (scoped flag off)
670
+ expect(result.environment.REPORT_QUEUE_URL).toEqual({
671
+ Ref: 'ReportQueue',
672
+ });
673
+
674
+ // IAM SendMessage grant on the queue Arn
675
+ const grant = result.iamStatements.find(
676
+ (s) =>
677
+ Array.isArray(s.Action) &&
678
+ s.Action.includes('sqs:SendMessage') &&
679
+ s.Resource &&
680
+ s.Resource['Fn::GetAtt'] &&
681
+ s.Resource['Fn::GetAtt'][0] === 'ReportQueue'
682
+ );
683
+ expect(grant).toBeDefined();
684
+ expect(grant.Action).toContain('sqs:SendMessageBatch');
685
+ });
686
+
687
+ it('scopes REPORT_QUEUE_URL to reportRouter + reportExecutor when scopedEnvironment is on', async () => {
688
+ const appDefinition = {
689
+ lambda: { scopedEnvironment: true },
690
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
691
+ };
692
+
693
+ const result = await adminScriptBuilder.build(appDefinition, {});
694
+
695
+ expect(result.environment.REPORT_QUEUE_URL).toBeUndefined();
696
+ for (const fnName of ['reportRouter', 'reportExecutor']) {
697
+ expect(
698
+ result.functionEnvironments[fnName].REPORT_QUEUE_URL
699
+ ).toEqual({ Ref: 'ReportQueue' });
700
+ }
701
+ });
702
+
703
+ it('creates ReportQueue + reportExecutor when only builtin reports are enabled', async () => {
704
+ const appDefinition = {
705
+ admin: { includeBuiltinReports: true },
706
+ };
707
+
708
+ const result = await adminScriptBuilder.build(appDefinition, {});
709
+
710
+ expect(result.resources.ReportQueue).toBeDefined();
711
+ expect(result.functions.reportExecutor).toBeDefined();
712
+ });
713
+
714
+ it('does NOT create ReportQueue or reportExecutor when only adminScripts are present', async () => {
715
+ const appDefinition = {
716
+ adminScripts: [{ Definition: { name: 'test-script' } }],
717
+ };
718
+
719
+ const result = await adminScriptBuilder.build(appDefinition, {});
720
+
721
+ expect(result.resources.ReportQueue).toBeUndefined();
722
+ expect(result.functions.reportExecutor).toBeUndefined();
723
+ expect(result.functions.reportRouter).toBeUndefined();
724
+ expect(result.environment.REPORT_QUEUE_URL).toBeUndefined();
725
+ });
726
+ });
727
+
728
+ describe('report artifacts (ReportArtifactBucket for non-JSON output)', () => {
729
+ it('provisions a private encrypted bucket + IAM + env when a report emits non-JSON', async () => {
730
+ const appDefinition = {
731
+ reports: [
732
+ {
733
+ Definition: {
734
+ name: 'sales-csv',
735
+ version: '1.0.0',
736
+ output: { format: 'csv' },
737
+ },
738
+ },
739
+ ],
740
+ };
741
+
742
+ const result = await adminScriptBuilder.build(appDefinition, {});
743
+
744
+ const bucket = result.resources.ReportArtifactBucket;
745
+ expect(bucket).toBeDefined();
746
+ expect(bucket.Type).toBe('AWS::S3::Bucket');
747
+ expect(
748
+ bucket.Properties.BucketEncryption
749
+ .ServerSideEncryptionConfiguration[0]
750
+ .ServerSideEncryptionByDefault.SSEAlgorithm
751
+ ).toBe('AES256');
752
+ expect(bucket.Properties.PublicAccessBlockConfiguration).toEqual({
753
+ BlockPublicAcls: true,
754
+ BlockPublicPolicy: true,
755
+ IgnorePublicAcls: true,
756
+ RestrictPublicBuckets: true,
757
+ });
758
+
759
+ // Env wired app-wide (scoped flag off).
760
+ expect(result.environment.REPORT_ARTIFACT_BUCKET).toEqual({
761
+ Ref: 'ReportArtifactBucket',
762
+ });
763
+
764
+ // IAM: object-level Put/Get scoped to the bucket keys.
765
+ const grant = result.iamStatements.find(
766
+ (s) =>
767
+ Array.isArray(s.Action) &&
768
+ s.Action.includes('s3:PutObject')
769
+ );
770
+ expect(grant).toBeDefined();
771
+ expect(grant.Action).toContain('s3:GetObject');
772
+ expect(grant.Resource['Fn::Sub'][0]).toBe('${BucketArn}/*');
773
+ expect(grant.Resource['Fn::Sub'][1]).toEqual({
774
+ BucketArn: { 'Fn::GetAtt': ['ReportArtifactBucket', 'Arn'] },
775
+ });
776
+ });
777
+
778
+ it('does NOT provision the bucket for JSON-only reports', async () => {
779
+ const appDefinition = {
780
+ reports: [
781
+ {
782
+ Definition: {
783
+ name: 'json-report',
784
+ version: '1.0.0',
785
+ output: { format: 'json' },
786
+ },
787
+ },
788
+ // No output field defaults to JSON.
789
+ { Definition: { name: 'plain', version: '1.0.0' } },
790
+ ],
791
+ };
792
+
793
+ const result = await adminScriptBuilder.build(appDefinition, {});
794
+
795
+ expect(result.resources.ReportArtifactBucket).toBeUndefined();
796
+ expect(result.environment.REPORT_ARTIFACT_BUCKET).toBeUndefined();
797
+ const grant = result.iamStatements.find(
798
+ (s) =>
799
+ Array.isArray(s.Action) &&
800
+ s.Action.includes('s3:PutObject')
801
+ );
802
+ expect(grant).toBeUndefined();
803
+ });
804
+
805
+ it('scopes REPORT_ARTIFACT_BUCKET to report functions when scopedEnvironment is on', async () => {
806
+ const appDefinition = {
807
+ lambda: { scopedEnvironment: true },
808
+ reports: [
809
+ {
810
+ Definition: {
811
+ name: 'sales-csv',
812
+ version: '1.0.0',
813
+ output: { format: 'csv' },
814
+ },
815
+ },
816
+ ],
817
+ };
818
+
819
+ const result = await adminScriptBuilder.build(appDefinition, {});
820
+
821
+ expect(result.environment.REPORT_ARTIFACT_BUCKET).toBeUndefined();
822
+ for (const fnName of ['reportRouter', 'reportExecutor']) {
823
+ expect(
824
+ result.functionEnvironments[fnName].REPORT_ARTIFACT_BUCKET
825
+ ).toEqual({ Ref: 'ReportArtifactBucket' });
826
+ }
827
+ });
828
+ });
829
+
830
+ describe('report scheduling (enableScheduling && reports)', () => {
831
+ it('wires the report scheduler env onto reportRouter targeting the report executor', async () => {
832
+ const appDefinition = {
833
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
834
+ admin: { enableScheduling: true },
835
+ };
836
+
837
+ const result = await adminScriptBuilder.build(appDefinition, {});
838
+
839
+ // Shared scheduler role + group are created even without scripts.
840
+ expect(result.resources.AdminScriptSchedulerRole).toBeDefined();
841
+ expect(result.resources.AdminScriptScheduleGroup).toBeDefined();
842
+
843
+ const routerEnv = result.functions.reportRouter.environment;
844
+ expect(routerEnv.SCHEDULER_PROVIDER).toBe('aws');
845
+ expect(routerEnv.SCHEDULER_ROLE_ARN).toEqual({
846
+ 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
847
+ });
848
+ expect(routerEnv.REPORT_SCHEDULE_GROUP).toEqual({
849
+ Ref: 'AdminScriptScheduleGroup',
850
+ });
851
+ // Constructed ARN (Fn::Sub), not Fn::GetAtt — avoids a circular dep.
852
+ expect(routerEnv.REPORT_EXECUTOR_LAMBDA_ARN).toEqual({
853
+ 'Fn::Sub':
854
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
855
+ });
856
+
857
+ // Must NOT leak onto the shared provider env.
858
+ expect(result.environment.SCHEDULER_PROVIDER).toBeUndefined();
859
+ expect(result.environment.REPORT_EXECUTOR_LAMBDA_ARN).toBeUndefined();
860
+ });
861
+
862
+ it('grants the scheduler role invoke on the report executor (reports-only)', async () => {
863
+ const appDefinition = {
864
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
865
+ admin: { enableScheduling: true },
866
+ };
867
+
868
+ const result = await adminScriptBuilder.build(appDefinition, {});
869
+
870
+ const statement =
871
+ result.resources.AdminScriptSchedulerRole.Properties.Policies[0]
872
+ .PolicyDocument.Statement[0];
873
+ // Only reports present -> single Resource, the report executor ARN.
874
+ expect(statement.Resource).toEqual({
875
+ 'Fn::Sub':
876
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
877
+ });
878
+
879
+ // scheduler:* + iam:PassRole grants present.
880
+ const schedulerGrant = result.iamStatements.find(
881
+ (s) =>
882
+ Array.isArray(s.Action) &&
883
+ s.Action.includes('scheduler:CreateSchedule')
884
+ );
885
+ expect(schedulerGrant).toBeDefined();
886
+ });
887
+
888
+ it('lets the scheduler role invoke BOTH executors when scripts and reports coexist', async () => {
889
+ const appDefinition = {
890
+ adminScripts: [{ Definition: { name: 'test-script' } }],
891
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
892
+ admin: { enableScheduling: true },
893
+ };
894
+
895
+ const result = await adminScriptBuilder.build(appDefinition, {});
896
+
897
+ const statement =
898
+ result.resources.AdminScriptSchedulerRole.Properties.Policies[0]
899
+ .PolicyDocument.Statement[0];
900
+ expect(statement.Resource).toEqual([
901
+ {
902
+ 'Fn::Sub':
903
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-adminScriptExecutor',
904
+ },
905
+ {
906
+ 'Fn::Sub':
907
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
908
+ },
909
+ ]);
910
+
911
+ // Both routers get their own scheduler env.
912
+ expect(
913
+ result.functions.adminScriptRouter.environment
914
+ .ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN
915
+ ).toBeDefined();
916
+ expect(
917
+ result.functions.reportRouter.environment
918
+ .REPORT_EXECUTOR_LAMBDA_ARN
919
+ ).toBeDefined();
920
+ });
921
+
922
+ it('does NOT wire report scheduler env when enableScheduling is off', async () => {
923
+ const appDefinition = {
924
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
925
+ };
926
+
927
+ const result = await adminScriptBuilder.build(appDefinition, {});
928
+
929
+ expect(result.resources.AdminScriptSchedulerRole).toBeUndefined();
930
+ expect(
931
+ result.functions.reportRouter.environment
932
+ ).toBeUndefined();
933
+ });
934
+ });
935
+
623
936
  describe('getName()', () => {
624
937
  it('should return AdminScriptBuilder', () => {
625
938
  expect(adminScriptBuilder.getName()).toBe('AdminScriptBuilder');
@@ -311,16 +311,7 @@ function createBaseDefinition(
311
311
  { httpApi: { path: '/health/{proxy+}', method: 'GET' } },
312
312
  ],
313
313
  },
314
- reporting: {
315
- handler: 'node_modules/@friggframework/core/handlers/routers/reporting.handler',
316
- ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
317
- skipEsbuild: true, // Handlers in node_modules don't need bundling
318
- package: skipEsbuildPackageConfig,
319
- events: [
320
- { httpApi: { path: '/api/v2/reports', method: 'GET' } },
321
- { httpApi: { path: '/api/v2/reports/{proxy+}', method: 'GET' } },
322
- ],
323
- },
314
+ // Reporting is an admin operation (ADR-010): the report router runs on the admin-scripts Lambda, not as a standalone function here.
324
315
  // Note: dbMigrate removed - MigrationBuilder now handles migration infrastructure
325
316
  // See: packages/devtools/infrastructure/domains/database/migration-builder.js
326
317
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.104",
4
+ "version": "2.0.0-next.106",
5
5
  "bin": {
6
6
  "frigg": "./frigg-cli/index.js"
7
7
  },
@@ -26,9 +26,9 @@
26
26
  "@babel/eslint-parser": "^7.18.9",
27
27
  "@babel/parser": "^7.25.3",
28
28
  "@babel/traverse": "^7.25.3",
29
- "@friggframework/core": "2.0.0-next.104",
30
- "@friggframework/schemas": "2.0.0-next.104",
31
- "@friggframework/test": "2.0.0-next.104",
29
+ "@friggframework/core": "2.0.0-next.106",
30
+ "@friggframework/schemas": "2.0.0-next.106",
31
+ "@friggframework/test": "2.0.0-next.106",
32
32
  "@hapi/boom": "^10.0.1",
33
33
  "@inquirer/prompts": "^5.3.8",
34
34
  "axios": "^1.18.0",
@@ -56,8 +56,8 @@
56
56
  "validate-npm-package-name": "^5.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@friggframework/eslint-config": "2.0.0-next.104",
60
- "@friggframework/prettier-config": "2.0.0-next.104",
59
+ "@friggframework/eslint-config": "2.0.0-next.106",
60
+ "@friggframework/prettier-config": "2.0.0-next.106",
61
61
  "aws-sdk-client-mock": "^4.1.0",
62
62
  "aws-sdk-client-mock-jest": "^4.1.0",
63
63
  "jest": "^30.1.3",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "deb915ccac00c3928ba337a202b500ffbfabe01a"
92
+ "gitHead": "979c1a62fe226206ec3801d1d7aacbfe2a8fac78"
93
93
  }