@friggframework/devtools 2.0.0--canary.622.eb44ae5.0 → 2.0.0--canary.625.27a7e7a.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 +48 -38
  33. package/infrastructure/domains/shared/environment-builder.test.js +113 -97
  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
@@ -1,13 +1,10 @@
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 {
8
- getAppEnvironmentVars,
9
- buildEnvironment,
10
- } = require('./environment-builder');
7
+ const { getAppEnvironmentVars, buildEnvironment } = require('./environment-builder');
11
8
 
12
9
  describe('Environment Builder', () => {
13
10
  describe('getAppEnvironmentVars()', () => {
@@ -41,7 +38,7 @@ describe('Environment Builder', () => {
41
38
  expect(result.DISABLED_VAR).toBeUndefined();
42
39
  });
43
40
 
44
- it('should ignore environment variables with non-boolean values', () => {
41
+ it("ignores non-boolean values other than the meaningful 'ssm' marker", () => {
45
42
  const appDefinition = {
46
43
  environment: {
47
44
  VALID: true,
@@ -111,39 +108,99 @@ describe('Environment Builder', () => {
111
108
  });
112
109
  });
113
110
 
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
- });
111
+ describe("getAppEnvironmentVars() - 'ssm' offload", () => {
112
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
127
113
 
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();
114
+ afterEach(() => {
115
+ if (originalSkipDiscovery === undefined) {
116
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
117
+ } else {
118
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
135
119
  }
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();
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, ''}");
147
204
  });
148
205
  });
149
206
 
@@ -154,10 +211,7 @@ describe('Environment Builder', () => {
154
211
  };
155
212
  const discoveredResources = {};
156
213
 
157
- const result = buildEnvironment(
158
- appEnvironmentVars,
159
- discoveredResources
160
- );
214
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
161
215
 
162
216
  expect(result.API_KEY).toBe("${env:API_KEY, ''}");
163
217
  expect(result.STAGE).toBe('${self:provider.stage}');
@@ -172,14 +226,9 @@ describe('Environment Builder', () => {
172
226
  kmsKeyId: 'arn:aws:kms:us-east-1:123456:key/abc-123',
173
227
  };
174
228
 
175
- const result = buildEnvironment(
176
- appEnvironmentVars,
177
- discoveredResources
178
- );
229
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
179
230
 
180
- expect(result.KMS_KEY_ARN).toBe(
181
- 'arn:aws:kms:us-east-1:123456:key/abc-123'
182
- );
231
+ expect(result.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456:key/abc-123');
183
232
  });
184
233
 
185
234
  it('should prefer kmsKeyId over kmsKeyArn if both present', () => {
@@ -189,33 +238,22 @@ describe('Environment Builder', () => {
189
238
  kmsKeyArn: 'arn:aws:kms:us-east-1:123456:key/secondary',
190
239
  };
191
240
 
192
- const result = buildEnvironment(
193
- appEnvironmentVars,
194
- discoveredResources
195
- );
241
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
196
242
 
197
243
  // Implementation uses if/else-if, so kmsKeyId takes priority
198
- expect(result.KMS_KEY_ARN).toBe(
199
- 'arn:aws:kms:us-east-1:123456:key/primary'
200
- );
244
+ expect(result.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456:key/primary');
201
245
  });
202
246
 
203
247
  it('should add database connection info if discovered', () => {
204
248
  const appEnvironmentVars = {};
205
249
  const discoveredResources = {
206
- auroraClusterEndpoint:
207
- 'cluster.abc.us-east-1.rds.amazonaws.com',
250
+ auroraClusterEndpoint: 'cluster.abc.us-east-1.rds.amazonaws.com',
208
251
  auroraPort: 5432,
209
252
  };
210
253
 
211
- const result = buildEnvironment(
212
- appEnvironmentVars,
213
- discoveredResources
214
- );
254
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
215
255
 
216
- expect(result.DATABASE_HOST).toBe(
217
- 'cluster.abc.us-east-1.rds.amazonaws.com'
218
- );
256
+ expect(result.DATABASE_HOST).toBe('cluster.abc.us-east-1.rds.amazonaws.com');
219
257
  expect(result.DATABASE_PORT).toBe('5432');
220
258
  });
221
259
 
@@ -225,10 +263,7 @@ describe('Environment Builder', () => {
225
263
  auroraClusterEndpoint: 'cluster.example.com',
226
264
  };
227
265
 
228
- const result = buildEnvironment(
229
- appEnvironmentVars,
230
- discoveredResources
231
- );
266
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
232
267
 
233
268
  expect(result.DATABASE_HOST).toBe('cluster.example.com');
234
269
  expect(result.DATABASE_PORT).toBe('5432');
@@ -237,18 +272,12 @@ describe('Environment Builder', () => {
237
272
  it('should add database secret ARN if discovered', () => {
238
273
  const appEnvironmentVars = {};
239
274
  const discoveredResources = {
240
- databaseSecretArn:
241
- 'arn:aws:secretsmanager:us-east-1:123456:secret:db-secret',
275
+ databaseSecretArn: 'arn:aws:secretsmanager:us-east-1:123456:secret:db-secret',
242
276
  };
243
277
 
244
- const result = buildEnvironment(
245
- appEnvironmentVars,
246
- discoveredResources
247
- );
278
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
248
279
 
249
- expect(result.DATABASE_SECRET_ARN).toBe(
250
- 'arn:aws:secretsmanager:us-east-1:123456:secret:db-secret'
251
- );
280
+ expect(result.DATABASE_SECRET_ARN).toBe('arn:aws:secretsmanager:us-east-1:123456:secret:db-secret');
252
281
  });
253
282
 
254
283
  it('should combine all discovered resources', () => {
@@ -259,27 +288,19 @@ describe('Environment Builder', () => {
259
288
  kmsKeyArn: 'arn:aws:kms:us-east-1:123456:key/abc',
260
289
  auroraClusterEndpoint: 'db.example.com',
261
290
  auroraPort: 3306,
262
- databaseSecretArn:
263
- 'arn:aws:secretsmanager:us-east-1:123456:secret:db',
291
+ databaseSecretArn: 'arn:aws:secretsmanager:us-east-1:123456:secret:db',
264
292
  };
265
293
 
266
- const result = buildEnvironment(
267
- appEnvironmentVars,
268
- discoveredResources
269
- );
294
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
270
295
 
271
296
  expect(result.CUSTOM_VAR).toBe("${env:CUSTOM_VAR, ''}");
272
297
  expect(result.FRIGG_STACK).toBe('${self:service}');
273
298
  expect(result.FRIGG_STAGE).toBe('${self:provider.stage}');
274
299
  expect(result.FRIGG_REGION).toBe('${self:provider.region}');
275
- expect(result.KMS_KEY_ARN).toBe(
276
- 'arn:aws:kms:us-east-1:123456:key/abc'
277
- );
300
+ expect(result.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456:key/abc');
278
301
  expect(result.DATABASE_HOST).toBe('db.example.com');
279
302
  expect(result.DATABASE_PORT).toBe('3306');
280
- expect(result.DATABASE_SECRET_ARN).toBe(
281
- 'arn:aws:secretsmanager:us-east-1:123456:secret:db'
282
- );
303
+ expect(result.DATABASE_SECRET_ARN).toBe('arn:aws:secretsmanager:us-east-1:123456:secret:db');
283
304
  });
284
305
 
285
306
  it('should handle empty discoveredResources', () => {
@@ -288,10 +309,7 @@ describe('Environment Builder', () => {
288
309
  };
289
310
  const discoveredResources = {};
290
311
 
291
- const result = buildEnvironment(
292
- appEnvironmentVars,
293
- discoveredResources
294
- );
312
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
295
313
 
296
314
  expect(result.API_KEY).toBe("${env:API_KEY, ''}");
297
315
  expect(result.FRIGG_STACK).toBe('${self:service}');
@@ -315,13 +333,11 @@ describe('Environment Builder', () => {
315
333
  auroraPort: 3306, // Number
316
334
  };
317
335
 
318
- const result = buildEnvironment(
319
- appEnvironmentVars,
320
- discoveredResources
321
- );
336
+ const result = buildEnvironment(appEnvironmentVars, discoveredResources);
322
337
 
323
338
  expect(result.DATABASE_PORT).toBe('3306'); // String
324
339
  expect(typeof result.DATABASE_PORT).toBe('string');
325
340
  });
326
341
  });
327
342
  });
343
+
@@ -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
+ };
@@ -0,0 +1,146 @@
1
+ const {
2
+ isScopedEnvironmentActive,
3
+ applyFunctionEnvironments,
4
+ } = require('./function-environments');
5
+
6
+ describe('function-environments', () => {
7
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
8
+
9
+ afterEach(() => {
10
+ if (originalSkipDiscovery === undefined) {
11
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
12
+ } else {
13
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
14
+ }
15
+ });
16
+
17
+ describe('isScopedEnvironmentActive', () => {
18
+ it('is true only when lambda.scopedEnvironment is set', () => {
19
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
20
+ expect(
21
+ isScopedEnvironmentActive({
22
+ lambda: { scopedEnvironment: true },
23
+ })
24
+ ).toBe(true);
25
+ expect(isScopedEnvironmentActive({})).toBe(false);
26
+ expect(
27
+ isScopedEnvironmentActive({
28
+ lambda: { scopedEnvironment: false },
29
+ })
30
+ ).toBe(false);
31
+ });
32
+
33
+ it('is false in local mode: the serverless-plugin injects LocalStack queue URLs at provider level only', () => {
34
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
35
+ expect(
36
+ isScopedEnvironmentActive({
37
+ lambda: { scopedEnvironment: true },
38
+ })
39
+ ).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('getIntegrationFunctionNames', () => {
44
+ const { getIntegrationFunctionNames } = require('./function-environments');
45
+
46
+ it('always includes the router and queue worker', () => {
47
+ expect(
48
+ getIntegrationFunctionNames({ Definition: { name: 'hubspot' } })
49
+ ).toEqual(['hubspot', 'hubspotQueueWorker']);
50
+ });
51
+
52
+ it('includes the webhook handler when webhooks are enabled', () => {
53
+ expect(
54
+ getIntegrationFunctionNames({
55
+ Definition: { name: 'hubspot', webhooks: true },
56
+ })
57
+ ).toContain('hubspotWebhook');
58
+ expect(
59
+ getIntegrationFunctionNames({
60
+ Definition: { name: 'hubspot', webhooks: { enabled: true } },
61
+ })
62
+ ).toContain('hubspotWebhook');
63
+ expect(
64
+ getIntegrationFunctionNames({
65
+ Definition: { name: 'hubspot', webhooks: { enabled: false } },
66
+ })
67
+ ).not.toContain('hubspotWebhook');
68
+ });
69
+
70
+ it('includes one function per routed extension binding, sanitized', () => {
71
+ const names = getIntegrationFunctionNames({
72
+ Definition: {
73
+ name: 'hubspot',
74
+ extensions: {
75
+ 'my-ext': { extension: { routes: [{ path: '/x', method: 'GET' }] } },
76
+ routeless: { extension: { routes: [] } },
77
+ },
78
+ },
79
+ });
80
+ expect(names).toContain('hubspot__myext');
81
+ expect(names).not.toContain('hubspot__routeless');
82
+ });
83
+ });
84
+
85
+ describe('getAdminFunctionNames', () => {
86
+ const { getAdminFunctionNames } = require('./function-environments');
87
+
88
+ it('returns the admin functions only when admin scripts are configured', () => {
89
+ expect(
90
+ getAdminFunctionNames({ adminScripts: [{ Definition: { name: 'x' } }] })
91
+ ).toEqual(['adminScriptRouter', 'adminScriptExecutor']);
92
+ expect(getAdminFunctionNames({})).toEqual([]);
93
+ expect(getAdminFunctionNames({ adminScripts: [] })).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe('applyFunctionEnvironments', () => {
98
+ const makeFunctions = () => ({
99
+ auth: { handler: 'auth.handler' },
100
+ hubspot: {
101
+ handler: 'hubspot.handler',
102
+ environment: { EXISTING: 'builder-set' },
103
+ },
104
+ });
105
+
106
+ it('assigns scoped env vars onto the target functions', () => {
107
+ const functions = makeFunctions();
108
+ applyFunctionEnvironments(functions, {
109
+ auth: { HUBSPOT_QUEUE_URL: 'url-1' },
110
+ hubspot: { HUBSPOT_QUEUE_URL: 'url-1' },
111
+ });
112
+
113
+ expect(functions.auth.environment).toEqual({
114
+ HUBSPOT_QUEUE_URL: 'url-1',
115
+ });
116
+ expect(functions.hubspot.environment).toEqual({
117
+ EXISTING: 'builder-set',
118
+ HUBSPOT_QUEUE_URL: 'url-1',
119
+ });
120
+ });
121
+
122
+ it('never clobbers a key a builder already set directly on the function', () => {
123
+ const functions = makeFunctions();
124
+ applyFunctionEnvironments(functions, {
125
+ hubspot: { EXISTING: 'scoped-value', OTHER: 'x' },
126
+ });
127
+
128
+ expect(functions.hubspot.environment.EXISTING).toBe('builder-set');
129
+ expect(functions.hubspot.environment.OTHER).toBe('x');
130
+ });
131
+
132
+ it('throws on an unknown function name instead of silently dropping vars', () => {
133
+ expect(() =>
134
+ applyFunctionEnvironments(makeFunctions(), {
135
+ typoFunction: { A: '1' },
136
+ })
137
+ ).toThrow(/typoFunction/);
138
+ });
139
+
140
+ it('is a no-op for an empty map', () => {
141
+ const functions = makeFunctions();
142
+ applyFunctionEnvironments(functions, {});
143
+ expect(functions.auth.environment).toBeUndefined();
144
+ });
145
+ });
146
+ });
@@ -20,6 +20,7 @@ const { SchedulerBuilder } = require('./domains/scheduler/scheduler-builder');
20
20
  const { AdminScriptBuilder } = require('./domains/admin-scripts/admin-script-builder');
21
21
 
22
22
  // Utilities
23
+ const { applyFunctionEnvironments } = require('./domains/shared/function-environments');
23
24
  const { modifyHandlerPaths } = require('./domains/shared/utilities/handler-path-resolver');
24
25
  const { createBaseDefinition } = require('./domains/shared/utilities/base-definition-factory');
25
26
  const { ensurePrismaLayerExists } = require('./domains/shared/utilities/prisma-layer-manager');
@@ -75,6 +76,10 @@ const composeServerlessDefinition = async (AppDefinition) => {
75
76
  definition.provider.iamRoleStatements.push(...merged.iamStatements);
76
77
  Object.assign(definition.provider.environment, merged.environment);
77
78
  Object.assign(definition.functions, merged.functions);
79
+ applyFunctionEnvironments(
80
+ definition.functions,
81
+ merged.functionEnvironments
82
+ );
78
83
 
79
84
  if (merged.vpcConfig) {
80
85
  definition.provider.vpc = merged.vpcConfig;