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

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 +88 -15
  36. package/infrastructure/domains/shared/environment-builder.test.js +304 -22
  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
@@ -1,20 +1,60 @@
1
1
  /**
2
2
  * Environment Builder Service
3
- *
3
+ *
4
4
  * Domain Service - Hexagonal Architecture
5
- *
5
+ *
6
6
  * Builds Lambda environment variable configuration from:
7
7
  * 1. AppDefinition environment flags
8
8
  * 2. Discovered AWS resources (VPC IDs, KMS keys, etc.)
9
9
  * 3. Generated resource references
10
10
  */
11
11
 
12
+ const {
13
+ isSsmOffloadActive,
14
+ getOffloadedKeys,
15
+ } = require('../parameters/offload-utils');
16
+
17
+ // OTLP-family exporters read their endpoint/headers from these standard env
18
+ // vars (ADR-011). When such an exporter is configured we auto-register them as
19
+ // Serverless passthroughs so the deployed Lambda inherits them from the deploy
20
+ // environment — no need for the adopter to also list them under `environment`.
21
+ //
22
+ // NOTE (VPC egress): a Lambda in a private subnet needs a NAT gateway or a VPC
23
+ // endpoint to reach an external OTLP backend (Honeycomb/Datadog). Without egress
24
+ // the exporter fails silently within its flush timeout — see the deploy docs.
25
+ const OTLP_EXPORTER_TYPES = new Set(['otlp', 'honeycomb', 'datadog']);
26
+ const OTEL_PASSTHROUGH_VARS = [
27
+ 'OTEL_EXPORTER_OTLP_ENDPOINT',
28
+ 'OTEL_EXPORTER_OTLP_HEADERS',
29
+ ];
30
+
31
+ function addTelemetryEnvPassthrough(appDefinition, envVars) {
32
+ const exporterType = appDefinition?.telemetry?.exporter?.type;
33
+ if (!OTLP_EXPORTER_TYPES.has(exporterType)) return;
34
+
35
+ // Skip offloaded keys here: baking '${env:KEY, ''}' resolves to '' at
36
+ // deploy (the value lives only in SSM), and '' !== undefined then blocks
37
+ // the SSM loader from ever fetching the real value.
38
+ const offloadedKeys = isSsmOffloadActive(appDefinition)
39
+ ? new Set(getOffloadedKeys(appDefinition))
40
+ : null;
41
+ for (const key of OTEL_PASSTHROUGH_VARS) {
42
+ if (offloadedKeys?.has(key)) continue;
43
+ envVars[key] = `\${env:${key}, ''}`;
44
+ }
45
+ }
46
+
12
47
  /**
13
48
  * Get environment variables from AppDefinition
14
- *
49
+ *
15
50
  * Extracts environment variable definitions where value is true,
16
51
  * and creates Serverless variable references.
17
- *
52
+ *
53
+ * A value of 'ssm' offloads the variable to Parameter Store when offload is
54
+ * active (see SsmBuilder), keeping it out of the Lambda env map. When offload
55
+ * is not active (local mode / ssm disabled) it falls back to the same
56
+ * `${env:KEY, ''}` reference as `true` so `frigg start` + dotenv keeps working.
57
+ *
18
58
  * @param {Object} appDefinition - Application definition
19
59
  * @returns {Object} Environment variable mappings
20
60
  */
@@ -38,16 +78,40 @@ function getAppEnvironmentVars(appDefinition) {
38
78
  'AWS_SESSION_TOKEN',
39
79
  ]);
40
80
 
41
- if (!appDefinition.environment) {
42
- return envVars;
43
- }
81
+ addTelemetryEnvPassthrough(appDefinition, envVars);
82
+
83
+ const environment = appDefinition.environment || {};
44
84
 
45
85
  console.log('📋 Loading environment variables from appDefinition...');
46
86
  const envKeys = [];
47
87
  const skippedKeys = [];
88
+ const offloadedKeys = [];
89
+ const offloadActive = isSsmOffloadActive(appDefinition);
90
+
91
+ for (const [key, value] of Object.entries(environment)) {
92
+ if (value === 'ssm' && offloadActive) {
93
+ offloadedKeys.push(key);
94
+ continue;
95
+ }
96
+ if (value !== true && value !== 'ssm') continue;
97
+ if (reservedVars.has(key)) {
98
+ skippedKeys.push(key);
99
+ continue;
100
+ }
101
+ envVars[key] = `\${env:${key}, ''}`;
102
+ envKeys.push(key);
103
+ }
48
104
 
49
- for (const [key, value] of Object.entries(appDefinition.environment)) {
50
- if (value !== true) continue;
105
+ // Keys declared only in ssm.parameters (no matching `environment` entry)
106
+ // get the same local-fallback treatment as `environment`-valued 'ssm' keys.
107
+ const ssmOnlyKeys = getOffloadedKeys(appDefinition).filter(
108
+ (key) => !(key in environment)
109
+ );
110
+ for (const key of ssmOnlyKeys) {
111
+ if (offloadActive) {
112
+ offloadedKeys.push(key);
113
+ continue;
114
+ }
51
115
  if (reservedVars.has(key)) {
52
116
  skippedKeys.push(key);
53
117
  continue;
@@ -65,19 +129,27 @@ function getAppEnvironmentVars(appDefinition) {
65
129
  }
66
130
  if (skippedKeys.length > 0) {
67
131
  console.log(
68
- ` ⚠️ Skipped ${skippedKeys.length
132
+ ` ⚠️ Skipped ${
133
+ skippedKeys.length
69
134
  } reserved AWS Lambda variables: ${skippedKeys.join(', ')}`
70
135
  );
71
136
  }
137
+ if (offloadedKeys.length > 0) {
138
+ console.log(
139
+ ` 🔒 Offloaded ${
140
+ offloadedKeys.length
141
+ } variables to SSM: ${offloadedKeys.join(', ')}`
142
+ );
143
+ }
72
144
 
73
145
  return envVars;
74
146
  }
75
147
 
76
148
  /**
77
149
  * Build complete environment configuration for Lambda functions
78
- *
150
+ *
79
151
  * Combines app environment vars with discovered AWS resource references
80
- *
152
+ *
81
153
  * @param {Object} appEnvironmentVars - Environment vars from AppDefinition
82
154
  * @param {Object} discoveredResources - Discovered AWS resources
83
155
  * @returns {Object} Complete environment configuration
@@ -85,7 +157,7 @@ function getAppEnvironmentVars(appDefinition) {
85
157
  function buildEnvironment(appEnvironmentVars, discoveredResources) {
86
158
  const environment = {
87
159
  ...appEnvironmentVars,
88
- STAGE: '${self:provider.stage}', // Used by encryption bypass logic
160
+ STAGE: '${self:provider.stage}', // Used by encryption bypass logic
89
161
  FRIGG_STACK: '${self:service}',
90
162
  FRIGG_STAGE: '${self:provider.stage}',
91
163
  FRIGG_REGION: '${self:provider.region}',
@@ -101,7 +173,9 @@ function buildEnvironment(appEnvironmentVars, discoveredResources) {
101
173
  // Add database connection info if discovered
102
174
  if (discoveredResources.auroraClusterEndpoint) {
103
175
  environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
104
- environment.DATABASE_PORT = String(discoveredResources.auroraPort || 5432);
176
+ environment.DATABASE_PORT = String(
177
+ discoveredResources.auroraPort || 5432
178
+ );
105
179
  }
106
180
 
107
181
  // Add secrets manager secret ARN if discovered
@@ -116,4 +190,3 @@ module.exports = {
116
190
  getAppEnvironmentVars,
117
191
  buildEnvironment,
118
192
  };
119
-
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Tests for Environment Builder Service
3
- *
3
+ *
4
4
  * Tests environment variable extraction and building
5
5
  */
6
6
 
7
- const { getAppEnvironmentVars, buildEnvironment } = require('./environment-builder');
7
+ const {
8
+ getAppEnvironmentVars,
9
+ buildEnvironment,
10
+ } = require('./environment-builder');
8
11
 
9
12
  describe('Environment Builder', () => {
10
13
  describe('getAppEnvironmentVars()', () => {
@@ -38,7 +41,7 @@ describe('Environment Builder', () => {
38
41
  expect(result.DISABLED_VAR).toBeUndefined();
39
42
  });
40
43
 
41
- it('should ignore environment variables with non-boolean values', () => {
44
+ it("ignores non-boolean values other than the meaningful 'ssm' marker", () => {
42
45
  const appDefinition = {
43
46
  environment: {
44
47
  VALID: true,
@@ -108,6 +111,244 @@ describe('Environment Builder', () => {
108
111
  });
109
112
  });
110
113
 
114
+ describe('telemetry env passthrough (ADR-011)', () => {
115
+ it('auto-adds OTLP env passthroughs when an OTLP-family exporter is configured', () => {
116
+ const result = getAppEnvironmentVars({
117
+ telemetry: { exporter: { type: 'otlp' } },
118
+ });
119
+
120
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
121
+ "${env:OTEL_EXPORTER_OTLP_ENDPOINT, ''}"
122
+ );
123
+ expect(result.OTEL_EXPORTER_OTLP_HEADERS).toBe(
124
+ "${env:OTEL_EXPORTER_OTLP_HEADERS, ''}"
125
+ );
126
+ });
127
+
128
+ it.each(['honeycomb', 'datadog'])(
129
+ 'adds OTLP passthroughs for the "%s" preset',
130
+ (type) => {
131
+ const result = getAppEnvironmentVars({
132
+ telemetry: { exporter: { type } },
133
+ });
134
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBeDefined();
135
+ }
136
+ );
137
+
138
+ it('adds no OTLP env vars for console/none exporters or absent telemetry', () => {
139
+ expect(
140
+ getAppEnvironmentVars({
141
+ telemetry: { exporter: { type: 'console' } },
142
+ }).OTEL_EXPORTER_OTLP_ENDPOINT
143
+ ).toBeUndefined();
144
+ expect(
145
+ getAppEnvironmentVars({}).OTEL_EXPORTER_OTLP_ENDPOINT
146
+ ).toBeUndefined();
147
+ });
148
+ });
149
+
150
+ describe("getAppEnvironmentVars() - 'ssm' offload", () => {
151
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
152
+
153
+ afterEach(() => {
154
+ if (originalSkipDiscovery === undefined) {
155
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
156
+ } else {
157
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
158
+ }
159
+ });
160
+
161
+ it("excludes 'ssm' keys when offload is active", () => {
162
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
163
+ const appDefinition = {
164
+ ssm: { enable: true },
165
+ environment: { FOO: 'ssm', BAR: true },
166
+ };
167
+
168
+ const result = getAppEnvironmentVars(appDefinition);
169
+
170
+ expect(result.FOO).toBeUndefined();
171
+ expect(result.BAR).toBe("${env:BAR, ''}");
172
+ });
173
+
174
+ it("falls back to env reference for 'ssm' keys when FRIGG_SKIP_AWS_DISCOVERY is set", () => {
175
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
176
+ const appDefinition = {
177
+ ssm: { enable: true },
178
+ environment: { FOO: 'ssm' },
179
+ };
180
+
181
+ const result = getAppEnvironmentVars(appDefinition);
182
+
183
+ expect(result.FOO).toBe("${env:FOO, ''}");
184
+ });
185
+
186
+ it("falls back to env reference for 'ssm' keys when ssm is disabled", () => {
187
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
188
+ const appDefinition = {
189
+ ssm: { enable: false },
190
+ environment: { FOO: 'ssm' },
191
+ };
192
+
193
+ const result = getAppEnvironmentVars(appDefinition);
194
+
195
+ expect(result.FOO).toBe("${env:FOO, ''}");
196
+ });
197
+
198
+ it('falls back to env reference for a key declared only in ssm.parameters when FRIGG_SKIP_AWS_DISCOVERY is set', () => {
199
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
200
+ const appDefinition = {
201
+ ssm: {
202
+ enable: true,
203
+ parameters: {
204
+ HUBSPOT_CLIENT_SECRET: { type: 'SecureString' },
205
+ },
206
+ },
207
+ };
208
+
209
+ const result = getAppEnvironmentVars(appDefinition);
210
+
211
+ expect(result.HUBSPOT_CLIENT_SECRET).toBe(
212
+ "${env:HUBSPOT_CLIENT_SECRET, ''}"
213
+ );
214
+ });
215
+
216
+ it('excludes a key declared only in ssm.parameters when offload is active', () => {
217
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
218
+ const appDefinition = {
219
+ ssm: {
220
+ enable: true,
221
+ parameters: {
222
+ HUBSPOT_CLIENT_SECRET: { type: 'SecureString' },
223
+ },
224
+ },
225
+ };
226
+
227
+ const result = getAppEnvironmentVars(appDefinition);
228
+
229
+ expect(result.HUBSPOT_CLIENT_SECRET).toBeUndefined();
230
+ });
231
+
232
+ it("does not double-process a key present in both environment:'ssm' and ssm.parameters", () => {
233
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
234
+ const appDefinition = {
235
+ ssm: {
236
+ enable: true,
237
+ parameters: { FOO: { type: 'SecureString' } },
238
+ },
239
+ environment: { FOO: 'ssm' },
240
+ };
241
+
242
+ const result = getAppEnvironmentVars(appDefinition);
243
+
244
+ expect(result.FOO).toBeUndefined();
245
+
246
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
247
+ const fallbackResult = getAppEnvironmentVars(appDefinition);
248
+ expect(fallbackResult.FOO).toBe("${env:FOO, ''}");
249
+ });
250
+ });
251
+
252
+ describe('telemetry env passthrough + SSM offload interaction', () => {
253
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
254
+
255
+ afterEach(() => {
256
+ if (originalSkipDiscovery === undefined) {
257
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
258
+ } else {
259
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
260
+ }
261
+ });
262
+
263
+ it("does not bake an OTEL passthrough var that is marked 'ssm' when offload is active", () => {
264
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
265
+ const appDefinition = {
266
+ telemetry: { exporter: { type: 'datadog' } },
267
+ ssm: { enable: true },
268
+ environment: {
269
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'ssm',
270
+ OTEL_EXPORTER_OTLP_HEADERS: 'ssm',
271
+ },
272
+ };
273
+
274
+ const result = getAppEnvironmentVars(appDefinition);
275
+
276
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined();
277
+ expect(result.OTEL_EXPORTER_OTLP_HEADERS).toBeUndefined();
278
+ });
279
+
280
+ it("keeps the direct passthrough for an OTEL var not marked 'ssm' while offloading its sibling", () => {
281
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
282
+ const appDefinition = {
283
+ telemetry: { exporter: { type: 'otlp' } },
284
+ ssm: { enable: true },
285
+ environment: {
286
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'ssm',
287
+ },
288
+ };
289
+
290
+ const result = getAppEnvironmentVars(appDefinition);
291
+
292
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined();
293
+ expect(result.OTEL_EXPORTER_OTLP_HEADERS).toBe(
294
+ "${env:OTEL_EXPORTER_OTLP_HEADERS, ''}"
295
+ );
296
+ });
297
+
298
+ it("falls back to the env passthrough for an 'ssm'-marked OTEL var in local mode", () => {
299
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
300
+ const appDefinition = {
301
+ telemetry: { exporter: { type: 'datadog' } },
302
+ ssm: { enable: true },
303
+ environment: {
304
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'ssm',
305
+ },
306
+ };
307
+
308
+ const result = getAppEnvironmentVars(appDefinition);
309
+
310
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
311
+ "${env:OTEL_EXPORTER_OTLP_ENDPOINT, ''}"
312
+ );
313
+ });
314
+
315
+ it("leaves a 'true'-marked OTEL var as a direct passthrough even when offload is active for another key", () => {
316
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
317
+ const appDefinition = {
318
+ telemetry: { exporter: { type: 'datadog' } },
319
+ ssm: { enable: true },
320
+ environment: {
321
+ OTEL_EXPORTER_OTLP_ENDPOINT: true,
322
+ SOME_OFFLOADED_SECRET: 'ssm',
323
+ },
324
+ };
325
+
326
+ const result = getAppEnvironmentVars(appDefinition);
327
+
328
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
329
+ "${env:OTEL_EXPORTER_OTLP_ENDPOINT, ''}"
330
+ );
331
+ expect(result.SOME_OFFLOADED_SECRET).toBeUndefined();
332
+ });
333
+
334
+ it('does not bake an OTEL passthrough var offloaded only via ssm.parameters', () => {
335
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
336
+ const appDefinition = {
337
+ telemetry: { exporter: { type: 'datadog' } },
338
+ ssm: {
339
+ enable: true,
340
+ parameters: {
341
+ OTEL_EXPORTER_OTLP_ENDPOINT: { type: 'SecureString' },
342
+ },
343
+ },
344
+ };
345
+
346
+ const result = getAppEnvironmentVars(appDefinition);
347
+
348
+ expect(result.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined();
349
+ });
350
+ });
351
+
111
352
  describe('buildEnvironment()', () => {
112
353
  it('should combine app vars with standard Frigg variables', () => {
113
354
  const appEnvironmentVars = {
@@ -115,7 +356,10 @@ describe('Environment Builder', () => {
115
356
  };
116
357
  const discoveredResources = {};
117
358
 
118
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
359
+ const result = buildEnvironment(
360
+ appEnvironmentVars,
361
+ discoveredResources
362
+ );
119
363
 
120
364
  expect(result.API_KEY).toBe("${env:API_KEY, ''}");
121
365
  expect(result.STAGE).toBe('${self:provider.stage}');
@@ -130,9 +374,14 @@ describe('Environment Builder', () => {
130
374
  kmsKeyId: 'arn:aws:kms:us-east-1:123456:key/abc-123',
131
375
  };
132
376
 
133
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
377
+ const result = buildEnvironment(
378
+ appEnvironmentVars,
379
+ discoveredResources
380
+ );
134
381
 
135
- expect(result.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456:key/abc-123');
382
+ expect(result.KMS_KEY_ARN).toBe(
383
+ 'arn:aws:kms:us-east-1:123456:key/abc-123'
384
+ );
136
385
  });
137
386
 
138
387
  it('should prefer kmsKeyId over kmsKeyArn if both present', () => {
@@ -142,22 +391,33 @@ describe('Environment Builder', () => {
142
391
  kmsKeyArn: 'arn:aws:kms:us-east-1:123456:key/secondary',
143
392
  };
144
393
 
145
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
394
+ const result = buildEnvironment(
395
+ appEnvironmentVars,
396
+ discoveredResources
397
+ );
146
398
 
147
399
  // Implementation uses if/else-if, so kmsKeyId takes priority
148
- expect(result.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456:key/primary');
400
+ expect(result.KMS_KEY_ARN).toBe(
401
+ 'arn:aws:kms:us-east-1:123456:key/primary'
402
+ );
149
403
  });
150
404
 
151
405
  it('should add database connection info if discovered', () => {
152
406
  const appEnvironmentVars = {};
153
407
  const discoveredResources = {
154
- auroraClusterEndpoint: 'cluster.abc.us-east-1.rds.amazonaws.com',
408
+ auroraClusterEndpoint:
409
+ 'cluster.abc.us-east-1.rds.amazonaws.com',
155
410
  auroraPort: 5432,
156
411
  };
157
412
 
158
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
413
+ const result = buildEnvironment(
414
+ appEnvironmentVars,
415
+ discoveredResources
416
+ );
159
417
 
160
- expect(result.DATABASE_HOST).toBe('cluster.abc.us-east-1.rds.amazonaws.com');
418
+ expect(result.DATABASE_HOST).toBe(
419
+ 'cluster.abc.us-east-1.rds.amazonaws.com'
420
+ );
161
421
  expect(result.DATABASE_PORT).toBe('5432');
162
422
  });
163
423
 
@@ -167,7 +427,10 @@ describe('Environment Builder', () => {
167
427
  auroraClusterEndpoint: 'cluster.example.com',
168
428
  };
169
429
 
170
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
430
+ const result = buildEnvironment(
431
+ appEnvironmentVars,
432
+ discoveredResources
433
+ );
171
434
 
172
435
  expect(result.DATABASE_HOST).toBe('cluster.example.com');
173
436
  expect(result.DATABASE_PORT).toBe('5432');
@@ -176,12 +439,18 @@ describe('Environment Builder', () => {
176
439
  it('should add database secret ARN if discovered', () => {
177
440
  const appEnvironmentVars = {};
178
441
  const discoveredResources = {
179
- databaseSecretArn: 'arn:aws:secretsmanager:us-east-1:123456:secret:db-secret',
442
+ databaseSecretArn:
443
+ 'arn:aws:secretsmanager:us-east-1:123456:secret:db-secret',
180
444
  };
181
445
 
182
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
446
+ const result = buildEnvironment(
447
+ appEnvironmentVars,
448
+ discoveredResources
449
+ );
183
450
 
184
- expect(result.DATABASE_SECRET_ARN).toBe('arn:aws:secretsmanager:us-east-1:123456:secret:db-secret');
451
+ expect(result.DATABASE_SECRET_ARN).toBe(
452
+ 'arn:aws:secretsmanager:us-east-1:123456:secret:db-secret'
453
+ );
185
454
  });
186
455
 
187
456
  it('should combine all discovered resources', () => {
@@ -192,19 +461,27 @@ describe('Environment Builder', () => {
192
461
  kmsKeyArn: 'arn:aws:kms:us-east-1:123456:key/abc',
193
462
  auroraClusterEndpoint: 'db.example.com',
194
463
  auroraPort: 3306,
195
- databaseSecretArn: 'arn:aws:secretsmanager:us-east-1:123456:secret:db',
464
+ databaseSecretArn:
465
+ 'arn:aws:secretsmanager:us-east-1:123456:secret:db',
196
466
  };
197
467
 
198
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
468
+ const result = buildEnvironment(
469
+ appEnvironmentVars,
470
+ discoveredResources
471
+ );
199
472
 
200
473
  expect(result.CUSTOM_VAR).toBe("${env:CUSTOM_VAR, ''}");
201
474
  expect(result.FRIGG_STACK).toBe('${self:service}');
202
475
  expect(result.FRIGG_STAGE).toBe('${self:provider.stage}');
203
476
  expect(result.FRIGG_REGION).toBe('${self:provider.region}');
204
- expect(result.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456:key/abc');
477
+ expect(result.KMS_KEY_ARN).toBe(
478
+ 'arn:aws:kms:us-east-1:123456:key/abc'
479
+ );
205
480
  expect(result.DATABASE_HOST).toBe('db.example.com');
206
481
  expect(result.DATABASE_PORT).toBe('3306');
207
- expect(result.DATABASE_SECRET_ARN).toBe('arn:aws:secretsmanager:us-east-1:123456:secret:db');
482
+ expect(result.DATABASE_SECRET_ARN).toBe(
483
+ 'arn:aws:secretsmanager:us-east-1:123456:secret:db'
484
+ );
208
485
  });
209
486
 
210
487
  it('should handle empty discoveredResources', () => {
@@ -213,7 +490,10 @@ describe('Environment Builder', () => {
213
490
  };
214
491
  const discoveredResources = {};
215
492
 
216
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
493
+ const result = buildEnvironment(
494
+ appEnvironmentVars,
495
+ discoveredResources
496
+ );
217
497
 
218
498
  expect(result.API_KEY).toBe("${env:API_KEY, ''}");
219
499
  expect(result.FRIGG_STACK).toBe('${self:service}');
@@ -237,11 +517,13 @@ describe('Environment Builder', () => {
237
517
  auroraPort: 3306, // Number
238
518
  };
239
519
 
240
- const result = buildEnvironment(appEnvironmentVars, discoveredResources);
520
+ const result = buildEnvironment(
521
+ appEnvironmentVars,
522
+ discoveredResources
523
+ );
241
524
 
242
525
  expect(result.DATABASE_PORT).toBe('3306'); // String
243
526
  expect(typeof result.DATABASE_PORT).toBe('string');
244
527
  });
245
528
  });
246
529
  });
247
-
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Per-function environment scoping (ADR-027)
3
+ *
4
+ * Builders emit `result.functionEnvironments` — a map of function name to
5
+ * env vars — instead of broadcasting framework vars app-wide through
6
+ * `result.environment`. The composer applies the merged map onto the final
7
+ * function definitions after every function (base + builder) exists.
8
+ */
9
+
10
+ /**
11
+ * Whether builders should scope framework env vars per function. Skipped in
12
+ * local mode: the serverless-plugin injects LocalStack queue URLs at
13
+ * provider level only, and function-level values would shadow them.
14
+ *
15
+ * @param {Object} appDefinition
16
+ * @returns {boolean}
17
+ */
18
+ function isScopedEnvironmentActive(appDefinition = {}) {
19
+ if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') return false;
20
+ return appDefinition.lambda?.scopedEnvironment === true;
21
+ }
22
+
23
+ /**
24
+ * Apply a merged functionEnvironments map onto the composed functions.
25
+ * A key a builder already set directly on a function wins (e.g. the
26
+ * admin-script router's own SCHEDULER_ROLE_ARN must not be clobbered by
27
+ * the integration-scheduler value). An unknown function name is a hard
28
+ * error — silently dropping a var would surface as a runtime failure.
29
+ *
30
+ * @param {Object} functions - definition.functions (mutated)
31
+ * @param {Object} functionEnvironments - { fnName: { KEY: value } }
32
+ */
33
+ function applyFunctionEnvironments(functions, functionEnvironments = {}) {
34
+ for (const [fnName, env] of Object.entries(functionEnvironments)) {
35
+ const fn = functions[fnName];
36
+ if (!fn) {
37
+ throw new Error(
38
+ `functionEnvironments targets unknown function '${fnName}' (known: ${Object.keys(
39
+ functions
40
+ ).join(', ')})`
41
+ );
42
+ }
43
+ fn.environment = { ...env, ...(fn.environment || {}) };
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Function names the integration builder creates for one integration.
49
+ * Wire contract: must stay in sync with
50
+ * IntegrationBuilder.createFunctionDefinitions — a drift here surfaces as a
51
+ * hard unknown-function error at compose time, not a silent var drop.
52
+ *
53
+ * @param {Object} integration - entry from appDefinition.integrations
54
+ * @returns {string[]}
55
+ */
56
+ function getIntegrationFunctionNames(integration) {
57
+ const name = integration.Definition.name;
58
+ const names = [name, `${name}QueueWorker`];
59
+
60
+ const webhooks = integration.Definition.webhooks;
61
+ if (webhooks === true || webhooks?.enabled === true) {
62
+ names.splice(1, 0, `${name}Webhook`);
63
+ }
64
+
65
+ for (const [bindingKey, binding] of Object.entries(
66
+ integration.Definition.extensions || {}
67
+ )) {
68
+ const routes = binding?.extension?.routes || [];
69
+ if (routes.length === 0) continue;
70
+ names.push(`${name}__${String(bindingKey).replace(/[^A-Za-z0-9]/g, '')}`);
71
+ }
72
+
73
+ return names;
74
+ }
75
+
76
+ /**
77
+ * Admin-script functions, when the feature is on (mirrors
78
+ * AdminScriptBuilder.shouldExecute). Both can instantiate arbitrary
79
+ * integrations, so they belong in every integration's queue-URL consumer
80
+ * set.
81
+ *
82
+ * @param {Object} appDefinition
83
+ * @returns {string[]}
84
+ */
85
+ function getAdminFunctionNames(appDefinition = {}) {
86
+ return Array.isArray(appDefinition.adminScripts) &&
87
+ appDefinition.adminScripts.length > 0
88
+ ? ['adminScriptRouter', 'adminScriptExecutor']
89
+ : [];
90
+ }
91
+
92
+ module.exports = {
93
+ isScopedEnvironmentActive,
94
+ applyFunctionEnvironments,
95
+ getIntegrationFunctionNames,
96
+ getAdminFunctionNames,
97
+ };