@mettlecast/domain-cdk-packer 0.2.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 (66) hide show
  1. package/README.md +83 -0
  2. package/dist/DomainStack.d.ts +68 -0
  3. package/dist/DomainStack.js +567 -0
  4. package/dist/__tests__/domain-stack.test.d.ts +1 -0
  5. package/dist/__tests__/domain-stack.test.js +223 -0
  6. package/dist/__tests__/lambda-factory.test.d.ts +1 -0
  7. package/dist/__tests__/lambda-factory.test.js +76 -0
  8. package/dist/__tests__/pack-flows.test.d.ts +1 -0
  9. package/dist/__tests__/pack-flows.test.js +283 -0
  10. package/dist/__tests__/registry.test.d.ts +1 -0
  11. package/dist/__tests__/registry.test.js +34 -0
  12. package/dist/__tests__/step-functions-codegen.test.d.ts +1 -0
  13. package/dist/__tests__/step-functions-codegen.test.js +159 -0
  14. package/dist/aspects/iam-boundaries-aspect.d.ts +11 -0
  15. package/dist/aspects/iam-boundaries-aspect.js +15 -0
  16. package/dist/aspects/index.d.ts +5 -0
  17. package/dist/aspects/index.js +3 -0
  18. package/dist/aspects/log-retention-aspect.d.ts +8 -0
  19. package/dist/aspects/log-retention-aspect.js +24 -0
  20. package/dist/aspects/tagging-aspect.d.ts +13 -0
  21. package/dist/aspects/tagging-aspect.js +17 -0
  22. package/dist/constructs/action-construct.d.ts +35 -0
  23. package/dist/constructs/action-construct.js +35 -0
  24. package/dist/constructs/alarm-construct.d.ts +19 -0
  25. package/dist/constructs/alarm-construct.js +58 -0
  26. package/dist/constructs/alarms-construct.d.ts +17 -0
  27. package/dist/constructs/alarms-construct.js +42 -0
  28. package/dist/constructs/api-construct.d.ts +30 -0
  29. package/dist/constructs/api-construct.js +64 -0
  30. package/dist/constructs/canary-construct.d.ts +13 -0
  31. package/dist/constructs/canary-construct.js +30 -0
  32. package/dist/constructs/cmk-construct.d.ts +9 -0
  33. package/dist/constructs/cmk-construct.js +20 -0
  34. package/dist/constructs/dashboard-construct.d.ts +15 -0
  35. package/dist/constructs/dashboard-construct.js +62 -0
  36. package/dist/constructs/health-construct.d.ts +11 -0
  37. package/dist/constructs/health-construct.js +29 -0
  38. package/dist/constructs/job-construct.d.ts +33 -0
  39. package/dist/constructs/job-construct.js +54 -0
  40. package/dist/constructs/schedule-construct.d.ts +27 -0
  41. package/dist/constructs/schedule-construct.js +54 -0
  42. package/dist/constructs/subscriber-construct.d.ts +30 -0
  43. package/dist/constructs/subscriber-construct.js +63 -0
  44. package/dist/constructs/waf-construct.d.ts +13 -0
  45. package/dist/constructs/waf-construct.js +75 -0
  46. package/dist/constructs/webhook-construct.d.ts +33 -0
  47. package/dist/constructs/webhook-construct.js +50 -0
  48. package/dist/flow-registry.d.ts +81 -0
  49. package/dist/flow-registry.js +2 -0
  50. package/dist/grouped-lambda-factory.d.ts +43 -0
  51. package/dist/grouped-lambda-factory.js +71 -0
  52. package/dist/iam/iam-policy-builder.d.ts +95 -0
  53. package/dist/iam/iam-policy-builder.js +232 -0
  54. package/dist/index.d.ts +36 -0
  55. package/dist/index.js +17 -0
  56. package/dist/lambda-factory.d.ts +46 -0
  57. package/dist/lambda-factory.js +80 -0
  58. package/dist/pack-domain.d.ts +35 -0
  59. package/dist/pack-domain.js +32 -0
  60. package/dist/pack-flows.d.ts +28 -0
  61. package/dist/pack-flows.js +154 -0
  62. package/dist/registry.d.ts +224 -0
  63. package/dist/registry.js +1 -0
  64. package/dist/step-functions-codegen.d.ts +65 -0
  65. package/dist/step-functions-codegen.js +55 -0
  66. package/package.json +36 -0
@@ -0,0 +1,567 @@
1
+ import * as cdk from 'aws-cdk-lib';
2
+ import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
3
+ import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
4
+ import * as apigwv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
5
+ import * as events from 'aws-cdk-lib/aws-events';
6
+ import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
7
+ import * as sqs from 'aws-cdk-lib/aws-sqs';
8
+ import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
9
+ import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
10
+ import * as iam from 'aws-cdk-lib/aws-iam';
11
+ import * as scheduler from 'aws-cdk-lib/aws-scheduler';
12
+ import * as s3 from 'aws-cdk-lib/aws-s3';
13
+ import { createGroupedLambdas } from './grouped-lambda-factory.js';
14
+ import { IamPolicyBuilder } from './iam/iam-policy-builder.js';
15
+ import { HealthConstruct } from './constructs/health-construct.js';
16
+ import { DashboardConstruct } from './constructs/dashboard-construct.js';
17
+ import { CmkConstruct } from './constructs/cmk-construct.js';
18
+ import { WafConstruct } from './constructs/waf-construct.js';
19
+ import { AlarmConstruct } from './constructs/alarm-construct.js';
20
+ import { CanaryConstruct } from './constructs/canary-construct.js';
21
+ /**
22
+ * Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
23
+ * Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
24
+ */
25
+ export class DomainStack extends cdk.Stack {
26
+ /** Shared HTTP API for routing API and webhook requests. */
27
+ httpApi;
28
+ /** Map of primitive-type key to Lambda function ARN, used by FlowsStack for direct invocation. */
29
+ lambdaArns = {};
30
+ /**
31
+ * Create a new DomainStack instance.
32
+ * @param scope - Parent CDK scope.
33
+ * @param id - Stack identifier.
34
+ * @param props - Stack properties including domain registry and event bus ARN.
35
+ */
36
+ constructor(scope, id, props) {
37
+ super(scope, id, props);
38
+ const { registry, eventBusArn, databaseUrl, dbSecretArn, userPoolArn, userPoolClientId, vpc, lambdaSg, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
39
+ const domainId = registry.domain.id;
40
+ const vpcProps = vpc ? { vpc, securityGroups: lambdaSg ? [lambdaSg] : undefined } : {};
41
+ const allowedOrigins = corsAllowedOrigins ?? ['*'];
42
+ this.httpApi = new apigwv2.HttpApi(this, 'HttpApi', {
43
+ apiName: `${domainId}-api`,
44
+ corsPreflight: {
45
+ allowHeaders: ['Content-Type', 'Authorization'],
46
+ allowMethods: [apigwv2.CorsHttpMethod.ANY],
47
+ allowOrigins: allowedOrigins,
48
+ ...(allowCredentials ? { allowCredentials: true } : {}),
49
+ },
50
+ });
51
+ // Wire Cognito JWT authorizer when user pool is provided
52
+ let jwtAuthorizer;
53
+ if (userPoolArn && userPoolClientId) {
54
+ const region = cdk.Stack.of(this).region;
55
+ const userPoolId = cdk.Fn.select(1, cdk.Fn.split('/', userPoolArn));
56
+ jwtAuthorizer = new apigwv2Authorizers.HttpJwtAuthorizer('CognitoAuthorizer', `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`, {
57
+ jwtAudience: [userPoolClientId],
58
+ });
59
+ }
60
+ // Optional WAF
61
+ if (enableWaf) {
62
+ new WafConstruct(this, 'Waf', { domainId, httpApi: this.httpApi });
63
+ }
64
+ // Optional CMK — customer-managed encryption key for DynamoDB, S3, SQS
65
+ let cmkKey;
66
+ if (enableCmk) {
67
+ const cmk = new CmkConstruct(this, 'Cmk', { domainId });
68
+ cmkKey = cmk.key;
69
+ }
70
+ const iamBuilder = new IamPolicyBuilder();
71
+ const eventBus = events.EventBus.fromEventBusArn(this, 'TibEventBus', eventBusArn);
72
+ // Build common environment for all Lambdas
73
+ const environment = {
74
+ TIB_DOMAIN_ID: domainId,
75
+ TIB_EVENT_BUS_ARN: eventBusArn,
76
+ };
77
+ if (databaseUrl) {
78
+ environment.DATABASE_URL = databaseUrl;
79
+ }
80
+ if (dbSecretArn) {
81
+ environment.DB_SECRET_ARN = dbSecretArn;
82
+ }
83
+ // Per-domain DynamoDB table — PK=tenantId (tenant is primary partition), SK=entity key
84
+ const domainTable = new dynamodb.Table(this, 'DomainTable', {
85
+ tableName: `tib-${domainId}`,
86
+ partitionKey: { name: 'tenantId', type: dynamodb.AttributeType.STRING },
87
+ sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
88
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
89
+ encryption: enableCmk ? dynamodb.TableEncryption.CUSTOMER_MANAGED : dynamodb.TableEncryption.AWS_MANAGED,
90
+ encryptionKey: cmkKey,
91
+ timeToLiveAttribute: 'expiresAt',
92
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
93
+ pointInTimeRecovery: true,
94
+ });
95
+ // Per-domain S3 bucket — all objects prefixed {tenantId}/ enforced in runtime
96
+ const bucketName = `tib-${domainId}`.length <= 63
97
+ ? `tib-${domainId}`
98
+ : `tib-${domainId.slice(0, 55)}-${cdk.Fn.select(0, cdk.Fn.split('-', cdk.Names.uniqueId(this))).toLowerCase()}`;
99
+ const domainBucket = new s3.Bucket(this, 'DomainBucket', {
100
+ bucketName,
101
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
102
+ encryption: enableCmk ? s3.BucketEncryption.KMS : s3.BucketEncryption.S3_MANAGED,
103
+ encryptionKey: cmkKey,
104
+ versioned: true,
105
+ enforceSSL: true,
106
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
107
+ lifecycleRules: [{
108
+ noncurrentVersionExpiration: cdk.Duration.days(90),
109
+ }],
110
+ });
111
+ environment['DOMAIN_TABLE_NAME'] = domainTable.tableName;
112
+ environment['DOMAIN_BUCKET_NAME'] = domainBucket.bucketName;
113
+ // Per-domain tenant-scoped IAM role for defence-in-depth storage segregation
114
+ const tenantScopedRole = new iam.Role(this, 'DomainTenantScopedRole', {
115
+ roleName: `tib-${domainId}-tenant-scoped`,
116
+ assumedBy: new iam.AccountPrincipal(cdk.Stack.of(this).account),
117
+ description: `Tenant-scoped role for ${domainId} domain. Only assumable with tenantId session tag.`,
118
+ maxSessionDuration: cdk.Duration.hours(1),
119
+ });
120
+ environment['DOMAIN_TENANT_ROLE_ARN'] = tenantScopedRole.roleArn;
121
+ // Pre-declare lambda group variables
122
+ let apiLambdas = [];
123
+ let webhookLambdas = [];
124
+ let subscriberLambdas = [];
125
+ let scheduleLambdas = [];
126
+ let jobLambdas = [];
127
+ let actionLambdas = [];
128
+ // Track all DLQs for alarm construct
129
+ const allDlqs = [];
130
+ // Deploy API endpoints as grouped Lambdas
131
+ if (registry.apis.length > 0) {
132
+ apiLambdas = createGroupedLambdas(this, {
133
+ domainId,
134
+ primitiveType: 'api',
135
+ handlerEntries: registry.apis.map(api => ({
136
+ id: api.id,
137
+ handlerFile: api.handlerFile,
138
+ })),
139
+ environment,
140
+ eventBusArn,
141
+ dedicated: registry.apis.some(api => api.deployment?.isolation === 'dedicated'),
142
+ reservedConcurrency,
143
+ logRetentionDays: logRetentionDays ?? 30,
144
+ ...vpcProps,
145
+ });
146
+ this.lambdaArns[`${domainId}-api`] = apiLambdas[0].functionArn;
147
+ // Wire each API Lambda to the HttpApi
148
+ const iamPolicies = iamBuilder.forApi();
149
+ apiLambdas.forEach((fn) => {
150
+ iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
151
+ });
152
+ // Add dbSecretArn grant if provided
153
+ if (dbSecretArn) {
154
+ apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
155
+ actions: ['secretsmanager:GetSecretValue'],
156
+ resources: [dbSecretArn],
157
+ })));
158
+ }
159
+ // Add routes for each API entry
160
+ for (const api of registry.apis) {
161
+ const fn = apiLambdas[0]; // Grouped Lambda or first dedicated
162
+ const apiRouteBase = {
163
+ path: api.path,
164
+ methods: [toHttpMethod(api.method)],
165
+ integration: new apigwv2integrations.HttpLambdaIntegration(`Apis${toPascalCase(api.id)}Integration`, fn),
166
+ };
167
+ let apiRoute;
168
+ if (api.authType === 'jwt' && jwtAuthorizer) {
169
+ apiRoute = { ...apiRouteBase, authorizer: jwtAuthorizer };
170
+ }
171
+ else if (api.authType === 'api-key') {
172
+ // TODO(#1785): wire HttpApiKeyAuthorizer
173
+ apiRoute = apiRouteBase;
174
+ }
175
+ else {
176
+ apiRoute = apiRouteBase;
177
+ }
178
+ this.httpApi.addRoutes(apiRoute);
179
+ }
180
+ }
181
+ // Deploy webhooks as grouped Lambdas
182
+ if (registry.webhooks.length > 0) {
183
+ // Create shared dedupe table for webhooks
184
+ const dedupeTable = new dynamodb.Table(this, 'WebhookDedupeTable', {
185
+ partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
186
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
187
+ timeToLiveAttribute: 'expiresAt',
188
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
189
+ });
190
+ webhookLambdas = createGroupedLambdas(this, {
191
+ domainId,
192
+ primitiveType: 'webhook',
193
+ handlerEntries: registry.webhooks.map(webhook => ({
194
+ id: webhook.id,
195
+ handlerFile: webhook.handlerFile,
196
+ })),
197
+ environment,
198
+ eventBusArn,
199
+ dedicated: registry.webhooks.some(webhook => webhook.deployment?.isolation === 'dedicated'),
200
+ reservedConcurrency,
201
+ logRetentionDays: logRetentionDays ?? 30,
202
+ ...vpcProps,
203
+ });
204
+ this.lambdaArns[`${domainId}-webhook`] = webhookLambdas[0].functionArn;
205
+ // Wire each webhook Lambda to the HttpApi
206
+ const iamPolicies = iamBuilder.forWebhook({ dedupeTableArn: dedupeTable.tableArn });
207
+ webhookLambdas.forEach((fn) => {
208
+ iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
209
+ dedupeTable.grantReadWriteData(fn);
210
+ });
211
+ // Add dbSecretArn grant if provided
212
+ if (dbSecretArn) {
213
+ webhookLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
214
+ actions: ['secretsmanager:GetSecretValue'],
215
+ resources: [dbSecretArn],
216
+ })));
217
+ }
218
+ // Add routes for each webhook entry
219
+ for (const webhook of registry.webhooks) {
220
+ const fn = webhookLambdas[0]; // Grouped Lambda or first dedicated
221
+ this.httpApi.addRoutes({
222
+ path: webhook.path,
223
+ methods: [apigwv2.HttpMethod.POST],
224
+ integration: new apigwv2integrations.HttpLambdaIntegration(`Webhooks${toPascalCase(webhook.id)}Integration`, fn),
225
+ });
226
+ }
227
+ }
228
+ // Deploy event subscribers as grouped Lambdas
229
+ if (registry.subscribers.length > 0) {
230
+ subscriberLambdas = createGroupedLambdas(this, {
231
+ domainId,
232
+ primitiveType: 'subscriber',
233
+ handlerEntries: registry.subscribers.map(subscriber => ({
234
+ id: subscriber.id,
235
+ handlerFile: subscriber.handlerFile,
236
+ })),
237
+ environment,
238
+ eventBusArn,
239
+ dedicated: registry.subscribers.some(subscriber => subscriber.deployment?.isolation === 'dedicated'),
240
+ reservedConcurrency,
241
+ logRetentionDays: logRetentionDays ?? 30,
242
+ ...vpcProps,
243
+ });
244
+ this.lambdaArns[`${domainId}-subscriber`] = subscriberLambdas[0].functionArn;
245
+ // Wire EventBridge → SQS → Lambda for each subscriber
246
+ for (const subscriber of registry.subscribers) {
247
+ const pascalId = toPascalCase(subscriber.id);
248
+ // Create dead-letter queue
249
+ const dlq = new sqs.Queue(this, `${pascalId}Dlq`, {
250
+ retentionPeriod: cdk.Duration.days(14),
251
+ encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
252
+ encryptionMasterKey: enableCmk ? cmkKey : undefined,
253
+ });
254
+ allDlqs.push(dlq);
255
+ // Create main queue with dead-letter queue configuration
256
+ const queue = new sqs.Queue(this, `${pascalId}Queue`, {
257
+ visibilityTimeout: cdk.Duration.seconds(180),
258
+ deadLetterQueue: {
259
+ queue: dlq,
260
+ maxReceiveCount: 5,
261
+ },
262
+ encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
263
+ encryptionMasterKey: enableCmk ? cmkKey : undefined,
264
+ });
265
+ // Add SQS as event source for the first grouped Lambda
266
+ const fn = subscriberLambdas[0];
267
+ fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
268
+ batchSize: 10,
269
+ maxConcurrency: subscriber.concurrency ?? 5,
270
+ }));
271
+ // Grant IAM permissions for this queue
272
+ const queuePolicies = iamBuilder.forSubscriber({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
273
+ queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
274
+ // Create EventBridge rule targeting SQS queue
275
+ new events.Rule(this, `${pascalId}Rule`, {
276
+ eventBus,
277
+ eventPattern: { detailType: [subscriber.event] },
278
+ targets: [new eventsTargets.SqsQueue(queue)],
279
+ });
280
+ }
281
+ // Add dbSecretArn grant if provided
282
+ if (dbSecretArn) {
283
+ subscriberLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
284
+ actions: ['secretsmanager:GetSecretValue'],
285
+ resources: [dbSecretArn],
286
+ })));
287
+ }
288
+ }
289
+ // Deploy scheduled tasks as grouped Lambdas
290
+ if (registry.schedules.length > 0) {
291
+ scheduleLambdas = createGroupedLambdas(this, {
292
+ domainId,
293
+ primitiveType: 'schedule',
294
+ handlerEntries: registry.schedules.map(schedule => ({
295
+ id: schedule.id,
296
+ handlerFile: schedule.handlerFile,
297
+ })),
298
+ environment,
299
+ eventBusArn,
300
+ dedicated: registry.schedules.some(schedule => schedule.deployment?.isolation === 'dedicated'),
301
+ reservedConcurrency,
302
+ logRetentionDays: logRetentionDays ?? 30,
303
+ ...vpcProps,
304
+ });
305
+ this.lambdaArns[`${domainId}-schedule`] = scheduleLambdas[0].functionArn;
306
+ const iamPolicies = iamBuilder.forSchedule();
307
+ scheduleLambdas.forEach((fn) => {
308
+ iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
309
+ });
310
+ // Add dbSecretArn grant if provided
311
+ if (dbSecretArn) {
312
+ scheduleLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
313
+ actions: ['secretsmanager:GetSecretValue'],
314
+ resources: [dbSecretArn],
315
+ })));
316
+ }
317
+ // Wire EventBridge Scheduler rules to Lambda
318
+ for (const schedule of registry.schedules) {
319
+ const pascalId = toPascalCase(schedule.id);
320
+ const fn = scheduleLambdas[0]; // Grouped Lambda or first dedicated
321
+ // Create a dedicated IAM role for the EventBridge Scheduler to assume
322
+ const schedulerRole = new iam.Role(this, `${pascalId}SchedulerRole`, {
323
+ assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'),
324
+ });
325
+ // Grant the scheduler role permission to invoke the Lambda
326
+ fn.grantInvoke(schedulerRole);
327
+ // Create the EventBridge Scheduler rule
328
+ new scheduler.CfnSchedule(this, `${pascalId}Schedule`, {
329
+ scheduleExpression: schedule.cron,
330
+ flexibleTimeWindow: { mode: 'OFF' },
331
+ state: schedule.enabled ? 'ENABLED' : 'DISABLED',
332
+ target: {
333
+ arn: fn.functionArn,
334
+ roleArn: schedulerRole.roleArn,
335
+ input: JSON.stringify({}),
336
+ },
337
+ });
338
+ }
339
+ }
340
+ // Deploy background jobs as grouped Lambdas
341
+ if (registry.jobs.length > 0) {
342
+ jobLambdas = createGroupedLambdas(this, {
343
+ domainId,
344
+ primitiveType: 'job',
345
+ handlerEntries: registry.jobs.map(job => ({
346
+ id: job.id,
347
+ handlerFile: job.handlerFile,
348
+ })),
349
+ environment,
350
+ eventBusArn,
351
+ dedicated: registry.jobs.some(job => job.deployment?.isolation === 'dedicated'),
352
+ reservedConcurrency,
353
+ logRetentionDays: logRetentionDays ?? 30,
354
+ ...vpcProps,
355
+ });
356
+ this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
357
+ // Wire SQS → Lambda for each job
358
+ for (const job of registry.jobs) {
359
+ const pascalId = toPascalCase(job.id);
360
+ // Create dead-letter queue
361
+ const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
362
+ retentionPeriod: cdk.Duration.days(14),
363
+ encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
364
+ encryptionMasterKey: enableCmk ? cmkKey : undefined,
365
+ });
366
+ allDlqs.push(dlq);
367
+ // Create main queue with dead-letter queue configuration
368
+ const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
369
+ visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds),
370
+ deadLetterQueue: {
371
+ queue: dlq,
372
+ maxReceiveCount: 5,
373
+ },
374
+ encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
375
+ encryptionMasterKey: enableCmk ? cmkKey : undefined,
376
+ });
377
+ // Add SQS as event source for the first grouped Lambda
378
+ const fn = jobLambdas[0];
379
+ fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
380
+ batchSize: 1,
381
+ }));
382
+ // Grant IAM permissions for this queue
383
+ const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
384
+ queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
385
+ }
386
+ // Add dbSecretArn grant if provided
387
+ if (dbSecretArn) {
388
+ jobLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
389
+ actions: ['secretsmanager:GetSecretValue'],
390
+ resources: [dbSecretArn],
391
+ })));
392
+ }
393
+ }
394
+ // Deploy callable actions as grouped Lambdas
395
+ if (registry.actions.length > 0) {
396
+ const actionLambdas = createGroupedLambdas(this, {
397
+ domainId,
398
+ primitiveType: 'action',
399
+ handlerEntries: registry.actions.map(action => ({
400
+ id: action.id,
401
+ handlerFile: action.handlerFile,
402
+ })),
403
+ environment,
404
+ eventBusArn,
405
+ dedicated: registry.actions.some(action => action.deployment?.isolation === 'dedicated'),
406
+ reservedConcurrency,
407
+ logRetentionDays: logRetentionDays ?? 30,
408
+ ...vpcProps,
409
+ });
410
+ this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
411
+ const iamPolicies = iamBuilder.forAction([
412
+ `arn:aws:lambda:${this.region}:${this.account}:function:TIB-*-domain-*-action-*`,
413
+ ]);
414
+ actionLambdas.forEach((fn) => {
415
+ iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
416
+ });
417
+ // Add dbSecretArn grant if provided
418
+ if (dbSecretArn) {
419
+ actionLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
420
+ actions: ['secretsmanager:GetSecretValue'],
421
+ resources: [dbSecretArn],
422
+ })));
423
+ }
424
+ }
425
+ // Grant all domain Lambdas read/write access to the per-domain table and bucket
426
+ const allDomainLambdas = [
427
+ ...apiLambdas, ...webhookLambdas, ...subscriberLambdas,
428
+ ...scheduleLambdas, ...jobLambdas, ...actionLambdas,
429
+ ];
430
+ for (const fn of allDomainLambdas) {
431
+ domainTable.grantReadWriteData(fn);
432
+ domainBucket.grantReadWrite(fn);
433
+ }
434
+ // Replace the tenant-scoped role trust policy — only domain Lambdas can assume with tenantId tag
435
+ const cfnRole = tenantScopedRole.node.defaultChild;
436
+ cfnRole.assumeRolePolicyDocument = cdk.Lazy.any({
437
+ produce: () => ({
438
+ Version: '2012-10-17',
439
+ Statement: allDomainLambdas.map(fn => ({
440
+ Effect: 'Allow',
441
+ Principal: { AWS: fn.role.roleArn },
442
+ Action: ['sts:AssumeRole', 'sts:TagSession'],
443
+ Condition: {
444
+ StringLike: { 'aws:RequestTag/tenantId': '*' },
445
+ 'ForAllValues:StringEquals': { 'sts:TransitiveTagKeys': ['tenantId'] },
446
+ },
447
+ })),
448
+ }),
449
+ });
450
+ // Attach tenant-scoped storage policy to the tenant-scoped role
451
+ const tenantStoragePolicies = iamBuilder.forTenantScopedStorage({
452
+ tableArn: domainTable.tableArn,
453
+ bucketArn: domainBucket.bucketArn,
454
+ });
455
+ tenantStoragePolicies.forEach(statement => tenantScopedRole.addToPolicy(statement));
456
+ // Grant each Lambda execution role permission to assume the tenant-scoped role
457
+ allDomainLambdas.forEach(fn => {
458
+ fn.role.addToPrincipalPolicy(new iam.PolicyStatement({
459
+ actions: ['sts:AssumeRole', 'sts:TagSession'],
460
+ resources: [tenantScopedRole.roleArn],
461
+ }));
462
+ });
463
+ // Optional CloudWatch alarms
464
+ let alarmConstruct;
465
+ if (enableAlarms) {
466
+ alarmConstruct = new AlarmConstruct(this, 'Alarms', {
467
+ domainId,
468
+ lambdaFunctions: allDomainLambdas,
469
+ dlqs: allDlqs,
470
+ snsTopicArn: alarmSnsTopicArn,
471
+ });
472
+ }
473
+ // Optional canary deployments — 10% traffic shift, auto-rollback on alarm breach
474
+ // Note: requires enableAlarms to be true for auto-rollback to work
475
+ if (enableCanaryDeploy) {
476
+ new CanaryConstruct(this, 'Canary', {
477
+ domainId,
478
+ lambdaFunctions: allDomainLambdas,
479
+ rollbackAlarms: alarmConstruct?.alarms ?? [],
480
+ });
481
+ }
482
+ // Deploy built-in health and readiness checks
483
+ const healthConstruct = new HealthConstruct(this, 'Health', {
484
+ domainId,
485
+ environment: {
486
+ EVENT_BUS_ARN: eventBusArn,
487
+ DB_SECRET_ARN: dbSecretArn ?? '',
488
+ },
489
+ });
490
+ this.httpApi.addRoutes({
491
+ path: '/_health',
492
+ methods: [apigwv2.HttpMethod.GET],
493
+ integration: new apigwv2integrations.HttpLambdaIntegration('HealthIntegration', healthConstruct.healthFn),
494
+ });
495
+ this.httpApi.addRoutes({
496
+ path: '/_ready',
497
+ methods: [apigwv2.HttpMethod.GET],
498
+ integration: new apigwv2integrations.HttpLambdaIntegration('ReadyIntegration', healthConstruct.readyFn),
499
+ });
500
+ // Create CloudWatch dashboard for all primitives
501
+ const allEndpoints = [
502
+ ...registry.apis.map(a => ({ id: a.id, primitiveClass: 'api' })),
503
+ ...registry.actions.map(a => ({ id: a.id, primitiveClass: 'action' })),
504
+ ...registry.subscribers.map(s => ({ id: s.id, primitiveClass: 'subscriber' })),
505
+ ...registry.jobs.map(j => ({ id: j.id, primitiveClass: 'job' })),
506
+ ...registry.webhooks.map(w => ({ id: w.id, primitiveClass: 'webhook' })),
507
+ ...registry.schedules.map(sc => ({ id: sc.id, primitiveClass: 'schedule' })),
508
+ ];
509
+ new DashboardConstruct(this, 'Dashboard', {
510
+ domainId,
511
+ envCode: this.stackName.split('-')[1] ?? 'dev',
512
+ endpoints: allEndpoints,
513
+ });
514
+ new cdk.CfnOutput(this, 'DomainTableName', {
515
+ value: domainTable.tableName,
516
+ description: 'Per-domain DynamoDB table name',
517
+ });
518
+ new cdk.CfnOutput(this, 'DomainBucketName', {
519
+ value: domainBucket.bucketName,
520
+ description: 'Per-domain S3 bucket name',
521
+ });
522
+ new cdk.CfnOutput(this, 'HttpApiUrl', {
523
+ value: this.httpApi.apiEndpoint,
524
+ description: 'Domain HTTP API endpoint',
525
+ });
526
+ new cdk.CfnOutput(this, 'DomainTenantScopedRoleArn', {
527
+ value: tenantScopedRole.roleArn,
528
+ description: 'Per-domain tenant-scoped IAM role ARN',
529
+ });
530
+ }
531
+ }
532
+ /**
533
+ * Convert kebab-case or snake_case string to PascalCase.
534
+ * @param s - Input string.
535
+ * @returns PascalCase string.
536
+ */
537
+ function toPascalCase(s) {
538
+ return s
539
+ .split(/[-_]/)
540
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
541
+ .join('');
542
+ }
543
+ /**
544
+ * Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
545
+ * @param method - HTTP method string (e.g., 'GET', 'POST').
546
+ * @returns HttpMethod enum value.
547
+ */
548
+ function toHttpMethod(method) {
549
+ switch (method.toUpperCase()) {
550
+ case 'GET':
551
+ return apigwv2.HttpMethod.GET;
552
+ case 'POST':
553
+ return apigwv2.HttpMethod.POST;
554
+ case 'PUT':
555
+ return apigwv2.HttpMethod.PUT;
556
+ case 'PATCH':
557
+ return apigwv2.HttpMethod.PATCH;
558
+ case 'DELETE':
559
+ return apigwv2.HttpMethod.DELETE;
560
+ case 'HEAD':
561
+ return apigwv2.HttpMethod.HEAD;
562
+ case 'OPTIONS':
563
+ return apigwv2.HttpMethod.OPTIONS;
564
+ default:
565
+ return apigwv2.HttpMethod.ANY;
566
+ }
567
+ }
@@ -0,0 +1 @@
1
+ export {};