@friggframework/devtools 2.0.0--canary.627.ce32cdc.0 → 2.0.0--canary.640.b31eb4a.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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * ADR-033: Aurora Serverless v2 scale-to-zero + NAT-free public connectivity
3
+ *
4
+ * These tests assert on the GENERATED CloudFormation template (via the full
5
+ * composer) plus the AuroraBuilder validator. They are deterministic and require
6
+ * neither a live AWS account nor Prisma client generation:
7
+ * - Validator cases call AuroraBuilder.validate() directly (pure, no I/O).
8
+ * - Template cases use vpc.management='create-new' + database.postgres
9
+ * management='managed', which resolve to STACK ownership without depending on
10
+ * any discovered AWS resource, so cloud discovery returning empty is fine.
11
+ *
12
+ * Both new capabilities are opt-in and default-off; the final describe block
13
+ * guards the no-regression promise: a default (vpc, no minCapacity) definition
14
+ * composes the same template as before.
15
+ */
16
+
17
+ const { composeServerlessDefinition } = require('../infrastructure-composer');
18
+ const { AuroraBuilder } = require('../domains/database/aurora-builder');
19
+
20
+ // Shared: an app definition that creates a fresh VPC + a Frigg-owned Aurora
21
+ // cluster in-stack, with overridable postgres config.
22
+ function makeApp(postgresOverrides = {}) {
23
+ return {
24
+ name: 'adr033-app',
25
+ provider: 'aws',
26
+ region: 'us-east-1',
27
+ integrations: [],
28
+ vpc: { enable: true, management: 'create-new' },
29
+ database: {
30
+ postgres: {
31
+ enable: true,
32
+ management: 'managed',
33
+ ...postgresOverrides,
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ function findResources(template, predicate) {
40
+ return Object.entries(template.resources.Resources).filter(([, r]) => predicate(r));
41
+ }
42
+
43
+ describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
44
+ beforeAll(() => {
45
+ process.env.AWS_REGION = 'us-east-1';
46
+ // Intentionally NOT setting FRIGG_SKIP_AWS_DISCOVERY — the builders must
47
+ // execute. create-new/managed resolve to STACK without needing discovery.
48
+ });
49
+
50
+ afterAll(() => {
51
+ delete process.env.AWS_REGION;
52
+ });
53
+
54
+ // ---------------------------------------------------------------------
55
+ // Validator
56
+ // ---------------------------------------------------------------------
57
+ describe('validator (AuroraBuilder.validate)', () => {
58
+ const build = new AuroraBuilder();
59
+ // ValidationResult exposes hasErrors(); "valid" means no errors.
60
+ const validateResult = (postgres) =>
61
+ build.validate({ database: { postgres: { enable: true, ...postgres } } });
62
+ const isValid = (postgres) => !validateResult(postgres).hasErrors();
63
+
64
+ test('accepts minCapacity: 0 (scale-to-zero)', () => {
65
+ expect(isValid({ minCapacity: 0 })).toBe(true);
66
+ });
67
+
68
+ test('rejects minCapacity: 0.3 (inside the forbidden (0, 0.5) band)', () => {
69
+ const r = validateResult({ minCapacity: 0.3 });
70
+ expect(r.hasErrors()).toBe(true);
71
+ expect(r.errors.join(' ')).toMatch(/minCapacity must be 0 \(scale-to-zero\) or between 0\.5 and 128/);
72
+ });
73
+
74
+ test('accepts minCapacity: 0.5 and minCapacity: 64', () => {
75
+ expect(isValid({ minCapacity: 0.5 })).toBe(true);
76
+ expect(isValid({ minCapacity: 64 })).toBe(true);
77
+ });
78
+
79
+ test('rejects secondsUntilAutoPause: 100 (below 300)', () => {
80
+ const r = validateResult({ minCapacity: 0, secondsUntilAutoPause: 100 });
81
+ expect(r.hasErrors()).toBe(true);
82
+ expect(r.errors.join(' ')).toMatch(/secondsUntilAutoPause must be an integer between 300 and 86400/);
83
+ });
84
+
85
+ test('accepts secondsUntilAutoPause: 3600', () => {
86
+ expect(isValid({ minCapacity: 0, secondsUntilAutoPause: 3600 })).toBe(true);
87
+ });
88
+
89
+ test("accepts connectivity: 'public'", () => {
90
+ expect(isValid({ connectivity: 'public' })).toBe(true);
91
+ });
92
+
93
+ test("rejects connectivity: 'nope'", () => {
94
+ const r = validateResult({ connectivity: 'nope' });
95
+ expect(r.hasErrors()).toBe(true);
96
+ expect(r.errors.join(' ')).toMatch(/Invalid database\.postgres\.connectivity/);
97
+ });
98
+
99
+ test('rejects non-array / non-CIDR allowedCidrs, accepts valid CIDRs', () => {
100
+ expect(isValid({ allowedCidrs: 'nope' })).toBe(false);
101
+ expect(isValid({ allowedCidrs: ['not-a-cidr'] })).toBe(false);
102
+ expect(isValid({ allowedCidrs: ['10.0.0.0/8', '203.0.113.5/32'] })).toBe(true);
103
+ });
104
+
105
+ test('warns (does not fail) when minCapacity:0 with an older pinned engine version', () => {
106
+ const r = validateResult({ minCapacity: 0, engineVersion: '15.4' });
107
+ expect(r.hasErrors()).toBe(false); // warning, not error
108
+ expect(r.warnings.join(' ')).toMatch(/may not support .*scale-to-zero/);
109
+ });
110
+
111
+ test('does not warn about engine when minCapacity:0 on a capable version', () => {
112
+ const r = validateResult({ minCapacity: 0, engineVersion: '15.13' });
113
+ expect(r.warnings.join(' ')).not.toMatch(/may not support .*scale-to-zero/);
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------
118
+ // Scale-to-zero (template shape)
119
+ // ---------------------------------------------------------------------
120
+ describe('scale-to-zero (minCapacity: 0)', () => {
121
+ test('MinCapacity is exactly 0 and SecondsUntilAutoPause defaults to 300', async () => {
122
+ const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
123
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
124
+
125
+ // Mutation guard: with the old `|| 0.5` bug this would be 0.5, not 0.
126
+ expect(scaling.MinCapacity).toBe(0);
127
+ expect(scaling.MinCapacity).not.toBe(0.5);
128
+ expect(scaling.SecondsUntilAutoPause).toBe(300);
129
+ });
130
+
131
+ test('SecondsUntilAutoPause honors a custom value', async () => {
132
+ const t = await composeServerlessDefinition(makeApp({ minCapacity: 0, secondsUntilAutoPause: 1800 }));
133
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
134
+ expect(scaling.MinCapacity).toBe(0);
135
+ expect(scaling.SecondsUntilAutoPause).toBe(1800);
136
+ });
137
+
138
+ test('MaxCapacity defaults to 4 and is preserved with scale-to-zero', async () => {
139
+ const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
140
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
141
+ expect(scaling.MaxCapacity).toBe(4);
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------
146
+ // Public connectivity (template shape)
147
+ // ---------------------------------------------------------------------
148
+ describe("connectivity: 'public'", () => {
149
+ test('Aurora ingress uses CidrIp — one rule per allowedCidr — not SourceSecurityGroupId', async () => {
150
+ const t = await composeServerlessDefinition(
151
+ makeApp({ connectivity: 'public', allowedCidrs: ['10.1.0.0/16', '203.0.113.7/32'] })
152
+ );
153
+ const ingress = findResources(
154
+ t,
155
+ (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
156
+ );
157
+ expect(ingress).toHaveLength(2);
158
+ const cidrs = ingress.map(([, r]) => r.Properties.CidrIp).sort();
159
+ expect(cidrs).toEqual(['10.1.0.0/16', '203.0.113.7/32']);
160
+ ingress.forEach(([, r]) => {
161
+ expect(r.Properties.CidrIp).toBeDefined();
162
+ expect(r.Properties.SourceSecurityGroupId).toBeUndefined();
163
+ });
164
+ });
165
+
166
+ test('allowedCidrs defaults to 0.0.0.0/0 when omitted', async () => {
167
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
168
+ const ingress = findResources(
169
+ t,
170
+ (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
171
+ );
172
+ expect(ingress).toHaveLength(1);
173
+ expect(ingress[0][1].Properties.CidrIp).toBe('0.0.0.0/0');
174
+ });
175
+
176
+ test('Aurora instance is PubliclyAccessible and cluster sits in public subnets', async () => {
177
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
178
+ expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(true);
179
+ expect(t.resources.Resources.FriggDBSubnetGroup.Properties.SubnetIds).toEqual([
180
+ { Ref: 'FriggPublicSubnet' },
181
+ { Ref: 'FriggPublicSubnet2' },
182
+ ]);
183
+ });
184
+
185
+ test('NO NAT Gateway resource is emitted', async () => {
186
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
187
+ const nats = findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway');
188
+ expect(nats).toHaveLength(0);
189
+ });
190
+
191
+ test('Lambda is NOT attached to the VPC (provider.vpc unset)', async () => {
192
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
193
+ expect(t.provider.vpc).toBeUndefined();
194
+ });
195
+
196
+ test('DATABASE_URL enforces TLS (sslmode=require)', async () => {
197
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
198
+ const url = t.provider.environment.DATABASE_URL;
199
+ expect(url['Fn::Sub'][0]).toContain('sslmode=require');
200
+ });
201
+
202
+ test('combines with scale-to-zero: $0-idle NAT-free Aurora', async () => {
203
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public', minCapacity: 0 }));
204
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
205
+ expect(scaling.MinCapacity).toBe(0);
206
+ expect(scaling.SecondsUntilAutoPause).toBe(300);
207
+ expect(findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway')).toHaveLength(0);
208
+ expect(t.provider.vpc).toBeUndefined();
209
+ });
210
+ });
211
+
212
+ // ---------------------------------------------------------------------
213
+ // No-regression: default (vpc) connectivity, no new fields
214
+ // ---------------------------------------------------------------------
215
+ describe("default connectivity: 'vpc' (no regression)", () => {
216
+ test('MinCapacity defaults to 0.5 with no SecondsUntilAutoPause', async () => {
217
+ const t = await composeServerlessDefinition(makeApp());
218
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
219
+ expect(scaling.MinCapacity).toBe(0.5);
220
+ expect(scaling.MaxCapacity).toBe(4);
221
+ expect(scaling.SecondsUntilAutoPause).toBeUndefined();
222
+ });
223
+
224
+ test('Aurora ingress uses SourceSecurityGroupId (Lambda SG), not CidrIp', async () => {
225
+ const t = await composeServerlessDefinition(makeApp());
226
+ const ingress = findResources(
227
+ t,
228
+ (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
229
+ );
230
+ expect(ingress).toHaveLength(1);
231
+ expect(ingress[0][1].Properties.SourceSecurityGroupId).toEqual({ Ref: 'FriggLambdaSecurityGroup' });
232
+ expect(ingress[0][1].Properties.CidrIp).toBeUndefined();
233
+ // Logical ID unchanged for the vpc path
234
+ expect(ingress[0][0]).toBe('FriggAuroraIngressRule');
235
+ });
236
+
237
+ test('Aurora instance is not publicly accessible by default', async () => {
238
+ const t = await composeServerlessDefinition(makeApp());
239
+ expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(false);
240
+ });
241
+
242
+ test('Lambda IS attached to the VPC (provider.vpc set)', async () => {
243
+ const t = await composeServerlessDefinition(makeApp());
244
+ expect(t.provider.vpc).toBeDefined();
245
+ expect(t.provider.vpc.subnetIds).toBeDefined();
246
+ expect(t.provider.vpc.securityGroupIds).toBeDefined();
247
+ });
248
+
249
+ test('DATABASE_URL does not add sslmode in vpc mode', async () => {
250
+ const t = await composeServerlessDefinition(makeApp());
251
+ const url = t.provider.environment.DATABASE_URL;
252
+ expect(url['Fn::Sub'][0]).not.toContain('sslmode=require');
253
+ });
254
+ });
255
+ });
@@ -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