@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
@@ -13,6 +13,11 @@
13
13
  */
14
14
 
15
15
  const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
16
+ const {
17
+ isScopedEnvironmentActive,
18
+ getIntegrationFunctionNames,
19
+ getAdminFunctionNames,
20
+ } = require('../shared/function-environments');
16
21
 
17
22
  class SchedulerBuilder extends InfrastructureBuilder {
18
23
  constructor() {
@@ -70,7 +75,7 @@ class SchedulerBuilder extends InfrastructureBuilder {
70
75
  this.addSchedulerIamStatements(result);
71
76
 
72
77
  // Add environment variables
73
- this.addEnvironmentVariables(result);
78
+ this.addEnvironmentVariables(result, appDefinition);
74
79
 
75
80
  console.log(`[${this.name}] ✅ Scheduler configuration completed`);
76
81
  return result;
@@ -196,15 +201,46 @@ class SchedulerBuilder extends InfrastructureBuilder {
196
201
  /**
197
202
  * Add environment variables for scheduler configuration
198
203
  */
199
- addEnvironmentVariables(result) {
200
- result.environment.SCHEDULER_ROLE_ARN = {
201
- 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
202
- };
203
- result.environment.SCHEDULE_GROUP_NAME = {
204
- Ref: 'FriggScheduleGroup',
204
+ addEnvironmentVariables(result, appDefinition = {}) {
205
+ const environment = {
206
+ SCHEDULER_ROLE_ARN: {
207
+ 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
208
+ },
209
+ SCHEDULE_GROUP_NAME: {
210
+ Ref: 'FriggScheduleGroup',
211
+ },
205
212
  };
206
213
 
207
- console.log(' ✓ Added scheduler environment variables');
214
+ if (!isScopedEnvironmentActive(appDefinition)) {
215
+ Object.assign(result.environment, environment);
216
+ console.log(' ✓ Added scheduler environment variables');
217
+ return;
218
+ }
219
+
220
+ // Consumers: auth (runs integration actions), the executor (runs
221
+ // admin scripts that instantiate integrations), and every
222
+ // integration function. NOT adminScriptRouter — it carries its own
223
+ // admin-scheduler role, set directly by the admin-script builder.
224
+ const targets = [
225
+ 'auth',
226
+ ...getAdminFunctionNames(appDefinition).filter(
227
+ (fnName) => fnName !== 'adminScriptRouter'
228
+ ),
229
+ ...(appDefinition.integrations || []).flatMap(
230
+ getIntegrationFunctionNames
231
+ ),
232
+ ];
233
+
234
+ result.functionEnvironments = result.functionEnvironments || {};
235
+ for (const fnName of targets) {
236
+ result.functionEnvironments[fnName] = {
237
+ ...result.functionEnvironments[fnName],
238
+ ...environment,
239
+ };
240
+ }
241
+ console.log(
242
+ ` ✓ Scoped scheduler environment variables to: ${targets.join(', ')}`
243
+ );
208
244
  }
209
245
  }
210
246
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tests for Scheduler Builder
3
+ */
4
+
5
+ const { SchedulerBuilder } = require('./scheduler-builder');
6
+
7
+ describe('SchedulerBuilder', () => {
8
+ let schedulerBuilder;
9
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
10
+
11
+ beforeEach(() => {
12
+ schedulerBuilder = new SchedulerBuilder();
13
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (originalSkipDiscovery === undefined) {
18
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
19
+ } else {
20
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
21
+ }
22
+ });
23
+
24
+ describe('shouldExecute()', () => {
25
+ it('runs when explicitly enabled', () => {
26
+ expect(
27
+ schedulerBuilder.shouldExecute({ scheduler: { enable: true } })
28
+ ).toBe(true);
29
+ });
30
+
31
+ it('runs when any integration has webhooks', () => {
32
+ expect(
33
+ schedulerBuilder.shouldExecute({
34
+ integrations: [
35
+ { Definition: { name: 'a', webhooks: true } },
36
+ ],
37
+ })
38
+ ).toBe(true);
39
+ });
40
+
41
+ it('skips otherwise', () => {
42
+ expect(
43
+ schedulerBuilder.shouldExecute({
44
+ integrations: [{ Definition: { name: 'a' } }],
45
+ })
46
+ ).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('build() environment', () => {
51
+ const scheduledApp = (extra = {}) => ({
52
+ scheduler: { enable: true },
53
+ integrations: [
54
+ { Definition: { name: 'hubspot', webhooks: true } },
55
+ { Definition: { name: 'slack' } },
56
+ ],
57
+ ...extra,
58
+ });
59
+
60
+ it('broadcasts scheduler vars app-wide by default', async () => {
61
+ const result = await schedulerBuilder.build(scheduledApp(), {});
62
+
63
+ expect(result.environment.SCHEDULER_ROLE_ARN).toEqual({
64
+ 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
65
+ });
66
+ expect(result.environment.SCHEDULE_GROUP_NAME).toEqual({
67
+ Ref: 'FriggScheduleGroup',
68
+ });
69
+ expect(result.functionEnvironments).toBeUndefined();
70
+ });
71
+
72
+ it('scopes scheduler vars to auth, adminScriptExecutor, and integration functions with lambda.scopedEnvironment', async () => {
73
+ const result = await schedulerBuilder.build(
74
+ scheduledApp({
75
+ lambda: { scopedEnvironment: true },
76
+ adminScripts: [{ Definition: { name: 'fix-things' } }],
77
+ }),
78
+ {}
79
+ );
80
+
81
+ expect(result.environment.SCHEDULER_ROLE_ARN).toBeUndefined();
82
+ expect(result.environment.SCHEDULE_GROUP_NAME).toBeUndefined();
83
+
84
+ const scoped = result.functionEnvironments;
85
+ for (const fnName of [
86
+ 'auth',
87
+ 'adminScriptExecutor',
88
+ 'hubspot',
89
+ 'hubspotWebhook',
90
+ 'hubspotQueueWorker',
91
+ 'slack',
92
+ 'slackQueueWorker',
93
+ ]) {
94
+ expect(scoped[fnName].SCHEDULER_ROLE_ARN).toEqual({
95
+ 'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
96
+ });
97
+ expect(scoped[fnName].SCHEDULE_GROUP_NAME).toEqual({
98
+ Ref: 'FriggScheduleGroup',
99
+ });
100
+ }
101
+
102
+ // the router keeps its own admin-scheduler role, set directly by
103
+ // the admin-script builder — never targeted here
104
+ expect(scoped.adminScriptRouter).toBeUndefined();
105
+ });
106
+
107
+ it('keeps broadcasting in local mode even with the flag on', async () => {
108
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
109
+ const result = await schedulerBuilder.build(
110
+ scheduledApp({ lambda: { scopedEnvironment: true } }),
111
+ {}
112
+ );
113
+
114
+ expect(result.environment.SCHEDULER_ROLE_ARN).toBeDefined();
115
+ expect(result.functionEnvironments).toBeUndefined();
116
+ });
117
+ });
118
+ });
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  * @param {Object} [options.features={}] - Enabled features { vpc, kms, ssm, websockets }
8
8
  * @param {string} [options.userPrefix='frigg-deployment-user'] - IAM user name prefix
9
9
  * @param {string} [options.stackName='frigg-deployment-iam'] - CloudFormation stack name
10
+ * @param {string} [options.ssmKmsKeyArn] - Customer-managed KMS key ARN for SecureString offload (appDefinition.ssm.kmsKeyArn)
10
11
  * @returns {string} CloudFormation YAML template
11
12
  */
12
13
  function generateIAMCloudFormation(options = {}) {
@@ -14,7 +15,8 @@ function generateIAMCloudFormation(options = {}) {
14
15
  appName = 'Frigg',
15
16
  features = {},
16
17
  userPrefix = 'frigg-deployment-user',
17
- stackName = 'frigg-deployment-iam'
18
+ stackName = 'frigg-deployment-iam',
19
+ ssmKmsKeyArn
18
20
  } = options;
19
21
 
20
22
  const deploymentUserName = userPrefix;
@@ -667,6 +669,9 @@ function generateIAMCloudFormation(options = {}) {
667
669
  'ssm:GetParameter',
668
670
  'ssm:GetParameters',
669
671
  'ssm:GetParametersByPath',
672
+ 'ssm:PutParameter',
673
+ 'ssm:DeleteParameter',
674
+ 'ssm:AddTagsToResource',
670
675
  ],
671
676
  Resource: [
672
677
  {
@@ -679,6 +684,32 @@ function generateIAMCloudFormation(options = {}) {
679
684
  },
680
685
  ],
681
686
  },
687
+ {
688
+ // SecureString offload: SSM performs the KMS encrypt/decrypt
689
+ // on the caller's behalf, so the grant is scoped to ViaService ssm.*
690
+ Sid: 'FriggSSMParameterKMSEncryption',
691
+ Effect: 'Allow',
692
+ Action: [
693
+ 'kms:Encrypt',
694
+ 'kms:GenerateDataKey',
695
+ 'kms:Decrypt',
696
+ ],
697
+ Resource: ssmKmsKeyArn
698
+ ? [ssmKmsKeyArn]
699
+ : [
700
+ {
701
+ 'Fn::Sub':
702
+ 'arn:aws:kms:*:${AWS::AccountId}:key/*',
703
+ },
704
+ ],
705
+ Condition: {
706
+ StringEquals: {
707
+ 'kms:ViaService': [
708
+ 'ssm.*.amazonaws.com',
709
+ ],
710
+ },
711
+ },
712
+ },
682
713
  ],
683
714
  },
684
715
  },
@@ -773,6 +804,7 @@ function getFeatureSummary(appDefinition) {
773
804
  features,
774
805
  integrationCount,
775
806
  appName: appDefinition.name || 'Unnamed Frigg App',
807
+ ssmKmsKeyArn: appDefinition.ssm?.kmsKeyArn,
776
808
  };
777
809
  }
778
810
 
@@ -38,6 +38,34 @@ describe('IAM Generator', () => {
38
38
  expect(summary.features.ssm).toBe(false);
39
39
  expect(summary.features.websockets).toBe(false);
40
40
  });
41
+
42
+ it('should surface ssm.kmsKeyArn from the app definition', () => {
43
+ const appDefinition = {
44
+ name: 'test-app',
45
+ ssm: {
46
+ enable: true,
47
+ kmsKeyArn:
48
+ 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
49
+ }
50
+ };
51
+
52
+ const summary = getFeatureSummary(appDefinition);
53
+
54
+ expect(summary.ssmKmsKeyArn).toBe(
55
+ 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
56
+ );
57
+ });
58
+
59
+ it('should leave ssmKmsKeyArn undefined when not configured', () => {
60
+ const appDefinition = {
61
+ name: 'test-app',
62
+ ssm: { enable: true }
63
+ };
64
+
65
+ const summary = getFeatureSummary(appDefinition);
66
+
67
+ expect(summary.ssmKmsKeyArn).toBeUndefined();
68
+ });
41
69
  });
42
70
 
43
71
  describe('generateIAMCloudFormation', () => {
@@ -120,6 +148,51 @@ describe('IAM Generator', () => {
120
148
  expect(yaml).toContain('EnableSSMSupport');
121
149
  });
122
150
 
151
+ it('should grant SSM-mediated KMS access via ssm.*.amazonaws.com when SSM is enabled', () => {
152
+ const appDefinition = {
153
+ name: 'test-app',
154
+ integrations: [],
155
+ ssm: { enable: true }
156
+ };
157
+
158
+ const summary = getFeatureSummary(appDefinition);
159
+ const yaml = generateIAMCloudFormation({
160
+ appName: summary.appName,
161
+ features: summary.features
162
+ });
163
+
164
+ expect(yaml).toContain('FriggSSMParameterKMSEncryption');
165
+ expect(yaml).toContain('kms:Encrypt');
166
+ expect(yaml).toContain('ssm.*.amazonaws.com');
167
+ // No customer-managed key configured: falls back to the account key wildcard
168
+ expect(yaml).toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
169
+ });
170
+
171
+ it('should scope the SSM KMS grant to ssm.kmsKeyArn when provided', () => {
172
+ const appDefinition = {
173
+ name: 'test-app',
174
+ integrations: [],
175
+ ssm: {
176
+ enable: true,
177
+ kmsKeyArn:
178
+ 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
179
+ }
180
+ };
181
+
182
+ const summary = getFeatureSummary(appDefinition);
183
+ const yaml = generateIAMCloudFormation({
184
+ appName: summary.appName,
185
+ features: summary.features,
186
+ ssmKmsKeyArn: appDefinition.ssm.kmsKeyArn
187
+ });
188
+
189
+ expect(yaml).toContain('FriggSSMParameterKMSEncryption');
190
+ expect(yaml).toContain(
191
+ 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
192
+ );
193
+ expect(yaml).not.toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
194
+ });
195
+
123
196
  it('should set correct default parameter values based on features', () => {
124
197
  const appDefinition = {
125
198
  name: 'test-app',
@@ -360,9 +360,24 @@ Resources:
360
360
  - 'ssm:GetParameter'
361
361
  - 'ssm:GetParameters'
362
362
  - 'ssm:GetParametersByPath'
363
+ - 'ssm:PutParameter'
364
+ - 'ssm:DeleteParameter'
365
+ - 'ssm:AddTagsToResource'
363
366
  Resource:
364
367
  - !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*frigg*'
365
368
  - !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*frigg*/*'
369
+ - Sid: 'FriggSSMParameterKMSEncryption'
370
+ Effect: Allow
371
+ Action:
372
+ - 'kms:Encrypt'
373
+ - 'kms:GenerateDataKey'
374
+ - 'kms:Decrypt'
375
+ Resource:
376
+ - !Sub 'arn:aws:kms:*:${AWS::AccountId}:key/*'
377
+ Condition:
378
+ StringEquals:
379
+ 'kms:ViaService':
380
+ - 'ssm.*.amazonaws.com'
366
381
 
367
382
  # Store access key in Secrets Manager
368
383
  FriggDeploymentCredentials:
@@ -256,6 +256,7 @@
256
256
  "Sid": "FriggKMSEncryptionPermissions",
257
257
  "Effect": "Allow",
258
258
  "Action": [
259
+ "kms:Encrypt",
259
260
  "kms:GenerateDataKey",
260
261
  "kms:Decrypt"
261
262
  ],
@@ -266,7 +267,8 @@
266
267
  "StringEquals": {
267
268
  "kms:ViaService": [
268
269
  "lambda.*.amazonaws.com",
269
- "s3.*.amazonaws.com"
270
+ "s3.*.amazonaws.com",
271
+ "ssm.*.amazonaws.com"
270
272
  ]
271
273
  }
272
274
  }
@@ -277,7 +279,10 @@
277
279
  "Action": [
278
280
  "ssm:GetParameter",
279
281
  "ssm:GetParameters",
280
- "ssm:GetParametersByPath"
282
+ "ssm:GetParametersByPath",
283
+ "ssm:PutParameter",
284
+ "ssm:DeleteParameter",
285
+ "ssm:AddTagsToResource"
281
286
  ],
282
287
  "Resource": [
283
288
  "arn:aws:ssm:*:*:parameter/*frigg*",
@@ -144,6 +144,7 @@ class BuilderOrchestrator {
144
144
  iamStatements: [],
145
145
  environment: {},
146
146
  functions: {},
147
+ functionEnvironments: {},
147
148
  layers: {},
148
149
  plugins: [],
149
150
  custom: {},
@@ -172,6 +173,19 @@ class BuilderOrchestrator {
172
173
  Object.assign(merged.functions, result.functions);
173
174
  }
174
175
 
176
+ // Merge function-scoped environment maps (applied by the
177
+ // composer once base + builder functions all exist)
178
+ if (result.functionEnvironments) {
179
+ for (const [fnName, env] of Object.entries(
180
+ result.functionEnvironments
181
+ )) {
182
+ merged.functionEnvironments[fnName] = {
183
+ ...merged.functionEnvironments[fnName],
184
+ ...env,
185
+ };
186
+ }
187
+ }
188
+
175
189
  // Merge layers
176
190
  if (result.layers) {
177
191
  Object.assign(merged.layers, result.layers);
@@ -208,6 +208,51 @@ describe('BuilderOrchestrator', () => {
208
208
 
209
209
  await expect(orchestrator.buildAll({})).rejects.toThrow('Build failed');
210
210
  });
211
+
212
+ it('should merge functionEnvironments per function, later builder winning on key conflict', async () => {
213
+ class ScopedEnvBuilderA extends InfrastructureBuilder {
214
+ getName() { return 'ScopedEnvBuilderA'; }
215
+ shouldExecute() { return true; }
216
+ validate() { return new ValidationResult(); }
217
+ async build() {
218
+ return {
219
+ functionEnvironments: {
220
+ auth: { QUEUE_A: 'url-a', SHARED: 'from-a' },
221
+ workerA: { QUEUE_A: 'url-a' },
222
+ },
223
+ };
224
+ }
225
+ }
226
+ class ScopedEnvBuilderB extends InfrastructureBuilder {
227
+ getName() { return 'ScopedEnvBuilderB'; }
228
+ shouldExecute() { return true; }
229
+ validate() { return new ValidationResult(); }
230
+ getDependencies() { return ['ScopedEnvBuilderA']; }
231
+ async build() {
232
+ return {
233
+ functionEnvironments: {
234
+ auth: { QUEUE_B: 'url-b', SHARED: 'from-b' },
235
+ },
236
+ };
237
+ }
238
+ }
239
+
240
+ orchestrator = new BuilderOrchestrator([
241
+ new ScopedEnvBuilderA(),
242
+ new ScopedEnvBuilderB(),
243
+ ]);
244
+
245
+ const result = await orchestrator.buildAll({});
246
+
247
+ expect(result.merged.functionEnvironments).toEqual({
248
+ auth: {
249
+ QUEUE_A: 'url-a',
250
+ QUEUE_B: 'url-b',
251
+ SHARED: 'from-b',
252
+ },
253
+ workerA: { QUEUE_A: 'url-a' },
254
+ });
255
+ });
211
256
  });
212
257
  });
213
258
 
@@ -9,12 +9,19 @@
9
9
  * 3. Generated resource references
10
10
  */
11
11
 
12
+ const { isSsmOffloadActive, getOffloadedKeys } = require('../parameters/offload-utils');
13
+
12
14
  /**
13
15
  * Get environment variables from AppDefinition
14
- *
16
+ *
15
17
  * Extracts environment variable definitions where value is true,
16
18
  * and creates Serverless variable references.
17
- *
19
+ *
20
+ * A value of 'ssm' offloads the variable to Parameter Store when offload is
21
+ * active (see SsmBuilder), keeping it out of the Lambda env map. When offload
22
+ * is not active (local mode / ssm disabled) it falls back to the same
23
+ * `${env:KEY, ''}` reference as `true` so `frigg start` + dotenv keeps working.
24
+ *
18
25
  * @param {Object} appDefinition - Application definition
19
26
  * @returns {Object} Environment variable mappings
20
27
  */
@@ -38,16 +45,38 @@ function getAppEnvironmentVars(appDefinition) {
38
45
  'AWS_SESSION_TOKEN',
39
46
  ]);
40
47
 
41
- if (!appDefinition.environment) {
42
- return envVars;
43
- }
48
+ const environment = appDefinition.environment || {};
44
49
 
45
50
  console.log('📋 Loading environment variables from appDefinition...');
46
51
  const envKeys = [];
47
52
  const skippedKeys = [];
53
+ const offloadedKeys = [];
54
+ const offloadActive = isSsmOffloadActive(appDefinition);
48
55
 
49
- for (const [key, value] of Object.entries(appDefinition.environment)) {
50
- if (value !== true) continue;
56
+ for (const [key, value] of Object.entries(environment)) {
57
+ if (value === 'ssm' && offloadActive) {
58
+ offloadedKeys.push(key);
59
+ continue;
60
+ }
61
+ if (value !== true && value !== 'ssm') continue;
62
+ if (reservedVars.has(key)) {
63
+ skippedKeys.push(key);
64
+ continue;
65
+ }
66
+ envVars[key] = `\${env:${key}, ''}`;
67
+ envKeys.push(key);
68
+ }
69
+
70
+ // Keys declared only in ssm.parameters (no matching `environment` entry)
71
+ // get the same local-fallback treatment as `environment`-valued 'ssm' keys.
72
+ const ssmOnlyKeys = getOffloadedKeys(appDefinition).filter(
73
+ (key) => !(key in environment)
74
+ );
75
+ for (const key of ssmOnlyKeys) {
76
+ if (offloadActive) {
77
+ offloadedKeys.push(key);
78
+ continue;
79
+ }
51
80
  if (reservedVars.has(key)) {
52
81
  skippedKeys.push(key);
53
82
  continue;
@@ -69,6 +98,13 @@ function getAppEnvironmentVars(appDefinition) {
69
98
  } reserved AWS Lambda variables: ${skippedKeys.join(', ')}`
70
99
  );
71
100
  }
101
+ if (offloadedKeys.length > 0) {
102
+ console.log(
103
+ ` 🔒 Offloaded ${offloadedKeys.length} variables to SSM: ${offloadedKeys.join(
104
+ ', '
105
+ )}`
106
+ );
107
+ }
72
108
 
73
109
  return envVars;
74
110
  }
@@ -38,7 +38,7 @@ describe('Environment Builder', () => {
38
38
  expect(result.DISABLED_VAR).toBeUndefined();
39
39
  });
40
40
 
41
- it('should ignore environment variables with non-boolean values', () => {
41
+ it("ignores non-boolean values other than the meaningful 'ssm' marker", () => {
42
42
  const appDefinition = {
43
43
  environment: {
44
44
  VALID: true,
@@ -108,6 +108,102 @@ describe('Environment Builder', () => {
108
108
  });
109
109
  });
110
110
 
111
+ describe("getAppEnvironmentVars() - 'ssm' offload", () => {
112
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
113
+
114
+ afterEach(() => {
115
+ if (originalSkipDiscovery === undefined) {
116
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
117
+ } else {
118
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
119
+ }
120
+ });
121
+
122
+ it("excludes 'ssm' keys when offload is active", () => {
123
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
124
+ const appDefinition = {
125
+ ssm: { enable: true },
126
+ environment: { FOO: 'ssm', BAR: true },
127
+ };
128
+
129
+ const result = getAppEnvironmentVars(appDefinition);
130
+
131
+ expect(result.FOO).toBeUndefined();
132
+ expect(result.BAR).toBe("${env:BAR, ''}");
133
+ });
134
+
135
+ it("falls back to env reference for 'ssm' keys when FRIGG_SKIP_AWS_DISCOVERY is set", () => {
136
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
137
+ const appDefinition = {
138
+ ssm: { enable: true },
139
+ environment: { FOO: 'ssm' },
140
+ };
141
+
142
+ const result = getAppEnvironmentVars(appDefinition);
143
+
144
+ expect(result.FOO).toBe("${env:FOO, ''}");
145
+ });
146
+
147
+ it("falls back to env reference for 'ssm' keys when ssm is disabled", () => {
148
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
149
+ const appDefinition = {
150
+ ssm: { enable: false },
151
+ environment: { FOO: 'ssm' },
152
+ };
153
+
154
+ const result = getAppEnvironmentVars(appDefinition);
155
+
156
+ expect(result.FOO).toBe("${env:FOO, ''}");
157
+ });
158
+
159
+ it('falls back to env reference for a key declared only in ssm.parameters when FRIGG_SKIP_AWS_DISCOVERY is set', () => {
160
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
161
+ const appDefinition = {
162
+ ssm: {
163
+ enable: true,
164
+ parameters: { HUBSPOT_CLIENT_SECRET: { type: 'SecureString' } },
165
+ },
166
+ };
167
+
168
+ const result = getAppEnvironmentVars(appDefinition);
169
+
170
+ expect(result.HUBSPOT_CLIENT_SECRET).toBe("${env:HUBSPOT_CLIENT_SECRET, ''}");
171
+ });
172
+
173
+ it('excludes a key declared only in ssm.parameters when offload is active', () => {
174
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
175
+ const appDefinition = {
176
+ ssm: {
177
+ enable: true,
178
+ parameters: { HUBSPOT_CLIENT_SECRET: { type: 'SecureString' } },
179
+ },
180
+ };
181
+
182
+ const result = getAppEnvironmentVars(appDefinition);
183
+
184
+ expect(result.HUBSPOT_CLIENT_SECRET).toBeUndefined();
185
+ });
186
+
187
+ it("does not double-process a key present in both environment:'ssm' and ssm.parameters", () => {
188
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
189
+ const appDefinition = {
190
+ ssm: {
191
+ enable: true,
192
+ parameters: { FOO: { type: 'SecureString' } },
193
+ },
194
+ environment: { FOO: 'ssm' },
195
+ };
196
+
197
+ const result = getAppEnvironmentVars(appDefinition);
198
+
199
+ expect(result.FOO).toBeUndefined();
200
+
201
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
202
+ const fallbackResult = getAppEnvironmentVars(appDefinition);
203
+ expect(fallbackResult.FOO).toBe("${env:FOO, ''}");
204
+ });
205
+ });
206
+
111
207
  describe('buildEnvironment()', () => {
112
208
  it('should combine app vars with standard Frigg variables', () => {
113
209
  const appEnvironmentVars = {