@friggframework/devtools 2.0.0-next.101 → 2.0.0-next.102

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 (42) 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 +308 -0
  7. package/frigg-cli/ssm-command/index.test.js +318 -0
  8. package/infrastructure/__tests__/helpers/test-utils.js +3 -5
  9. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  10. package/infrastructure/__tests__/ssm-preload-node-options.test.js +79 -0
  11. package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
  12. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
  13. package/infrastructure/domains/database/migration-builder.js +46 -9
  14. package/infrastructure/domains/database/migration-builder.test.js +101 -0
  15. package/infrastructure/domains/integration/integration-builder.js +65 -9
  16. package/infrastructure/domains/integration/integration-builder.test.js +95 -0
  17. package/infrastructure/domains/networking/vpc-builder.js +57 -7
  18. package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
  19. package/infrastructure/domains/networking/vpc-discovery.js +9 -2
  20. package/infrastructure/domains/networking/vpc-discovery.test.js +19 -1
  21. package/infrastructure/domains/networking/vpc-resolver.js +12 -3
  22. package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
  23. package/infrastructure/domains/parameters/offload-utils.js +190 -0
  24. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  25. package/infrastructure/domains/parameters/ssm-builder.js +67 -14
  26. package/infrastructure/domains/parameters/ssm-builder.test.js +158 -1
  27. package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
  28. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  29. package/infrastructure/domains/security/iam-generator.js +36 -1
  30. package/infrastructure/domains/security/iam-generator.test.js +83 -0
  31. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +17 -0
  32. package/infrastructure/domains/security/templates/iam-policy-full.json +8 -3
  33. package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
  34. package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
  35. package/infrastructure/domains/shared/environment-builder.js +43 -7
  36. package/infrastructure/domains/shared/environment-builder.test.js +97 -1
  37. package/infrastructure/domains/shared/function-environments.js +97 -0
  38. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  39. package/infrastructure/infrastructure-composer.js +48 -1
  40. package/infrastructure/infrastructure-composer.test.js +161 -15
  41. package/infrastructure/integration.test.js +3 -5
  42. package/package.json +8 -7
@@ -0,0 +1,318 @@
1
+ const { mockClient } = require('aws-sdk-client-mock');
2
+ const {
3
+ SSMClient,
4
+ PutParameterCommand,
5
+ GetParametersCommand,
6
+ } = require('@aws-sdk/client-ssm');
7
+ const { pushOffloadedParameters, resolvePushRegion } = require('./index');
8
+
9
+ describe('ssm-command pushOffloadedParameters', () => {
10
+ let ssmMock;
11
+ const originalEnv = process.env;
12
+
13
+ const appDefinition = {
14
+ name: 'my-app',
15
+ ssm: {
16
+ enable: true,
17
+ parameters: {
18
+ MY_SECRET: { type: 'SecureString' },
19
+ },
20
+ },
21
+ environment: {
22
+ MY_CONFIG: 'ssm',
23
+ PLAIN: true,
24
+ },
25
+ };
26
+
27
+ beforeEach(() => {
28
+ ssmMock = mockClient(SSMClient);
29
+ ssmMock.on(PutParameterCommand).resolves({ Version: 1 });
30
+ process.env = {
31
+ ...originalEnv,
32
+ MY_SECRET: 'shh',
33
+ MY_CONFIG: 'value-1',
34
+ };
35
+ });
36
+
37
+ afterEach(() => {
38
+ ssmMock.restore();
39
+ process.env = originalEnv;
40
+ });
41
+
42
+ it('pushes each offloaded key with resolved name, type, and overwrite', async () => {
43
+ const result = await pushOffloadedParameters(appDefinition, 'prod');
44
+
45
+ const calls = ssmMock.commandCalls(PutParameterCommand);
46
+ expect(calls).toHaveLength(2);
47
+
48
+ const byName = Object.fromEntries(
49
+ calls.map((c) => [c.args[0].input.Name, c.args[0].input])
50
+ );
51
+ expect(byName['/frigg/my-app/prod/MY_SECRET']).toMatchObject({
52
+ Value: 'shh',
53
+ Type: 'SecureString',
54
+ Overwrite: true,
55
+ });
56
+ expect(byName['/frigg/my-app/prod/MY_CONFIG']).toMatchObject({
57
+ Value: 'value-1',
58
+ Type: 'String',
59
+ Overwrite: true,
60
+ });
61
+ expect(result.pushed).toHaveLength(2);
62
+ });
63
+
64
+ it('passes KeyId for SecureString when ssm.kmsKeyArn is set', async () => {
65
+ const withKey = {
66
+ ...appDefinition,
67
+ ssm: {
68
+ ...appDefinition.ssm,
69
+ kmsKeyArn: 'arn:aws:kms:us-east-1:1:key/k',
70
+ },
71
+ };
72
+ await pushOffloadedParameters(withKey, 'dev');
73
+
74
+ const inputs = ssmMock
75
+ .commandCalls(PutParameterCommand)
76
+ .map((c) => c.args[0].input);
77
+ const secure = inputs.find((i) => i.Type === 'SecureString');
78
+ const plain = inputs.find((i) => i.Type === 'String');
79
+ expect(secure.KeyId).toBe('arn:aws:kms:us-east-1:1:key/k');
80
+ expect(plain.KeyId).toBeUndefined();
81
+ });
82
+
83
+ it('is a silent no-op when nothing is offloaded', async () => {
84
+ const result = await pushOffloadedParameters(
85
+ { name: 'x', environment: { PLAIN: true } },
86
+ 'dev'
87
+ );
88
+ expect(result.pushed).toHaveLength(0);
89
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
90
+ });
91
+
92
+ it('throws listing keys with missing or empty values', async () => {
93
+ delete process.env.MY_SECRET;
94
+ process.env.MY_CONFIG = '';
95
+
96
+ await expect(
97
+ pushOffloadedParameters(appDefinition, 'dev')
98
+ ).rejects.toThrow(/MY_SECRET.*MY_CONFIG|MY_CONFIG.*MY_SECRET/s);
99
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
100
+ });
101
+
102
+ it('skips missing values with allowEmpty when the parameter already exists', async () => {
103
+ delete process.env.MY_SECRET;
104
+ ssmMock.on(GetParametersCommand).resolves({
105
+ Parameters: [{ Name: '/frigg/my-app/dev/MY_SECRET' }],
106
+ });
107
+
108
+ const result = await pushOffloadedParameters(appDefinition, 'dev', {
109
+ allowEmpty: true,
110
+ });
111
+ expect(result.skipped).toEqual(['MY_SECRET']);
112
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(1);
113
+ });
114
+
115
+ it('aborts when allowEmpty skips a key that does not exist in Parameter Store', async () => {
116
+ delete process.env.MY_SECRET;
117
+ ssmMock.on(GetParametersCommand).resolves({ Parameters: [] });
118
+
119
+ await expect(
120
+ pushOffloadedParameters(appDefinition, 'dev', { allowEmpty: true })
121
+ ).rejects.toThrow(/MY_SECRET.*no such parameter|--allow-empty/s);
122
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
123
+ });
124
+
125
+ it('rejects values over the standard tier limit and suggests advanced', async () => {
126
+ process.env.MY_CONFIG = 'x'.repeat(4097);
127
+
128
+ await expect(
129
+ pushOffloadedParameters(appDefinition, 'dev')
130
+ ).rejects.toThrow(/advanced/i);
131
+ });
132
+
133
+ it('accepts large values with the advanced tier', async () => {
134
+ process.env.MY_CONFIG = 'x'.repeat(4097);
135
+
136
+ await pushOffloadedParameters(appDefinition, 'dev', {
137
+ tier: 'advanced',
138
+ });
139
+ const inputs = ssmMock
140
+ .commandCalls(PutParameterCommand)
141
+ .map((c) => c.args[0].input);
142
+ expect(
143
+ inputs.find((i) => i.Name.endsWith('/MY_CONFIG')).Tier
144
+ ).toBe('Advanced');
145
+ });
146
+
147
+ it('rejects values over the advanced tier limit', async () => {
148
+ process.env.MY_CONFIG = 'x'.repeat(8193);
149
+
150
+ await expect(
151
+ pushOffloadedParameters(appDefinition, 'dev', {
152
+ tier: 'advanced',
153
+ })
154
+ ).rejects.toThrow(/8192|8 ?KB/i);
155
+ });
156
+
157
+ describe('per-key tier resolution', () => {
158
+ const withKeyTier = {
159
+ ...appDefinition,
160
+ ssm: {
161
+ ...appDefinition.ssm,
162
+ parameters: {
163
+ MY_SECRET: { type: 'SecureString' },
164
+ MY_CONFIG: { tier: 'advanced' },
165
+ },
166
+ },
167
+ };
168
+
169
+ it('accepts a large value when the key is configured advanced in the app definition', async () => {
170
+ process.env.MY_CONFIG = 'x'.repeat(4097);
171
+
172
+ await pushOffloadedParameters(withKeyTier, 'dev');
173
+
174
+ const config = ssmMock
175
+ .commandCalls(PutParameterCommand)
176
+ .map((c) => c.args[0].input)
177
+ .find((i) => i.Name.endsWith('/MY_CONFIG'));
178
+ expect(config.Tier).toBe('Advanced');
179
+ });
180
+
181
+ it('validates each key against its own configured tier', async () => {
182
+ // MY_CONFIG (advanced) tolerates 5000 bytes; MY_SECRET (standard) does not
183
+ process.env.MY_CONFIG = 'x'.repeat(5000);
184
+ process.env.MY_SECRET = 'y'.repeat(5000);
185
+
186
+ await expect(
187
+ pushOffloadedParameters(withKeyTier, 'dev')
188
+ ).rejects.toThrow(/MY_SECRET/);
189
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
190
+ });
191
+
192
+ it('does not tag standard-tier keys with an Advanced tier', async () => {
193
+ await pushOffloadedParameters(withKeyTier, 'dev');
194
+
195
+ const secret = ssmMock
196
+ .commandCalls(PutParameterCommand)
197
+ .map((c) => c.args[0].input)
198
+ .find((i) => i.Name.endsWith('/MY_SECRET'));
199
+ expect(secret.Tier).toBeUndefined();
200
+ });
201
+
202
+ it('lets the --tier CLI option override per-key config for all keys', async () => {
203
+ process.env.MY_CONFIG = 'x'.repeat(4097);
204
+
205
+ await expect(
206
+ pushOffloadedParameters(withKeyTier, 'dev', {
207
+ tier: 'standard',
208
+ })
209
+ ).rejects.toThrow(/advanced/i);
210
+ });
211
+ });
212
+
213
+ it('throws on invalid offload config (markers without ssm.enable)', async () => {
214
+ await expect(
215
+ pushOffloadedParameters(
216
+ { name: 'x', environment: { FOO: 'ssm' } },
217
+ 'dev'
218
+ )
219
+ ).rejects.toThrow(/ssm\.enable/);
220
+ });
221
+
222
+ it('wraps AWS errors with the parameter name', async () => {
223
+ ssmMock
224
+ .on(PutParameterCommand, {
225
+ Name: '/frigg/my-app/dev/MY_SECRET',
226
+ })
227
+ .rejects(new Error('boom'));
228
+
229
+ await expect(
230
+ pushOffloadedParameters(appDefinition, 'dev')
231
+ ).rejects.toThrow(/MY_SECRET/);
232
+ });
233
+
234
+ it('retries a throttled PutParameter and succeeds', async () => {
235
+ const throttle = Object.assign(new Error('Rate exceeded'), {
236
+ name: 'ThrottlingException',
237
+ });
238
+ ssmMock
239
+ .on(PutParameterCommand, {
240
+ Name: '/frigg/my-app/dev/MY_SECRET',
241
+ })
242
+ .rejectsOnce(throttle)
243
+ .resolves({ Version: 2 });
244
+
245
+ const result = await pushOffloadedParameters(appDefinition, 'dev');
246
+
247
+ expect(result.pushed).toHaveLength(2);
248
+ const secretCalls = ssmMock
249
+ .commandCalls(PutParameterCommand)
250
+ .filter(
251
+ (c) => c.args[0].input.Name === '/frigg/my-app/dev/MY_SECRET'
252
+ );
253
+ expect(secretCalls).toHaveLength(2);
254
+ });
255
+
256
+ it('exposes keys pushed so far on a mid-batch failure', async () => {
257
+ // MY_CONFIG (sorted before MY_SECRET) succeeds; MY_SECRET fails.
258
+ ssmMock
259
+ .on(PutParameterCommand, {
260
+ Name: '/frigg/my-app/dev/MY_CONFIG',
261
+ })
262
+ .resolves({ Version: 1 });
263
+ ssmMock
264
+ .on(PutParameterCommand, {
265
+ Name: '/frigg/my-app/dev/MY_SECRET',
266
+ })
267
+ .rejects(new Error('boom'));
268
+
269
+ let caught;
270
+ try {
271
+ await pushOffloadedParameters(appDefinition, 'dev');
272
+ } catch (error) {
273
+ caught = error;
274
+ }
275
+
276
+ expect(caught).toBeDefined();
277
+ expect(caught.pushed).toEqual([
278
+ { name: '/frigg/my-app/dev/MY_CONFIG', version: 1 },
279
+ ]);
280
+ });
281
+
282
+ it('honors a custom parameterPrefix', async () => {
283
+ const custom = {
284
+ ...appDefinition,
285
+ ssm: { ...appDefinition.ssm, parameterPrefix: '/team/x' },
286
+ };
287
+ await pushOffloadedParameters(custom, 'dev');
288
+
289
+ const names = ssmMock
290
+ .commandCalls(PutParameterCommand)
291
+ .map((c) => c.args[0].input.Name);
292
+ expect(names).toEqual(
293
+ expect.arrayContaining([
294
+ '/team/x/MY_SECRET',
295
+ '/team/x/MY_CONFIG',
296
+ ])
297
+ );
298
+ });
299
+
300
+ describe('resolvePushRegion', () => {
301
+ it('prefers an explicit --region option over the environment', () => {
302
+ process.env.AWS_REGION = 'us-west-2';
303
+ expect(resolvePushRegion({ region: 'eu-central-1' })).toBe(
304
+ 'eu-central-1'
305
+ );
306
+ });
307
+
308
+ it('falls back to AWS_REGION when no option is given', () => {
309
+ process.env.AWS_REGION = 'us-west-2';
310
+ expect(resolvePushRegion({})).toBe('us-west-2');
311
+ });
312
+
313
+ it('defaults to us-east-1 when neither option nor AWS_REGION is set', () => {
314
+ delete process.env.AWS_REGION;
315
+ expect(resolvePushRegion({})).toBe('us-east-1');
316
+ });
317
+ });
318
+ });
@@ -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
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The SSM INIT preload (NODE_OPTIONS=--import) must be scoped to skipEsbuild
3
+ * functions only. esbuild-bundled functions (e.g. defaultWebsocket) do not
4
+ * package the preload .mjs, and a missing --import target fatally aborts Node
5
+ * startup — so a global NODE_OPTIONS would brick them (ADR-027 review finding).
6
+ */
7
+
8
+ const { composeServerlessDefinition } = require('../infrastructure-composer');
9
+
10
+ jest.mock('../domains/shared/resource-discovery', () => {
11
+ const original = jest.requireActual('../domains/shared/resource-discovery');
12
+ return {
13
+ ...original,
14
+ gatherDiscoveredResources: jest.fn().mockResolvedValue({
15
+ defaultVpcId: 'vpc-123456',
16
+ defaultSecurityGroupId: 'sg-123456',
17
+ privateSubnetId1: 'subnet-1',
18
+ privateSubnetId2: 'subnet-2',
19
+ defaultKmsKeyId:
20
+ 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
21
+ auroraClusterEndpoint: 'c.cluster-x.us-east-1.rds.amazonaws.com',
22
+ auroraPort: 5432,
23
+ }),
24
+ };
25
+ });
26
+
27
+ const buildApp = (overrides = {}) => ({
28
+ name: 'preload-test',
29
+ provider: 'aws',
30
+ usePrismaLambdaLayer: false,
31
+ encryption: { fieldLevelEncryptionMethod: 'aes' },
32
+ vpc: { enable: false },
33
+ database: { postgres: { enable: true } },
34
+ websockets: { enable: true },
35
+ integrations: [{ Definition: { name: 'hubspot' } }],
36
+ ...overrides,
37
+ });
38
+
39
+ const IMPORT_FRAGMENT = '--import file:///var/task/node_modules/@friggframework/core/core/ssm-preload.mjs';
40
+
41
+ describe('SSM preload NODE_OPTIONS scoping', () => {
42
+ beforeEach(() => {
43
+ process.argv = ['node', 'test'];
44
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
45
+ });
46
+
47
+ it('sets NODE_OPTIONS --import on skipEsbuild functions and NOT on esbuild-bundled ones when offload is active', async () => {
48
+ const definition = await composeServerlessDefinition(
49
+ buildApp({
50
+ ssm: { enable: true },
51
+ environment: { HUBSPOT_SECRET: 'ssm' },
52
+ })
53
+ );
54
+ const fns = definition.functions;
55
+
56
+ // skipEsbuild handlers (auth/user/health + integration) package the
57
+ // preload → they get the flag.
58
+ expect(fns.auth.skipEsbuild).toBe(true);
59
+ expect(fns.auth.environment.NODE_OPTIONS).toBe(IMPORT_FRAGMENT);
60
+ expect(fns.hubspot.environment.NODE_OPTIONS).toBe(IMPORT_FRAGMENT);
61
+
62
+ // defaultWebsocket is esbuild-bundled (no skipEsbuild) → MUST NOT get it.
63
+ expect(fns.defaultWebsocket.skipEsbuild).toBeFalsy();
64
+ expect(fns.defaultWebsocket.environment?.NODE_OPTIONS).toBeUndefined();
65
+
66
+ // Never global.
67
+ expect(definition.provider.environment.NODE_OPTIONS).toBeUndefined();
68
+ });
69
+
70
+ it('sets no NODE_OPTIONS anywhere when offload is inactive', async () => {
71
+ const definition = await composeServerlessDefinition(
72
+ buildApp({ ssm: { enable: true } }) // enabled, but no 'ssm' keys
73
+ );
74
+ for (const fn of Object.values(definition.functions)) {
75
+ expect(fn.environment?.NODE_OPTIONS).toBeUndefined();
76
+ }
77
+ expect(definition.provider.environment.NODE_OPTIONS).toBeUndefined();
78
+ });
79
+ });
@@ -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
  });