@mettlecast/domain-cdk-packer 0.2.52 → 0.2.54

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.
@@ -27,6 +27,10 @@ export interface DomainStackProps extends cdk.StackProps {
27
27
  vpc?: ec2.IVpc;
28
28
  /** Security group for domain Lambdas — must allow egress to Aurora SG. */
29
29
  lambdaSg?: ec2.ISecurityGroup;
30
+ /** Subnet selection for VPC-attached handlers that should remain internal-only. */
31
+ internalSubnetSelection?: ec2.SubnetSelection;
32
+ /** Subnet selection for VPC-attached handlers that need NAT-backed internet egress. */
33
+ internetSubnetSelection?: ec2.SubnetSelection;
30
34
  /** Shared HTTP API Gateway — when provided, domain routes are added here instead of creating a separate API. */
31
35
  httpApi?: apigwv2.IHttpApi;
32
36
  /** Path to domain source root � defaults to ../../domains/{domainId}. */
@@ -55,10 +55,8 @@ export class DomainStack extends cdk.Stack {
55
55
  */
56
56
  constructor(scope, id, props) {
57
57
  super(scope, id, props);
58
- const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, vpc, lambdaSg, httpApi, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
58
+ const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, vpc, lambdaSg, internalSubnetSelection, internetSubnetSelection, httpApi, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
59
59
  const domainId = registry.domain.id;
60
- const domainRoot = props.domainRoot ?? "../../../../domains/" + domainId;
61
- const vpcProps = vpc ? { vpc, securityGroups: lambdaSg ? [lambdaSg] : undefined } : {};
62
60
  const allowedOrigins = corsAllowedOrigins ?? ['*'];
63
61
  this.httpApi = httpApi ?? new apigwv2.HttpApi(this, 'HttpApi', {
64
62
  apiName: `${domainId}-api`,
@@ -103,6 +101,50 @@ export class DomainStack extends cdk.Stack {
103
101
  if (dbSecretArn) {
104
102
  environment.DB_SECRET_ARN = dbSecretArn;
105
103
  }
104
+ const resolveSubnetSelection = (access) => {
105
+ if (access === 'internet')
106
+ return internetSubnetSelection ?? internalSubnetSelection;
107
+ return internalSubnetSelection ?? internetSubnetSelection;
108
+ };
109
+ const normaliseOutboundAccess = (access) => access === 'internet' ? 'internet' : 'internal';
110
+ const splitByOutboundAccess = (entries) => {
111
+ const buckets = { internal: [], internet: [] };
112
+ for (const entry of entries)
113
+ buckets[normaliseOutboundAccess(entry.outboundAccess)].push(entry);
114
+ return Object.entries(buckets).filter(([, grouped]) => grouped.length > 0).map(([outboundAccess, grouped]) => ({ outboundAccess, entries: grouped }));
115
+ };
116
+ const createPrimitiveHandlers = (entries, primitiveType) => {
117
+ const lambdas = [];
118
+ const byId = new Map();
119
+ for (const group of splitByOutboundAccess(entries)) {
120
+ const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated') || primitiveType === 'api';
121
+ const groupLambdas = createGroupedLambdas(this, {
122
+ domainId,
123
+ domainRoot: registry.domainRoot,
124
+ primitiveType,
125
+ handlerEntries: group.entries.map(entry => ({ id: entry.id, handlerFile: entry.handlerFile })),
126
+ environment,
127
+ eventBusArn,
128
+ dedicated,
129
+ reservedConcurrency,
130
+ logRetentionDays: logRetentionDays ?? 30,
131
+ lambdaGroupId: group.outboundAccess,
132
+ ...(vpc ? {
133
+ vpc,
134
+ securityGroups: lambdaSg ? [lambdaSg] : undefined,
135
+ subnetSelection: resolveSubnetSelection(group.outboundAccess),
136
+ } : {}),
137
+ });
138
+ lambdas.push(...groupLambdas);
139
+ if (dedicated) {
140
+ group.entries.forEach((entry, index) => byId.set(entry.id, groupLambdas[index]));
141
+ }
142
+ else {
143
+ group.entries.forEach(entry => byId.set(entry.id, groupLambdas[0]));
144
+ }
145
+ }
146
+ return { lambdas, byId };
147
+ };
106
148
  // Per-domain DynamoDB table — PK=tenantId (tenant is primary partition), SK=entity key
107
149
  const domainTable = new dynamodb.Table(this, 'DomainTable', {
108
150
  tableName: `tib-${domainId}`,
@@ -152,21 +194,9 @@ export class DomainStack extends cdk.Stack {
152
194
  const allDlqs = [];
153
195
  // Deploy API endpoints as grouped Lambdas
154
196
  if (registry.apis.length > 0) {
155
- apiLambdas = createGroupedLambdas(this, {
156
- domainId,
157
- primitiveType: 'api',
158
- handlerEntries: registry.apis.map(api => ({
159
- id: api.id,
160
- handlerFile: api.handlerFile,
161
- })),
162
- environment,
163
- eventBusArn,
164
- dedicated: true,
165
- domainRoot,
166
- reservedConcurrency,
167
- logRetentionDays: logRetentionDays ?? 30,
168
- ...vpcProps,
169
- });
197
+ const apiHandlers = createPrimitiveHandlers(registry.apis, 'api');
198
+ const apiLambdaById = apiHandlers.byId;
199
+ apiLambdas = apiHandlers.lambdas;
170
200
  this.lambdaArns[`${domainId}-api`] = apiLambdas[0].functionArn;
171
201
  // Wire each API Lambda to the HttpApi
172
202
  const iamPolicies = iamBuilder.forApi();
@@ -182,7 +212,7 @@ export class DomainStack extends cdk.Stack {
182
212
  }
183
213
  // Add routes for each API entry
184
214
  for (const api of registry.apis) {
185
- const fn = apiLambdas[0]; // Grouped Lambda or first dedicated
215
+ const fn = apiLambdaById.get(api.id);
186
216
  const apiRouteBase = {
187
217
  path: api.path,
188
218
  methods: [toHttpMethod(api.method)],
@@ -211,20 +241,9 @@ export class DomainStack extends cdk.Stack {
211
241
  timeToLiveAttribute: 'expiresAt',
212
242
  removalPolicy: cdk.RemovalPolicy.DESTROY,
213
243
  });
214
- webhookLambdas = createGroupedLambdas(this, {
215
- domainId,
216
- primitiveType: 'webhook',
217
- handlerEntries: registry.webhooks.map(webhook => ({
218
- id: webhook.id,
219
- handlerFile: webhook.handlerFile,
220
- })),
221
- environment,
222
- eventBusArn,
223
- dedicated: registry.webhooks.some(webhook => webhook.deployment?.isolation === 'dedicated'),
224
- reservedConcurrency,
225
- logRetentionDays: logRetentionDays ?? 30,
226
- ...vpcProps,
227
- });
244
+ const webhookHandlers = createPrimitiveHandlers(registry.webhooks, 'webhook');
245
+ const webhookLambdaById = webhookHandlers.byId;
246
+ webhookLambdas = webhookHandlers.lambdas;
228
247
  this.lambdaArns[`${domainId}-webhook`] = webhookLambdas[0].functionArn;
229
248
  // Wire each webhook Lambda to the HttpApi
230
249
  const iamPolicies = iamBuilder.forWebhook({ dedupeTableArn: dedupeTable.tableArn });
@@ -241,26 +260,15 @@ export class DomainStack extends cdk.Stack {
241
260
  }
242
261
  // Add routes for each webhook entry
243
262
  for (const webhook of registry.webhooks) {
244
- const fn = webhookLambdas[0]; // Grouped Lambda or first dedicated
263
+ const fn = webhookLambdaById.get(webhook.id);
245
264
  addRouteToApi(this, fn, `/${domainId}${webhook.path}`, [apigwv2.HttpMethod.POST], this.httpApi.httpApiId);
246
265
  }
247
266
  }
248
267
  // Deploy event subscribers as grouped Lambdas
249
268
  if (registry.subscribers.length > 0) {
250
- subscriberLambdas = createGroupedLambdas(this, {
251
- domainId,
252
- primitiveType: 'subscriber',
253
- handlerEntries: registry.subscribers.map(subscriber => ({
254
- id: subscriber.id,
255
- handlerFile: subscriber.handlerFile,
256
- })),
257
- environment,
258
- eventBusArn,
259
- dedicated: registry.subscribers.some(subscriber => subscriber.deployment?.isolation === 'dedicated'),
260
- reservedConcurrency,
261
- logRetentionDays: logRetentionDays ?? 30,
262
- ...vpcProps,
263
- });
269
+ const subscriberHandlers = createPrimitiveHandlers(registry.subscribers, 'subscriber');
270
+ const subscriberLambdaById = subscriberHandlers.byId;
271
+ subscriberLambdas = subscriberHandlers.lambdas;
264
272
  this.lambdaArns[`${domainId}-subscriber`] = subscriberLambdas[0].functionArn;
265
273
  // Wire EventBridge → SQS → Lambda for each subscriber
266
274
  for (const subscriber of registry.subscribers) {
@@ -283,7 +291,7 @@ export class DomainStack extends cdk.Stack {
283
291
  encryptionMasterKey: enableCmk ? cmkKey : undefined,
284
292
  });
285
293
  // Add SQS as event source for the first grouped Lambda
286
- const fn = subscriberLambdas[0];
294
+ const fn = subscriberLambdaById.get(subscriber.id);
287
295
  fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
288
296
  batchSize: 10,
289
297
  maxConcurrency: subscriber.concurrency ?? 5,
@@ -308,20 +316,9 @@ export class DomainStack extends cdk.Stack {
308
316
  }
309
317
  // Deploy scheduled tasks as grouped Lambdas
310
318
  if (registry.schedules.length > 0) {
311
- scheduleLambdas = createGroupedLambdas(this, {
312
- domainId,
313
- primitiveType: 'schedule',
314
- handlerEntries: registry.schedules.map(schedule => ({
315
- id: schedule.id,
316
- handlerFile: schedule.handlerFile,
317
- })),
318
- environment,
319
- eventBusArn,
320
- dedicated: registry.schedules.some(schedule => schedule.deployment?.isolation === 'dedicated'),
321
- reservedConcurrency,
322
- logRetentionDays: logRetentionDays ?? 30,
323
- ...vpcProps,
324
- });
319
+ const scheduleHandlers = createPrimitiveHandlers(registry.schedules, 'schedule');
320
+ const scheduleLambdaById = scheduleHandlers.byId;
321
+ scheduleLambdas = scheduleHandlers.lambdas;
325
322
  this.lambdaArns[`${domainId}-schedule`] = scheduleLambdas[0].functionArn;
326
323
  const iamPolicies = iamBuilder.forSchedule();
327
324
  scheduleLambdas.forEach((fn) => {
@@ -337,7 +334,7 @@ export class DomainStack extends cdk.Stack {
337
334
  // Wire EventBridge Scheduler rules to Lambda
338
335
  for (const schedule of registry.schedules) {
339
336
  const pascalId = toPascalCase(schedule.id);
340
- const fn = scheduleLambdas[0]; // Grouped Lambda or first dedicated
337
+ const fn = scheduleLambdaById.get(schedule.id);
341
338
  // Create a dedicated IAM role for the EventBridge Scheduler to assume
342
339
  const schedulerRole = new iam.Role(this, `${pascalId}SchedulerRole`, {
343
340
  assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'),
@@ -359,20 +356,9 @@ export class DomainStack extends cdk.Stack {
359
356
  }
360
357
  // Deploy background jobs as grouped Lambdas
361
358
  if (registry.jobs.length > 0) {
362
- jobLambdas = createGroupedLambdas(this, {
363
- domainId,
364
- primitiveType: 'job',
365
- handlerEntries: registry.jobs.map(job => ({
366
- id: job.id,
367
- handlerFile: job.handlerFile,
368
- })),
369
- environment,
370
- eventBusArn,
371
- dedicated: registry.jobs.some(job => job.deployment?.isolation === 'dedicated'),
372
- reservedConcurrency,
373
- logRetentionDays: logRetentionDays ?? 30,
374
- ...vpcProps,
375
- });
359
+ const jobHandlers = createPrimitiveHandlers(registry.jobs, 'job');
360
+ const jobLambdaById = jobHandlers.byId;
361
+ jobLambdas = jobHandlers.lambdas;
376
362
  this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
377
363
  // Wire SQS → Lambda for each job
378
364
  for (const job of registry.jobs) {
@@ -395,7 +381,7 @@ export class DomainStack extends cdk.Stack {
395
381
  encryptionMasterKey: enableCmk ? cmkKey : undefined,
396
382
  });
397
383
  // Add SQS as event source for the first grouped Lambda
398
- const fn = jobLambdas[0];
384
+ const fn = jobLambdaById.get(job.id);
399
385
  fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
400
386
  batchSize: 1,
401
387
  }));
@@ -413,20 +399,8 @@ export class DomainStack extends cdk.Stack {
413
399
  }
414
400
  // Deploy callable actions as grouped Lambdas
415
401
  if (registry.actions.length > 0) {
416
- const actionLambdas = createGroupedLambdas(this, {
417
- domainId,
418
- primitiveType: 'action',
419
- handlerEntries: registry.actions.map(action => ({
420
- id: action.id,
421
- handlerFile: action.handlerFile,
422
- })),
423
- environment,
424
- eventBusArn,
425
- dedicated: registry.actions.some(action => action.deployment?.isolation === 'dedicated'),
426
- reservedConcurrency,
427
- logRetentionDays: logRetentionDays ?? 30,
428
- ...vpcProps,
429
- });
402
+ const actionHandlers = createPrimitiveHandlers(registry.actions, 'action');
403
+ actionLambdas = actionHandlers.lambdas;
430
404
  this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
431
405
  const iamPolicies = iamBuilder.forAction([
432
406
  `arn:aws:lambda:${this.region}:${this.account}:function:TIB-*-domain-*-action-*`,
@@ -1,10 +1,10 @@
1
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1
+ import { describe, it, expect, afterAll } from 'vitest';
2
2
  import * as cdk from 'aws-cdk-lib';
3
3
  import { Template } from 'aws-cdk-lib/assertions';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { DomainStack } from '../DomainStack.js';
7
- const DOMAIN_ROOT = '/tmp/test-domain-cdk-packer';
7
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer');
8
8
  const minimalRegistry = {
9
9
  schemaVersion: '1',
10
10
  domainRoot: DOMAIN_ROOT,
@@ -27,11 +27,9 @@ const minimalRegistry = {
27
27
  integrations: [],
28
28
  events: [],
29
29
  };
30
- beforeAll(() => {
31
- const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
32
- fs.mkdirSync(handlerDir, { recursive: true });
33
- fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
34
- });
30
+ const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
31
+ fs.mkdirSync(handlerDir, { recursive: true });
32
+ fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
35
33
  afterAll(() => {
36
34
  fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
37
35
  });
@@ -95,17 +93,9 @@ describe('DomainStack', () => {
95
93
  dbSecretArn,
96
94
  });
97
95
  const template = Template.fromStack(stack);
98
- template.allResources('AWS::Lambda::Function', {
99
- Environment: {
100
- Variables: {
101
- Match: {
102
- stringLike: {
103
- DB_SECRET_ARN: dbSecretArn,
104
- },
105
- },
106
- },
107
- },
108
- });
96
+ const lambdas = template.findResources('AWS::Lambda::Function');
97
+ const domainLambdas = Object.entries(lambdas).filter(([id]) => !id.includes('Health') && !id.includes('Ready') && !id.includes('LogRetention'));
98
+ expect(domainLambdas.some(([, resource]) => resource.Properties.Environment?.Variables?.DB_SECRET_ARN === dbSecretArn)).toBe(true);
109
99
  });
110
100
  it('secretsmanager:GetSecretValue grant added when dbSecretArn provided', () => {
111
101
  const app = new cdk.App();
@@ -116,24 +106,9 @@ describe('DomainStack', () => {
116
106
  dbSecretArn,
117
107
  });
118
108
  const template = Template.fromStack(stack);
119
- template.allResources('AWS::IAM::Policy', {
120
- PolicyDocument: {
121
- Match: {
122
- objectLike: {
123
- Statement: [
124
- {
125
- Match: {
126
- objectLike: {
127
- Action: ['secretsmanager:GetSecretValue'],
128
- Resource: [dbSecretArn],
129
- },
130
- },
131
- },
132
- ],
133
- },
134
- },
135
- },
136
- });
109
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
110
+ const hasSecretGrant = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Action?.includes('secretsmanager:GetSecretValue') && statement.Resource?.includes(dbSecretArn)));
111
+ expect(hasSecretGrant).toBe(true);
137
112
  });
138
113
  describe('domain S3 bucket', () => {
139
114
  const app = new cdk.App();
@@ -166,55 +141,29 @@ describe('DomainStack', () => {
166
141
  const lambdas = template.findResources('AWS::Lambda::Function');
167
142
  for (const [id, res] of Object.entries(lambdas)) {
168
143
  // Skip health/readiness Lambdas from HealthConstruct
169
- if (id.includes('Health') || id.includes('Ready'))
144
+ if (id.includes('Health') || id.includes('Ready') || id.includes('LogRetention'))
170
145
  continue;
171
- expect(res.Properties.Environment.Variables).toHaveProperty('DOMAIN_TENANT_ROLE_ARN');
146
+ expect(res.Properties.Environment?.Variables).toHaveProperty('DOMAIN_TENANT_ROLE_ARN');
172
147
  }
173
148
  });
174
149
  it('grants Lambda execution roles sts:AssumeRole + sts:TagSession on the tenant-scoped role', () => {
175
- template.hasResourceProperties('AWS::IAM::Policy', {
176
- PolicyDocument: {
177
- Statement: expect.arrayContaining([
178
- expect.objectContaining({
179
- Action: expect.arrayContaining(['sts:AssumeRole', 'sts:TagSession']),
180
- Effect: 'Allow',
181
- }),
182
- ]),
183
- },
184
- });
150
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
151
+ const hasAssumeRoleGrant = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Effect === 'Allow'
152
+ && statement.Action?.includes('sts:AssumeRole')
153
+ && statement.Action?.includes('sts:TagSession')));
154
+ expect(hasAssumeRoleGrant).toBe(true);
185
155
  });
186
156
  it('tenant-scoped role has trust policy with request-tag condition', () => {
187
- template.hasResourceProperties('AWS::IAM::Role', {
188
- AssumeRolePolicyDocument: {
189
- Statement: expect.arrayContaining([
190
- expect.objectContaining({
191
- Action: expect.arrayContaining(['sts:AssumeRole', 'sts:TagSession']),
192
- Effect: 'Allow',
193
- Condition: expect.objectContaining({
194
- StringLike: expect.objectContaining({
195
- 'aws:RequestTag/tenantId': '*',
196
- }),
197
- }),
198
- }),
199
- ]),
200
- },
201
- });
157
+ const roles = Object.values(template.findResources('AWS::IAM::Role'));
158
+ const hasTrustCondition = roles.some(resource => resource.Properties.AssumeRolePolicyDocument.Statement.some(statement => statement.Action?.includes('sts:AssumeRole')
159
+ && statement.Action?.includes('sts:TagSession')
160
+ && statement.Condition?.StringLike?.['aws:RequestTag/tenantId'] === '*'));
161
+ expect(hasTrustCondition).toBe(true);
202
162
  });
203
163
  it('tenant-scoped role policy uses PrincipalTag conditions', () => {
204
- template.hasResourceProperties('AWS::IAM::Policy', {
205
- Roles: expect.arrayContaining([expect.objectContaining({ Ref: expect.any(String) })]),
206
- PolicyDocument: {
207
- Statement: expect.arrayContaining([
208
- expect.objectContaining({
209
- Condition: expect.objectContaining({
210
- 'ForAllValues:StringEquals': expect.objectContaining({
211
- 'dynamodb:LeadingKeys': ['${aws:PrincipalTag/tenantId}'],
212
- }),
213
- }),
214
- }),
215
- ]),
216
- },
217
- });
164
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
165
+ const hasPrincipalTagCondition = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Condition?.['ForAllValues:StringEquals']?.['dynamodb:LeadingKeys']?.includes('${aws:PrincipalTag/tenantId}')));
166
+ expect(hasPrincipalTagCondition).toBe(true);
218
167
  });
219
168
  it('has CfnOutput for tenant-scoped role ARN', () => {
220
169
  template.hasOutput('DomainTenantScopedRoleArn', {});
@@ -28,6 +28,10 @@ export interface GroupedLambdaProps {
28
28
  vpc?: ec2.IVpc;
29
29
  /** Security groups to attach to the Lambda(s). */
30
30
  securityGroups?: ec2.ISecurityGroup[];
31
+ /** Subnet selection for the Lambda(s) when attached to a VPC. */
32
+ subnetSelection?: ec2.SubnetSelection;
33
+ /** Optional suffix used when multiple Lambda groups share the same primitive type. */
34
+ lambdaGroupId?: string;
31
35
  /** When true, creates one dedicated Lambda per handler instead of a single grouped Lambda. */
32
36
  dedicated?: boolean;
33
37
  /** Reserved concurrency for all Lambdas in this group. If omitted, no limit. */
@@ -3,19 +3,19 @@ import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs';
3
3
  import * as logs from 'aws-cdk-lib/aws-logs';
4
4
  import * as cdk from 'aws-cdk-lib';
5
5
  import { writeFileSync, mkdirSync } from 'fs';
6
- import { join } from 'path';
6
+ import { join, relative } from 'path';
7
7
  function camelCase(str) {
8
8
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
9
9
  }
10
10
  function toPascalCase(str) {
11
11
  return str.charAt(0).toUpperCase() + camelCase(str).slice(1);
12
12
  }
13
- function generateDedicatedEntry(domainRoot, entry, primitiveType) {
13
+ function generateDedicatedEntry(handlerImportPath, entry, primitiveType) {
14
14
  const exportName = camelCase(entry.id);
15
15
  if (primitiveType === 'schedule') {
16
16
  return [
17
17
  `import { hydrateCtx } from '@mettlecast/domain-runtime';`,
18
- `import { ${exportName} } from '${domainRoot}/${entry.handlerFile}';`,
18
+ `import { ${exportName} } from '${handlerImportPath}';`,
19
19
  ``,
20
20
  `export const handler = async (event: any) => {`,
21
21
  ` const ctx = await hydrateCtx(event, {`,
@@ -36,17 +36,15 @@ function generateDedicatedEntry(domainRoot, entry, primitiveType) {
36
36
  const adapter = adapterMap[primitiveType] ?? 'createApiLambdaHandler';
37
37
  return [
38
38
  `import { ${adapter} } from '@mettlecast/domain-runtime';`,
39
- `import { ${exportName} } from '${domainRoot}/${entry.handlerFile}';`,
39
+ `import { ${exportName} } from '${handlerImportPath}';`,
40
40
  '',
41
41
  `export const handler = ${adapter}(${exportName});`,
42
42
  ].join('\n');
43
43
  }
44
- function writeTempEntry(content) {
45
- const dir = join('.tib', 'entries');
44
+ function createTempEntryDir() {
45
+ const dir = join(process.cwd(), '.tib-domain-entries', `${Date.now()}-${Math.random().toString(36).slice(2)}`);
46
46
  mkdirSync(dir, { recursive: true });
47
- const filePath = join(dir, `entry-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`);
48
- writeFileSync(filePath, content, 'utf8');
49
- return filePath;
47
+ return dir;
50
48
  }
51
49
  const adapterCache = new Map();
52
50
  /**
@@ -57,18 +55,30 @@ const adapterCache = new Map();
57
55
  * Grouped mode retains Code.fromAsset for backward compatibility.
58
56
  */
59
57
  export function createGroupedLambdas(scope, props) {
60
- const vpcConfig = props.vpc ? { vpc: props.vpc, securityGroups: props.securityGroups } : {};
58
+ const vpcConfig = props.vpc
59
+ ? {
60
+ vpc: props.vpc,
61
+ securityGroups: props.securityGroups,
62
+ ...(props.subnetSelection ? { vpcSubnets: props.subnetSelection } : {}),
63
+ }
64
+ : {};
61
65
  const logRetention = toLogRetention(props.logRetentionDays ?? 30);
62
66
  const powertoolsLayerArn = `arn:aws:lambda:${cdk.Stack.of(scope).region}:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:26`;
67
+ const groupSuffix = props.lambdaGroupId ? `-${props.lambdaGroupId}` : '';
68
+ const groupSuffixPascal = props.lambdaGroupId ? toPascalCase(props.lambdaGroupId) : '';
69
+ const serviceName = `${props.domainId}-${props.primitiveType}${groupSuffix}`;
63
70
  if (props.dedicated) {
64
71
  if (!props.domainRoot)
65
72
  throw new Error('domainRoot is required when dedicated=true');
66
73
  const domainRoot = props.domainRoot;
67
74
  const fns = props.handlerEntries.map(entry => {
68
- const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}-${entry.id}`, powertoolsLayerArn);
69
- const entryContent = generateDedicatedEntry(domainRoot, entry, props.primitiveType);
70
- const entryPath = writeTempEntry(entryContent);
71
- return new lambdaNode.NodejsFunction(scope, `${props.domainId}-${props.primitiveType}-${entry.id}`, {
75
+ const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}${groupSuffixPascal}-${entry.id}`, powertoolsLayerArn);
76
+ const entryDir = createTempEntryDir();
77
+ const entryPath = join(entryDir, 'entry.ts');
78
+ const handlerImportPath = relative(entryDir, join(domainRoot, entry.handlerFile)).replace(/\\/g, '/');
79
+ const entryContent = generateDedicatedEntry(handlerImportPath.startsWith('.') ? handlerImportPath : `./${handlerImportPath}`, entry, props.primitiveType);
80
+ writeFileSync(entryPath, entryContent, 'utf8');
81
+ return new lambdaNode.NodejsFunction(scope, `${props.domainId}-${props.primitiveType}${groupSuffix}-${entry.id}`, {
72
82
  runtime: lambda.Runtime.NODEJS_22_X,
73
83
  architecture: lambda.Architecture.ARM_64,
74
84
  entry: entryPath,
@@ -81,7 +91,7 @@ export function createGroupedLambdas(scope, props) {
81
91
  },
82
92
  environment: {
83
93
  ...props.environment,
84
- POWERTOOLS_SERVICE_NAME: `${props.domainId}-${props.primitiveType}`,
94
+ POWERTOOLS_SERVICE_NAME: serviceName,
85
95
  POWERTOOLS_LOG_LEVEL: 'INFO',
86
96
  TIB_HANDLER_ID: entry.id,
87
97
  TIB_EVENT_BUS_ARN: props.eventBusArn,
@@ -96,9 +106,9 @@ export function createGroupedLambdas(scope, props) {
96
106
  return fns;
97
107
  }
98
108
  // Single grouped Lambda — all handlers for this primitive type bundled together
99
- const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}`, powertoolsLayerArn);
109
+ const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}${groupSuffixPascal}`, powertoolsLayerArn);
100
110
  return [
101
- new lambda.Function(scope, `${props.domainId}-${props.primitiveType}`, {
111
+ new lambda.Function(scope, `${props.domainId}-${props.primitiveType}${groupSuffix}`, {
102
112
  runtime: lambda.Runtime.NODEJS_22_X,
103
113
  architecture: lambda.Architecture.ARM_64,
104
114
  handler: 'index.handler',
@@ -106,7 +116,7 @@ export function createGroupedLambdas(scope, props) {
106
116
  layers: [powertoolsLayer],
107
117
  environment: {
108
118
  ...props.environment,
109
- POWERTOOLS_SERVICE_NAME: `${props.domainId}-${props.primitiveType}`,
119
+ POWERTOOLS_SERVICE_NAME: serviceName,
110
120
  POWERTOOLS_LOG_LEVEL: 'INFO',
111
121
  TIB_HANDLER_MAP: JSON.stringify(props.handlerEntries.map(e => e.id)),
112
122
  TIB_EVENT_BUS_ARN: props.eventBusArn,
@@ -13,6 +13,10 @@ export interface PackDomainOptions {
13
13
  vpc?: ec2.IVpc;
14
14
  /** Security group for domain Lambdas. */
15
15
  lambdaSg?: ec2.ISecurityGroup;
16
+ /** Subnet selection for VPC-attached handlers that should remain internal-only. */
17
+ internalSubnetSelection?: ec2.SubnetSelection;
18
+ /** Subnet selection for VPC-attached handlers that need NAT-backed internet egress. */
19
+ internetSubnetSelection?: ec2.SubnetSelection;
16
20
  /** Shared HTTP API Gateway — when provided, domain routes use this instead of creating a separate API. */
17
21
  httpApi?: apigwv2.HttpApi;
18
22
  /** Cognito User Pool ARN — when provided, HTTP API routes are JWT-protected. */
@@ -24,6 +24,8 @@ export function packDomain(registry, app, stackId, eventBusArn, options) {
24
24
  env: opts.env,
25
25
  vpc: opts.vpc,
26
26
  lambdaSg: opts.lambdaSg,
27
+ internalSubnetSelection: opts.internalSubnetSelection,
28
+ internetSubnetSelection: opts.internetSubnetSelection,
27
29
  httpApi: opts.httpApi,
28
30
  userPoolArn: opts.userPoolArn,
29
31
  userPoolId: opts.userPoolId,
@@ -6,6 +6,8 @@ export type SchemaSnapshot = Record<string, unknown>;
6
6
  * Kind discriminant for registry entries.
7
7
  */
8
8
  export type RegistryEntryKind = 'api' | 'webhook' | 'subscriber' | 'schedule' | 'job' | 'action' | 'integration' | 'event' | 'domain';
9
+ /** Controls whether a VPC-attached handler may use NAT-backed public internet egress. */
10
+ export type RegistryOutboundAccess = 'internal' | 'internet';
9
11
  /**
10
12
  * Plain-value subset of DeploymentConfig for serialization.
11
13
  */
@@ -63,6 +65,8 @@ export interface ApiRegistryEntry extends BaseRegistryEntry {
63
65
  authType: 'jwt' | 'api-key' | 'none';
64
66
  /** Optional deployment overrides. */
65
67
  deployment?: SerialDeploymentConfig;
68
+ /** Whether this handler may use NAT-backed public internet egress. */
69
+ outboundAccess?: RegistryOutboundAccess;
66
70
  /** JSON Schema snapshot for the HTTP request body. */
67
71
  requestSchema?: SchemaSnapshot;
68
72
  /** JSON Schema snapshot for the HTTP response body. */
@@ -93,6 +97,8 @@ export interface WebhookRegistryEntry extends BaseRegistryEntry {
93
97
  hmacSecretRef?: string;
94
98
  /** Optional deployment overrides. */
95
99
  deployment?: SerialDeploymentConfig;
100
+ /** Whether this handler may use NAT-backed public internet egress. */
101
+ outboundAccess?: RegistryOutboundAccess;
96
102
  }
97
103
  /**
98
104
  * Event subscriber registry entry.
@@ -110,6 +116,8 @@ export interface SubscriberRegistryEntry extends BaseRegistryEntry {
110
116
  concurrency?: number;
111
117
  /** Optional deployment overrides. */
112
118
  deployment?: SerialDeploymentConfig;
119
+ /** Whether this handler may use NAT-backed public internet egress. */
120
+ outboundAccess?: RegistryOutboundAccess;
113
121
  }
114
122
  /**
115
123
  * Schedule (cron) registry entry.
@@ -125,6 +133,8 @@ export interface ScheduleRegistryEntry extends BaseRegistryEntry {
125
133
  enabled: boolean;
126
134
  /** Optional deployment overrides. */
127
135
  deployment?: SerialDeploymentConfig;
136
+ /** Whether this handler may use NAT-backed public internet egress. */
137
+ outboundAccess?: RegistryOutboundAccess;
128
138
  }
129
139
  /**
130
140
  * Background job registry entry.
@@ -140,6 +150,8 @@ export interface JobRegistryEntry extends BaseRegistryEntry {
140
150
  visibilityTimeoutSeconds: number;
141
151
  /** Optional deployment overrides. */
142
152
  deployment?: SerialDeploymentConfig;
153
+ /** Whether this handler may use NAT-backed public internet egress. */
154
+ outboundAccess?: RegistryOutboundAccess;
143
155
  }
144
156
  /**
145
157
  * Action registry entry for callable domain actions.
@@ -155,6 +167,8 @@ export interface ActionRegistryEntry extends BaseRegistryEntry {
155
167
  idempotent: boolean;
156
168
  /** Optional deployment overrides. */
157
169
  deployment?: SerialDeploymentConfig;
170
+ /** Whether this handler may use NAT-backed public internet egress. */
171
+ outboundAccess?: RegistryOutboundAccess;
158
172
  /** JSON Schema snapshot for the action input. */
159
173
  inputSchema?: SchemaSnapshot;
160
174
  /** JSON Schema snapshot for the action output. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",