@friggframework/devtools 2.0.0--canary.623.b39b42d.0 → 2.0.0--canary.625.950ba82.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 (39) hide show
  1. package/frigg-cli/__tests__/unit/commands/generate-iam.test.js +97 -0
  2. package/frigg-cli/deploy-command/index.js +44 -0
  3. package/frigg-cli/generate-command/index.js +2 -1
  4. package/frigg-cli/generate-iam-command.js +2 -1
  5. package/frigg-cli/index.js +16 -1
  6. package/frigg-cli/ssm-command/index.js +270 -0
  7. package/frigg-cli/ssm-command/index.test.js +301 -0
  8. package/infrastructure/__tests__/helpers/test-utils.js +3 -5
  9. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  10. package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
  11. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
  12. package/infrastructure/domains/database/migration-builder.js +46 -9
  13. package/infrastructure/domains/database/migration-builder.test.js +101 -0
  14. package/infrastructure/domains/integration/integration-builder.js +65 -9
  15. package/infrastructure/domains/integration/integration-builder.test.js +95 -0
  16. package/infrastructure/domains/networking/vpc-builder.js +57 -7
  17. package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
  18. package/infrastructure/domains/networking/vpc-resolver.js +12 -3
  19. package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
  20. package/infrastructure/domains/parameters/offload-utils.js +175 -0
  21. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  22. package/infrastructure/domains/parameters/ssm-builder.js +60 -14
  23. package/infrastructure/domains/parameters/ssm-builder.test.js +145 -1
  24. package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
  25. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  26. package/infrastructure/domains/security/iam-generator.js +33 -1
  27. package/infrastructure/domains/security/iam-generator.test.js +73 -0
  28. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +15 -0
  29. package/infrastructure/domains/security/templates/iam-policy-full.json +7 -2
  30. package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
  31. package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
  32. package/infrastructure/domains/shared/environment-builder.js +43 -7
  33. package/infrastructure/domains/shared/environment-builder.test.js +97 -1
  34. package/infrastructure/domains/shared/function-environments.js +97 -0
  35. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  36. package/infrastructure/infrastructure-composer.js +5 -0
  37. package/infrastructure/infrastructure-composer.test.js +69 -14
  38. package/infrastructure/integration.test.js +3 -5
  39. package/package.json +8 -7
@@ -0,0 +1,301 @@
1
+ const { mockClient } = require('aws-sdk-client-mock');
2
+ const { SSMClient, PutParameterCommand } = require('@aws-sdk/client-ssm');
3
+ const { pushOffloadedParameters, resolvePushRegion } = require('./index');
4
+
5
+ describe('ssm-command pushOffloadedParameters', () => {
6
+ let ssmMock;
7
+ const originalEnv = process.env;
8
+
9
+ const appDefinition = {
10
+ name: 'my-app',
11
+ ssm: {
12
+ enable: true,
13
+ parameters: {
14
+ MY_SECRET: { type: 'SecureString' },
15
+ },
16
+ },
17
+ environment: {
18
+ MY_CONFIG: 'ssm',
19
+ PLAIN: true,
20
+ },
21
+ };
22
+
23
+ beforeEach(() => {
24
+ ssmMock = mockClient(SSMClient);
25
+ ssmMock.on(PutParameterCommand).resolves({ Version: 1 });
26
+ process.env = {
27
+ ...originalEnv,
28
+ MY_SECRET: 'shh',
29
+ MY_CONFIG: 'value-1',
30
+ };
31
+ });
32
+
33
+ afterEach(() => {
34
+ ssmMock.restore();
35
+ process.env = originalEnv;
36
+ });
37
+
38
+ it('pushes each offloaded key with resolved name, type, and overwrite', async () => {
39
+ const result = await pushOffloadedParameters(appDefinition, 'prod');
40
+
41
+ const calls = ssmMock.commandCalls(PutParameterCommand);
42
+ expect(calls).toHaveLength(2);
43
+
44
+ const byName = Object.fromEntries(
45
+ calls.map((c) => [c.args[0].input.Name, c.args[0].input])
46
+ );
47
+ expect(byName['/frigg/my-app/prod/MY_SECRET']).toMatchObject({
48
+ Value: 'shh',
49
+ Type: 'SecureString',
50
+ Overwrite: true,
51
+ });
52
+ expect(byName['/frigg/my-app/prod/MY_CONFIG']).toMatchObject({
53
+ Value: 'value-1',
54
+ Type: 'String',
55
+ Overwrite: true,
56
+ });
57
+ expect(result.pushed).toHaveLength(2);
58
+ });
59
+
60
+ it('passes KeyId for SecureString when ssm.kmsKeyArn is set', async () => {
61
+ const withKey = {
62
+ ...appDefinition,
63
+ ssm: {
64
+ ...appDefinition.ssm,
65
+ kmsKeyArn: 'arn:aws:kms:us-east-1:1:key/k',
66
+ },
67
+ };
68
+ await pushOffloadedParameters(withKey, 'dev');
69
+
70
+ const inputs = ssmMock
71
+ .commandCalls(PutParameterCommand)
72
+ .map((c) => c.args[0].input);
73
+ const secure = inputs.find((i) => i.Type === 'SecureString');
74
+ const plain = inputs.find((i) => i.Type === 'String');
75
+ expect(secure.KeyId).toBe('arn:aws:kms:us-east-1:1:key/k');
76
+ expect(plain.KeyId).toBeUndefined();
77
+ });
78
+
79
+ it('is a silent no-op when nothing is offloaded', async () => {
80
+ const result = await pushOffloadedParameters(
81
+ { name: 'x', environment: { PLAIN: true } },
82
+ 'dev'
83
+ );
84
+ expect(result.pushed).toHaveLength(0);
85
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
86
+ });
87
+
88
+ it('throws listing keys with missing or empty values', async () => {
89
+ delete process.env.MY_SECRET;
90
+ process.env.MY_CONFIG = '';
91
+
92
+ await expect(
93
+ pushOffloadedParameters(appDefinition, 'dev')
94
+ ).rejects.toThrow(/MY_SECRET.*MY_CONFIG|MY_CONFIG.*MY_SECRET/s);
95
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
96
+ });
97
+
98
+ it('skips missing values with allowEmpty instead of throwing', async () => {
99
+ delete process.env.MY_SECRET;
100
+
101
+ const result = await pushOffloadedParameters(appDefinition, 'dev', {
102
+ allowEmpty: true,
103
+ });
104
+ expect(result.skipped).toEqual(['MY_SECRET']);
105
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(1);
106
+ });
107
+
108
+ it('rejects values over the standard tier limit and suggests advanced', async () => {
109
+ process.env.MY_CONFIG = 'x'.repeat(4097);
110
+
111
+ await expect(
112
+ pushOffloadedParameters(appDefinition, 'dev')
113
+ ).rejects.toThrow(/advanced/i);
114
+ });
115
+
116
+ it('accepts large values with the advanced tier', async () => {
117
+ process.env.MY_CONFIG = 'x'.repeat(4097);
118
+
119
+ await pushOffloadedParameters(appDefinition, 'dev', {
120
+ tier: 'advanced',
121
+ });
122
+ const inputs = ssmMock
123
+ .commandCalls(PutParameterCommand)
124
+ .map((c) => c.args[0].input);
125
+ expect(
126
+ inputs.find((i) => i.Name.endsWith('/MY_CONFIG')).Tier
127
+ ).toBe('Advanced');
128
+ });
129
+
130
+ it('rejects values over the advanced tier limit', async () => {
131
+ process.env.MY_CONFIG = 'x'.repeat(8193);
132
+
133
+ await expect(
134
+ pushOffloadedParameters(appDefinition, 'dev', {
135
+ tier: 'advanced',
136
+ })
137
+ ).rejects.toThrow(/8192|8 ?KB/i);
138
+ });
139
+
140
+ describe('per-key tier resolution', () => {
141
+ const withKeyTier = {
142
+ ...appDefinition,
143
+ ssm: {
144
+ ...appDefinition.ssm,
145
+ parameters: {
146
+ MY_SECRET: { type: 'SecureString' },
147
+ MY_CONFIG: { tier: 'advanced' },
148
+ },
149
+ },
150
+ };
151
+
152
+ it('accepts a large value when the key is configured advanced in the app definition', async () => {
153
+ process.env.MY_CONFIG = 'x'.repeat(4097);
154
+
155
+ await pushOffloadedParameters(withKeyTier, 'dev');
156
+
157
+ const config = ssmMock
158
+ .commandCalls(PutParameterCommand)
159
+ .map((c) => c.args[0].input)
160
+ .find((i) => i.Name.endsWith('/MY_CONFIG'));
161
+ expect(config.Tier).toBe('Advanced');
162
+ });
163
+
164
+ it('validates each key against its own configured tier', async () => {
165
+ // MY_CONFIG (advanced) tolerates 5000 bytes; MY_SECRET (standard) does not
166
+ process.env.MY_CONFIG = 'x'.repeat(5000);
167
+ process.env.MY_SECRET = 'y'.repeat(5000);
168
+
169
+ await expect(
170
+ pushOffloadedParameters(withKeyTier, 'dev')
171
+ ).rejects.toThrow(/MY_SECRET/);
172
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
173
+ });
174
+
175
+ it('does not tag standard-tier keys with an Advanced tier', async () => {
176
+ await pushOffloadedParameters(withKeyTier, 'dev');
177
+
178
+ const secret = ssmMock
179
+ .commandCalls(PutParameterCommand)
180
+ .map((c) => c.args[0].input)
181
+ .find((i) => i.Name.endsWith('/MY_SECRET'));
182
+ expect(secret.Tier).toBeUndefined();
183
+ });
184
+
185
+ it('lets the --tier CLI option override per-key config for all keys', async () => {
186
+ process.env.MY_CONFIG = 'x'.repeat(4097);
187
+
188
+ await expect(
189
+ pushOffloadedParameters(withKeyTier, 'dev', {
190
+ tier: 'standard',
191
+ })
192
+ ).rejects.toThrow(/advanced/i);
193
+ });
194
+ });
195
+
196
+ it('throws on invalid offload config (markers without ssm.enable)', async () => {
197
+ await expect(
198
+ pushOffloadedParameters(
199
+ { name: 'x', environment: { FOO: 'ssm' } },
200
+ 'dev'
201
+ )
202
+ ).rejects.toThrow(/ssm\.enable/);
203
+ });
204
+
205
+ it('wraps AWS errors with the parameter name', async () => {
206
+ ssmMock
207
+ .on(PutParameterCommand, {
208
+ Name: '/frigg/my-app/dev/MY_SECRET',
209
+ })
210
+ .rejects(new Error('boom'));
211
+
212
+ await expect(
213
+ pushOffloadedParameters(appDefinition, 'dev')
214
+ ).rejects.toThrow(/MY_SECRET/);
215
+ });
216
+
217
+ it('retries a throttled PutParameter and succeeds', async () => {
218
+ const throttle = Object.assign(new Error('Rate exceeded'), {
219
+ name: 'ThrottlingException',
220
+ });
221
+ ssmMock
222
+ .on(PutParameterCommand, {
223
+ Name: '/frigg/my-app/dev/MY_SECRET',
224
+ })
225
+ .rejectsOnce(throttle)
226
+ .resolves({ Version: 2 });
227
+
228
+ const result = await pushOffloadedParameters(appDefinition, 'dev');
229
+
230
+ expect(result.pushed).toHaveLength(2);
231
+ const secretCalls = ssmMock
232
+ .commandCalls(PutParameterCommand)
233
+ .filter(
234
+ (c) => c.args[0].input.Name === '/frigg/my-app/dev/MY_SECRET'
235
+ );
236
+ expect(secretCalls).toHaveLength(2);
237
+ });
238
+
239
+ it('exposes keys pushed so far on a mid-batch failure', async () => {
240
+ // MY_CONFIG (sorted before MY_SECRET) succeeds; MY_SECRET fails.
241
+ ssmMock
242
+ .on(PutParameterCommand, {
243
+ Name: '/frigg/my-app/dev/MY_CONFIG',
244
+ })
245
+ .resolves({ Version: 1 });
246
+ ssmMock
247
+ .on(PutParameterCommand, {
248
+ Name: '/frigg/my-app/dev/MY_SECRET',
249
+ })
250
+ .rejects(new Error('boom'));
251
+
252
+ let caught;
253
+ try {
254
+ await pushOffloadedParameters(appDefinition, 'dev');
255
+ } catch (error) {
256
+ caught = error;
257
+ }
258
+
259
+ expect(caught).toBeDefined();
260
+ expect(caught.pushed).toEqual([
261
+ { name: '/frigg/my-app/dev/MY_CONFIG', version: 1 },
262
+ ]);
263
+ });
264
+
265
+ it('honors a custom parameterPrefix', async () => {
266
+ const custom = {
267
+ ...appDefinition,
268
+ ssm: { ...appDefinition.ssm, parameterPrefix: '/team/x' },
269
+ };
270
+ await pushOffloadedParameters(custom, 'dev');
271
+
272
+ const names = ssmMock
273
+ .commandCalls(PutParameterCommand)
274
+ .map((c) => c.args[0].input.Name);
275
+ expect(names).toEqual(
276
+ expect.arrayContaining([
277
+ '/team/x/MY_SECRET',
278
+ '/team/x/MY_CONFIG',
279
+ ])
280
+ );
281
+ });
282
+
283
+ describe('resolvePushRegion', () => {
284
+ it('prefers an explicit --region option over the environment', () => {
285
+ process.env.AWS_REGION = 'us-west-2';
286
+ expect(resolvePushRegion({ region: 'eu-central-1' })).toBe(
287
+ 'eu-central-1'
288
+ );
289
+ });
290
+
291
+ it('falls back to AWS_REGION when no option is given', () => {
292
+ process.env.AWS_REGION = 'us-west-2';
293
+ expect(resolvePushRegion({})).toBe('us-west-2');
294
+ });
295
+
296
+ it('defaults to us-east-1 when neither option nor AWS_REGION is set', () => {
297
+ delete process.env.AWS_REGION;
298
+ expect(resolvePushRegion({})).toBe('us-east-1');
299
+ });
300
+ });
301
+ });
@@ -166,12 +166,10 @@ function verifyKmsConfiguration(config) {
166
166
  * @param {Object} config - Serverless configuration
167
167
  */
168
168
  function verifySsmConfiguration(config) {
169
- expect(config.provider.layers).toEqual([
170
- 'arn:aws:lambda:${self:provider.region}:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11'
171
- ]);
172
- expect(config.provider.environment.SSM_PARAMETER_PREFIX).toBe('/${self:service}/${self:provider.stage}');
169
+ // The framework does NOT use the AWS Parameters-and-Secrets extension layer
170
+ expect(config.provider.layers).toBeUndefined();
173
171
 
174
- // Verify SSM IAM permissions
172
+ // Verify SSM IAM permissions (broad read grant added whenever SSM is enabled)
175
173
  const ssmPermission = config.provider.iamRoleStatements.find(
176
174
  statement => statement.Action.includes('ssm:GetParameter')
177
175
  );
@@ -0,0 +1,126 @@
1
+ /**
2
+ * End-to-end composer tests for per-function environment scoping
3
+ * (lambda.scopedEnvironment, ADR-027).
4
+ */
5
+
6
+ const { composeServerlessDefinition } = require('../infrastructure-composer');
7
+
8
+ jest.mock('../domains/shared/resource-discovery', () => {
9
+ const originalModule = jest.requireActual(
10
+ '../domains/shared/resource-discovery'
11
+ );
12
+ return {
13
+ ...originalModule,
14
+ gatherDiscoveredResources: jest.fn().mockResolvedValue({
15
+ defaultVpcId: 'vpc-123456',
16
+ defaultSecurityGroupId: 'sg-123456',
17
+ privateSubnetId1: 'subnet-123456',
18
+ privateSubnetId2: 'subnet-789012',
19
+ defaultKmsKeyId:
20
+ 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012',
21
+ auroraClusterEndpoint:
22
+ 'test-cluster.cluster-abc123.us-east-1.rds.amazonaws.com',
23
+ auroraPort: 5432,
24
+ }),
25
+ };
26
+ });
27
+
28
+ const buildApp = (overrides = {}) => ({
29
+ name: 'scoped-test-app',
30
+ provider: 'aws',
31
+ usePrismaLambdaLayer: false,
32
+ encryption: { fieldLevelEncryptionMethod: 'aes' },
33
+ vpc: { enable: false },
34
+ database: { postgres: { enable: true } },
35
+ adminScripts: [{ Definition: { name: 'fixThings' } }],
36
+ integrations: [
37
+ { Definition: { name: 'hubspot', webhooks: true } },
38
+ { Definition: { name: 'slack' } },
39
+ ],
40
+ ...overrides,
41
+ });
42
+
43
+ const SCOPED_VARS = [
44
+ 'HUBSPOT_QUEUE_URL',
45
+ 'SLACK_QUEUE_URL',
46
+ 'SCHEDULER_ROLE_ARN',
47
+ 'SCHEDULE_GROUP_NAME',
48
+ 'S3_BUCKET_NAME',
49
+ 'MIGRATION_STATUS_BUCKET',
50
+ 'DB_MIGRATION_QUEUE_URL',
51
+ 'ADMIN_SCRIPT_QUEUE_URL',
52
+ ];
53
+
54
+ describe('lambda.scopedEnvironment composer e2e', () => {
55
+ beforeEach(() => {
56
+ process.argv = ['node', 'test'];
57
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
58
+ });
59
+
60
+ afterEach(() => {
61
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
62
+ });
63
+
64
+ it('removes scoped vars from provider.environment and lands them on exact consumer sets', async () => {
65
+ const definition = await composeServerlessDefinition(
66
+ buildApp({ lambda: { scopedEnvironment: true } })
67
+ );
68
+
69
+ for (const key of SCOPED_VARS) {
70
+ expect(definition.provider.environment[key]).toBeUndefined();
71
+ }
72
+ // DB_TYPE deliberately stays global
73
+ expect(definition.provider.environment.DB_TYPE).toBe('postgresql');
74
+
75
+ const env = (fnName) => definition.functions[fnName].environment || {};
76
+
77
+ // auth + admin functions can reach every integration queue
78
+ for (const fnName of [
79
+ 'auth',
80
+ 'adminScriptRouter',
81
+ 'adminScriptExecutor',
82
+ ]) {
83
+ expect(env(fnName).HUBSPOT_QUEUE_URL).toBeDefined();
84
+ expect(env(fnName).SLACK_QUEUE_URL).toBeDefined();
85
+ }
86
+
87
+ // owning integration set, cross-integration isolation
88
+ expect(env('hubspot').HUBSPOT_QUEUE_URL).toBeDefined();
89
+ expect(env('hubspotWebhook').HUBSPOT_QUEUE_URL).toBeDefined();
90
+ expect(env('hubspotQueueWorker').HUBSPOT_QUEUE_URL).toBeDefined();
91
+ expect(env('hubspotQueueWorker').SLACK_QUEUE_URL).toBeUndefined();
92
+ expect(env('slackQueueWorker').HUBSPOT_QUEUE_URL).toBeUndefined();
93
+
94
+ // scheduler vars on integration functions and the executor, but the
95
+ // router keeps its own admin-scheduler role reference untouched
96
+ expect(env('hubspot').SCHEDULER_ROLE_ARN).toEqual({
97
+ 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
98
+ });
99
+ expect(env('adminScriptExecutor').SCHEDULER_ROLE_ARN).toEqual({
100
+ 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
101
+ });
102
+
103
+ // migration vars only on the migration functions
104
+ expect(env('dbMigrationRouter').DB_MIGRATION_QUEUE_URL).toBeDefined();
105
+ expect(env('dbMigrationWorker').S3_BUCKET_NAME).toBeDefined();
106
+
107
+ // base functions that consume none of this carry none of it
108
+ for (const fnName of ['user', 'health']) {
109
+ for (const key of SCOPED_VARS) {
110
+ expect(env(fnName)[key]).toBeUndefined();
111
+ }
112
+ }
113
+ });
114
+
115
+ it('keeps full broadcast when the flag is off, byte-identical to omitting the lambda key', async () => {
116
+ const withoutKey = await composeServerlessDefinition(buildApp());
117
+ const withFlagOff = await composeServerlessDefinition(
118
+ buildApp({ lambda: { scopedEnvironment: false } })
119
+ );
120
+
121
+ expect(withFlagOff).toEqual(withoutKey);
122
+ for (const key of SCOPED_VARS) {
123
+ expect(withoutKey.provider.environment[key]).toBeDefined();
124
+ }
125
+ });
126
+ });
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
17
+ const { isScopedEnvironmentActive } = require('../shared/function-environments');
17
18
 
18
19
  class AdminScriptBuilder extends InfrastructureBuilder {
19
20
  constructor() {
@@ -67,7 +68,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
67
68
  };
68
69
 
69
70
  // Create admin script queue
70
- this.createAdminScriptQueue(result);
71
+ this.createAdminScriptQueue(result, appDefinition);
71
72
 
72
73
  // Create Lambda function for script execution
73
74
  this.createScriptExecutorFunction(appDefinition, result, usePrismaLayer);
@@ -90,7 +91,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
90
91
  return result;
91
92
  }
92
93
 
93
- createAdminScriptQueue(result) {
94
+ createAdminScriptQueue(result, appDefinition) {
94
95
  result.resources.AdminScriptQueue = {
95
96
  Type: 'AWS::SQS::Queue',
96
97
  Properties: {
@@ -106,7 +107,20 @@ class AdminScriptBuilder extends InfrastructureBuilder {
106
107
  },
107
108
  };
108
109
 
109
- result.environment.ADMIN_SCRIPT_QUEUE_URL = { Ref: 'AdminScriptQueue' };
110
+ if (isScopedEnvironmentActive(appDefinition)) {
111
+ // Only the admin functions read this queue URL
112
+ result.functionEnvironments = result.functionEnvironments || {};
113
+ for (const fnName of ['adminScriptRouter', 'adminScriptExecutor']) {
114
+ result.functionEnvironments[fnName] = {
115
+ ...result.functionEnvironments[fnName],
116
+ ADMIN_SCRIPT_QUEUE_URL: { Ref: 'AdminScriptQueue' },
117
+ };
118
+ }
119
+ } else {
120
+ result.environment.ADMIN_SCRIPT_QUEUE_URL = {
121
+ Ref: 'AdminScriptQueue',
122
+ };
123
+ }
110
124
 
111
125
  // The router enqueues async executions and scripts enqueue continuations
112
126
  // via queueScript()/queueScriptBatch(). The base role's wildcard does not
@@ -625,4 +625,49 @@ describe('AdminScriptBuilder', () => {
625
625
  expect(adminScriptBuilder.getName()).toBe('AdminScriptBuilder');
626
626
  });
627
627
  });
628
+
629
+ describe('scoped environment (lambda.scopedEnvironment)', () => {
630
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
631
+
632
+ beforeEach(() => {
633
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
634
+ });
635
+
636
+ afterEach(() => {
637
+ if (originalSkipDiscovery === undefined) {
638
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
639
+ } else {
640
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
641
+ }
642
+ });
643
+
644
+ it('scopes ADMIN_SCRIPT_QUEUE_URL to the admin functions only', async () => {
645
+ const result = await adminScriptBuilder.build(
646
+ {
647
+ lambda: { scopedEnvironment: true },
648
+ adminScripts: [{ Definition: { name: 'fix-things' } }],
649
+ },
650
+ {}
651
+ );
652
+
653
+ expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toBeUndefined();
654
+ for (const fnName of ['adminScriptRouter', 'adminScriptExecutor']) {
655
+ expect(
656
+ result.functionEnvironments[fnName].ADMIN_SCRIPT_QUEUE_URL
657
+ ).toEqual({ Ref: 'AdminScriptQueue' });
658
+ }
659
+ });
660
+
661
+ it('broadcasts app-wide when the flag is off', async () => {
662
+ const result = await adminScriptBuilder.build(
663
+ { adminScripts: [{ Definition: { name: 'fix-things' } }] },
664
+ {}
665
+ );
666
+
667
+ expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toEqual({
668
+ Ref: 'AdminScriptQueue',
669
+ });
670
+ expect(result.functionEnvironments).toBeUndefined();
671
+ });
672
+ });
628
673
  });
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
17
+ const { isScopedEnvironmentActive } = require('../shared/function-environments');
17
18
  const { MigrationResourceResolver } = require('./migration-resolver');
18
19
  const { createEmptyDiscoveryResult, ResourceOwnership } = require('../shared/types');
19
20
 
@@ -564,14 +565,30 @@ class MigrationBuilder extends InfrastructureBuilder {
564
565
 
565
566
  console.log(' ✓ Created DbMigrationQueue resource');
566
567
 
567
- // Add S3 bucket name to environment (for migration Lambda functions)
568
- result.environment.S3_BUCKET_NAME = { Ref: 'FriggMigrationStatusBucket' };
569
- result.environment.MIGRATION_STATUS_BUCKET = { Ref: 'FriggMigrationStatusBucket' };
568
+ const migrationEnvironment = {
569
+ // S3 bucket for migration Lambda functions
570
+ S3_BUCKET_NAME: { Ref: 'FriggMigrationStatusBucket' },
571
+ MIGRATION_STATUS_BUCKET: { Ref: 'FriggMigrationStatusBucket' },
572
+ DB_MIGRATION_QUEUE_URL: { Ref: 'DbMigrationQueue' },
573
+ };
570
574
 
571
- // Add queue URL to environment
572
- result.environment.DB_MIGRATION_QUEUE_URL = { Ref: 'DbMigrationQueue' };
575
+ if (isScopedEnvironmentActive(appDefinition)) {
576
+ // Only the migration functions read these
577
+ result.functionEnvironments = result.functionEnvironments || {};
578
+ for (const fnName of ['dbMigrationRouter', 'dbMigrationWorker']) {
579
+ result.functionEnvironments[fnName] = {
580
+ ...result.functionEnvironments[fnName],
581
+ ...migrationEnvironment,
582
+ };
583
+ }
584
+ } else {
585
+ Object.assign(result.environment, migrationEnvironment);
586
+ }
573
587
 
574
- // Hardcode DB_TYPE for PostgreSQL-only migrations
588
+ // Hardcode DB_TYPE for PostgreSQL-only migrations. Stays app-wide
589
+ // even when scoping: it is tiny and broadly consumed, and scoping it
590
+ // would push every function through the app-definition fallback in
591
+ // core's getDatabaseType() at cold start.
575
592
  result.environment.DB_TYPE = 'postgresql';
576
593
 
577
594
  console.log(' ✓ Added S3_BUCKET_NAME, DB_MIGRATION_QUEUE_URL, and DB_TYPE environment variables');
@@ -656,9 +673,29 @@ class MigrationBuilder extends InfrastructureBuilder {
656
673
  .replace(/\//g, ':');
657
674
 
658
675
  // Add environment variables (using external resource names/URLs)
659
- result.environment.S3_BUCKET_NAME = bucketName;
660
- result.environment.MIGRATION_STATUS_BUCKET = bucketName;
661
- result.environment.DB_MIGRATION_QUEUE_URL = queueUrl;
676
+ const migrationEnvironment = {
677
+ S3_BUCKET_NAME: bucketName,
678
+ MIGRATION_STATUS_BUCKET: bucketName,
679
+ DB_MIGRATION_QUEUE_URL: queueUrl,
680
+ };
681
+
682
+ if (isScopedEnvironmentActive(appDefinition)) {
683
+ // Only the migration functions read these
684
+ result.functionEnvironments = result.functionEnvironments || {};
685
+ for (const fnName of ['dbMigrationRouter', 'dbMigrationWorker']) {
686
+ result.functionEnvironments[fnName] = {
687
+ ...result.functionEnvironments[fnName],
688
+ ...migrationEnvironment,
689
+ };
690
+ }
691
+ } else {
692
+ Object.assign(result.environment, migrationEnvironment);
693
+ }
694
+
695
+ // Hardcode DB_TYPE for PostgreSQL-only migrations. Stays app-wide
696
+ // even when scoping: it is tiny and broadly consumed, and scoping it
697
+ // would push every function through the app-definition fallback in
698
+ // core's getDatabaseType() at cold start.
662
699
  result.environment.DB_TYPE = 'postgresql';
663
700
 
664
701
  console.log(' ✓ Added S3_BUCKET_NAME, DB_MIGRATION_QUEUE_URL, and DB_TYPE environment variables');