@semiont/cli 0.1.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 (36) hide show
  1. package/README.md +497 -0
  2. package/dist/cli.mjs +45524 -0
  3. package/dist/dashboard/dashboard.css +238 -0
  4. package/dist/dashboard/dashboard.js +14 -0
  5. package/dist/dashboard/dashboard.js.map +7 -0
  6. package/dist/mcp-server/handlers-stubs.d.ts +40 -0
  7. package/dist/mcp-server/handlers-stubs.d.ts.map +1 -0
  8. package/dist/mcp-server/handlers-stubs.js +22 -0
  9. package/dist/mcp-server/handlers-stubs.js.map +1 -0
  10. package/dist/mcp-server/handlers.d.ts +96 -0
  11. package/dist/mcp-server/handlers.d.ts.map +1 -0
  12. package/dist/mcp-server/handlers.js +253 -0
  13. package/dist/mcp-server/handlers.js.map +1 -0
  14. package/dist/mcp-server/index.d.ts +3 -0
  15. package/dist/mcp-server/index.d.ts.map +1 -0
  16. package/dist/mcp-server/index.js +365 -0
  17. package/dist/mcp-server/index.js.map +1 -0
  18. package/dist/mcp-server/index.test.d.ts +7 -0
  19. package/dist/mcp-server/index.test.d.ts.map +1 -0
  20. package/dist/mcp-server/index.test.js +183 -0
  21. package/dist/mcp-server/index.test.js.map +1 -0
  22. package/dist/templates/cdk/app-stack.ts +893 -0
  23. package/dist/templates/cdk/app.ts +54 -0
  24. package/dist/templates/cdk/data-stack.ts +416 -0
  25. package/dist/templates/cdk.json +36 -0
  26. package/dist/templates/environments/ci.json +52 -0
  27. package/dist/templates/environments/local.json +82 -0
  28. package/dist/templates/environments/production.json +57 -0
  29. package/dist/templates/environments/staging.json +49 -0
  30. package/dist/templates/environments/test.json +61 -0
  31. package/dist/templates/inference-claude.json +14 -0
  32. package/dist/templates/inference-openai.json +16 -0
  33. package/dist/templates/package.json +23 -0
  34. package/dist/templates/semiont.json +31 -0
  35. package/dist/templates/tsconfig.json +27 -0
  36. package/package.json +91 -0
@@ -0,0 +1,893 @@
1
+ import * as cdk from 'aws-cdk-lib';
2
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
3
+ import * as rds from 'aws-cdk-lib/aws-rds';
4
+ import * as ecs from 'aws-cdk-lib/aws-ecs';
5
+ import * as ecr from 'aws-cdk-lib/aws-ecr';
6
+ import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
7
+ import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
8
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
9
+ import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
10
+ import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
11
+ import * as sns from 'aws-cdk-lib/aws-sns';
12
+ import * as budgets from 'aws-cdk-lib/aws-budgets';
13
+ import * as iam from 'aws-cdk-lib/aws-iam';
14
+ import * as logs from 'aws-cdk-lib/aws-logs';
15
+ import * as route53 from 'aws-cdk-lib/aws-route53';
16
+ import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
17
+ import * as acm from 'aws-cdk-lib/aws-certificatemanager';
18
+ import * as efs from 'aws-cdk-lib/aws-efs';
19
+ import { Construct } from 'constructs';
20
+
21
+ interface SemiontAppStackProps extends cdk.StackProps {
22
+ // No longer passing infra resources as props
23
+ // They will be imported via CloudFormation exports
24
+ }
25
+
26
+ export class SemiontAppStack extends cdk.Stack {
27
+ constructor(scope: Construct, id: string, props: SemiontAppStackProps) {
28
+ super(scope, id, props);
29
+
30
+ // Import resources from data stack using CloudFormation exports
31
+ const dataStackName = 'SemiontDataStack';
32
+
33
+ // Import VPC - we need to use fromVpcAttributes since fromLookup doesn't work with tokens
34
+ // We're using 2 AZs, so explicitly specify them
35
+ // Note: CDK will show warnings about missing routeTableIds. These warnings can be ignored
36
+ // as we're importing an existing VPC and not modifying routes. The warnings are due to
37
+ // CDK's limitation when importing VPCs via CloudFormation exports.
38
+ const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
39
+ vpcId: cdk.Fn.importValue(`${dataStackName}-VpcId`),
40
+ availabilityZones: ['us-east-2a', 'us-east-2b'], // First 2 AZs in us-east-2
41
+ publicSubnetIds: [
42
+ cdk.Fn.importValue(`${dataStackName}-PublicSubnet1Id`),
43
+ cdk.Fn.importValue(`${dataStackName}-PublicSubnet2Id`),
44
+ ],
45
+ privateSubnetIds: [
46
+ cdk.Fn.importValue(`${dataStackName}-PrivateSubnet1Id`),
47
+ cdk.Fn.importValue(`${dataStackName}-PrivateSubnet2Id`),
48
+ ],
49
+ });
50
+
51
+ // Import Security Groups
52
+ const dbSecurityGroup = ec2.SecurityGroup.fromSecurityGroupId(
53
+ this,
54
+ 'ImportedDbSecurityGroup',
55
+ cdk.Fn.importValue(`${dataStackName}-DbSecurityGroupId`)
56
+ );
57
+
58
+ const ecsSecurityGroup = ec2.SecurityGroup.fromSecurityGroupId(
59
+ this,
60
+ 'ImportedEcsSecurityGroup',
61
+ cdk.Fn.importValue(`${dataStackName}-EcsSecurityGroupId`)
62
+ );
63
+
64
+ const albSecurityGroup = ec2.SecurityGroup.fromSecurityGroupId(
65
+ this,
66
+ 'ImportedAlbSecurityGroup',
67
+ cdk.Fn.importValue(`${dataStackName}-AlbSecurityGroupId`)
68
+ );
69
+
70
+ // Import EFS FileSystem
71
+ const fileSystem = efs.FileSystem.fromFileSystemAttributes(this, 'ImportedFileSystem', {
72
+ fileSystemId: cdk.Fn.importValue(`${dataStackName}-EfsFileSystemId`),
73
+ securityGroup: ecsSecurityGroup,
74
+ });
75
+
76
+ // Import Database (for endpoint reference)
77
+ const databaseEndpoint = cdk.Fn.importValue(`${dataStackName}-DatabaseEndpoint`);
78
+ const databasePort = cdk.Fn.importValue(`${dataStackName}-DatabasePort`);
79
+
80
+ // Import Secrets
81
+ const dbCredentials = secretsmanager.Secret.fromSecretCompleteArn(
82
+ this,
83
+ 'ImportedDbCredentials',
84
+ cdk.Fn.importValue(`${dataStackName}-DbCredentialsSecretArn`)
85
+ );
86
+
87
+ const appSecrets = secretsmanager.Secret.fromSecretCompleteArn(
88
+ this,
89
+ 'ImportedAppSecrets',
90
+ cdk.Fn.importValue(`${dataStackName}-AppSecretsSecretArn`)
91
+ );
92
+
93
+ const jwtSecret = secretsmanager.Secret.fromSecretCompleteArn(
94
+ this,
95
+ 'ImportedJwtSecret',
96
+ cdk.Fn.importValue(`${dataStackName}-JwtSecretArn`)
97
+ );
98
+
99
+ const adminPassword = secretsmanager.Secret.fromSecretCompleteArn(
100
+ this,
101
+ 'ImportedAdminPassword',
102
+ cdk.Fn.importValue(`${dataStackName}-AdminPasswordSecretArn`)
103
+ );
104
+
105
+ const googleOAuth = secretsmanager.Secret.fromSecretCompleteArn(
106
+ this,
107
+ 'ImportedGoogleOAuth',
108
+ cdk.Fn.importValue(`${dataStackName}-GoogleOAuthSecretArn`)
109
+ );
110
+
111
+ const githubOAuth = secretsmanager.Secret.fromSecretCompleteArn(
112
+ this,
113
+ 'ImportedGitHubOAuth',
114
+ cdk.Fn.importValue(`${dataStackName}-GitHubOAuthSecretArn`)
115
+ );
116
+
117
+ const adminEmails = secretsmanager.Secret.fromSecretCompleteArn(
118
+ this,
119
+ 'ImportedAdminEmails',
120
+ cdk.Fn.importValue(`${dataStackName}-AdminEmailsSecretArn`)
121
+ );
122
+
123
+ // Get configuration from CDK context
124
+ const siteName = this.node.tryGetContext('siteName') || 'Semiont';
125
+ const domainName = this.node.tryGetContext('domain') || 'example.com';
126
+ const rootDomain = this.node.tryGetContext('rootDomain') || 'example.com';
127
+ const oauthAllowedDomains = this.node.tryGetContext('oauthAllowedDomains') || ['example.com'];
128
+ const databaseName = this.node.tryGetContext('databaseName') || 'semiont';
129
+ const awsCertificateArn = this.node.tryGetContext('certificateArn');
130
+ const awsHostedZoneId = this.node.tryGetContext('hostedZoneId');
131
+
132
+ const certificateArn = new cdk.CfnParameter(this, 'CertificateArn', {
133
+ type: 'String',
134
+ default: awsCertificateArn,
135
+ description: 'ACM Certificate ARN for HTTPS'
136
+ });
137
+
138
+ const hostedZoneId = new cdk.CfnParameter(this, 'HostedZoneId', {
139
+ type: 'String',
140
+ default: awsHostedZoneId,
141
+ description: 'Route53 Hosted Zone ID'
142
+ });
143
+
144
+ // ECS Cluster with Service Connect
145
+ const cluster = new ecs.Cluster(this, 'SemiontCluster', {
146
+ vpc,
147
+ defaultCloudMapNamespace: {
148
+ name: 'semiont.local',
149
+ },
150
+ });
151
+
152
+ // Enable Container Insights
153
+ cluster.enableFargateCapacityProviders();
154
+
155
+ // CloudWatch Log Group
156
+ const logGroup = new logs.LogGroup(this, 'SemiontLogGroup', {
157
+ retention: logs.RetentionDays.ONE_MONTH,
158
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
159
+ });
160
+
161
+ // Backend Task Definition
162
+ // Note: CDK may show warnings about deprecated inferenceAccelerators property.
163
+ // This is a known CDK bug (https://github.com/aws/aws-cdk/issues/11339) where CDK internally
164
+ // uses a deprecated CloudFormation property. The warning can be safely ignored.
165
+ const backendTaskDefinition = new ecs.FargateTaskDefinition(this, 'SemiontBackendTaskDef', {
166
+ memoryLimitMiB: 512,
167
+ cpu: 256,
168
+ });
169
+
170
+ // Frontend Task Definition
171
+ const frontendTaskDefinition = new ecs.FargateTaskDefinition(this, 'SemiontFrontendTaskDef', {
172
+ memoryLimitMiB: 512,
173
+ cpu: 256,
174
+ });
175
+
176
+ // Add EFS volume to backend task definition for uploads
177
+ const efsVolumeConfig: ecs.EfsVolumeConfiguration = {
178
+ fileSystemId: fileSystem.fileSystemId,
179
+ transitEncryption: 'DISABLED',
180
+ authorizationConfig: {
181
+ iam: 'DISABLED',
182
+ },
183
+ };
184
+
185
+ backendTaskDefinition.addVolume({
186
+ name: 'efs-volume',
187
+ efsVolumeConfiguration: efsVolumeConfig,
188
+ });
189
+
190
+ // IAM role for backend tasks to access Secrets Manager
191
+ backendTaskDefinition.taskRole.addToPrincipalPolicy(
192
+ new iam.PolicyStatement({
193
+ effect: iam.Effect.ALLOW,
194
+ actions: [
195
+ 'secretsmanager:GetSecretValue',
196
+ ],
197
+ resources: [
198
+ dbCredentials.secretArn,
199
+ appSecrets.secretArn,
200
+ jwtSecret.secretArn,
201
+ adminPassword.secretArn,
202
+ googleOAuth.secretArn,
203
+ adminEmails.secretArn,
204
+ ],
205
+ })
206
+ );
207
+
208
+ // IAM role for frontend tasks (minimal permissions)
209
+ frontendTaskDefinition.taskRole.addToPrincipalPolicy(
210
+ new iam.PolicyStatement({
211
+ effect: iam.Effect.ALLOW,
212
+ actions: [
213
+ 'secretsmanager:GetSecretValue',
214
+ ],
215
+ resources: [
216
+ appSecrets.secretArn,
217
+ googleOAuth.secretArn,
218
+ ],
219
+ })
220
+ );
221
+
222
+ // Add EFS permissions to backend task role
223
+ backendTaskDefinition.taskRole.addToPrincipalPolicy(
224
+ new iam.PolicyStatement({
225
+ effect: iam.Effect.ALLOW,
226
+ actions: [
227
+ 'elasticfilesystem:ClientMount',
228
+ 'elasticfilesystem:ClientWrite',
229
+ 'elasticfilesystem:ClientRootAccess',
230
+ ],
231
+ resources: [fileSystem.fileSystemArn],
232
+ })
233
+ );
234
+
235
+ // Add ECS Exec permissions to backend task role
236
+ backendTaskDefinition.taskRole.addToPrincipalPolicy(
237
+ new iam.PolicyStatement({
238
+ effect: iam.Effect.ALLOW,
239
+ actions: [
240
+ 'ssmmessages:CreateControlChannel',
241
+ 'ssmmessages:CreateDataChannel',
242
+ 'ssmmessages:OpenControlChannel',
243
+ 'ssmmessages:OpenDataChannel',
244
+ ],
245
+ resources: ['*'],
246
+ })
247
+ );
248
+
249
+ // Add Neptune permissions for graph database access
250
+ backendTaskDefinition.taskRole.addToPrincipalPolicy(
251
+ new iam.PolicyStatement({
252
+ effect: iam.Effect.ALLOW,
253
+ actions: [
254
+ 'neptune-db:*',
255
+ 'rds:DescribeDBClusters',
256
+ 'rds:DescribeDBInstances',
257
+ ],
258
+ resources: ['*'],
259
+ })
260
+ );
261
+
262
+ // Add ECS Exec permissions to frontend task role
263
+ frontendTaskDefinition.taskRole.addToPrincipalPolicy(
264
+ new iam.PolicyStatement({
265
+ effect: iam.Effect.ALLOW,
266
+ actions: [
267
+ 'ssmmessages:CreateControlChannel',
268
+ 'ssmmessages:CreateDataChannel',
269
+ 'ssmmessages:OpenControlChannel',
270
+ 'ssmmessages:OpenDataChannel',
271
+ ],
272
+ resources: ['*'],
273
+ })
274
+ );
275
+
276
+ // Get environment from context
277
+ const environment = this.node.tryGetContext('environment') || 'production';
278
+
279
+ // Backend container - use ECR image or default
280
+ const backendImageUri = this.node.tryGetContext('backendImageUri');
281
+ const backendRepoName = `semiont-backend`;
282
+ const backendImage = backendImageUri
283
+ ? ecs.ContainerImage.fromEcrRepository(
284
+ ecr.Repository.fromRepositoryName(this, 'BackendEcrRepo', backendRepoName),
285
+ backendImageUri.split(':')[1] || 'latest'
286
+ )
287
+ : ecs.ContainerImage.fromEcrRepository(
288
+ ecr.Repository.fromRepositoryName(this, 'BackendEcrRepoDefault', backendRepoName),
289
+ 'latest'
290
+ );
291
+
292
+ const backendContainer = backendTaskDefinition.addContainer('semiont-backend', {
293
+ image: backendImage,
294
+ environment: {
295
+ NODE_ENV: this.node.tryGetContext('nodeEnv') || 'production',
296
+ DEPLOYMENT_VERSION: new Date().toISOString(), // Forces new task definition on every deploy
297
+ DB_HOST: databaseEndpoint,
298
+ DB_PORT: '5432',
299
+ DB_NAME: databaseName,
300
+ PORT: '4000',
301
+ API_PORT: '4000',
302
+ CORS_ORIGIN: `https://${domainName}`,
303
+ FRONTEND_URL: `https://${domainName}`,
304
+ AWS_REGION: this.region,
305
+ // Configuration for OAuth
306
+ SITE_NAME: siteName,
307
+ DOMAIN: domainName,
308
+ OAUTH_ALLOWED_DOMAINS: Array.isArray(oauthAllowedDomains) ? oauthAllowedDomains.join(',') : oauthAllowedDomains,
309
+ SITE_DOMAIN: domainName,
310
+ },
311
+ secrets: {
312
+ DB_USER: ecs.Secret.fromSecretsManager(dbCredentials, 'username'),
313
+ DB_PASSWORD: ecs.Secret.fromSecretsManager(dbCredentials, 'password'),
314
+ JWT_SECRET: ecs.Secret.fromSecretsManager(jwtSecret, 'jwtSecret'),
315
+ SESSION_SECRET: ecs.Secret.fromSecretsManager(appSecrets, 'sessionSecret'),
316
+ GOOGLE_CLIENT_ID: ecs.Secret.fromSecretsManager(googleOAuth, 'clientId'),
317
+ GOOGLE_CLIENT_SECRET: ecs.Secret.fromSecretsManager(googleOAuth, 'clientSecret'),
318
+ },
319
+ logging: ecs.LogDrivers.awsLogs({
320
+ streamPrefix: 'semiont-backend',
321
+ logGroup,
322
+ }),
323
+ healthCheck: {
324
+ command: ['CMD-SHELL', 'curl -f http://localhost:4000/api/health || exit 1'],
325
+ interval: cdk.Duration.seconds(30),
326
+ timeout: cdk.Duration.seconds(5),
327
+ retries: 3,
328
+ startPeriod: cdk.Duration.minutes(1),
329
+ },
330
+ });
331
+
332
+ backendContainer.addPortMappings({
333
+ containerPort: 4000,
334
+ name: 'backend', // This name must match the portMappingName in Service Connect config
335
+ });
336
+
337
+ // Mount EFS volume for uploads
338
+ backendContainer.addMountPoints({
339
+ sourceVolume: 'efs-volume',
340
+ containerPath: '/app/uploads',
341
+ readOnly: false,
342
+ });
343
+
344
+ // Frontend container - use ECR image or default
345
+ const frontendImageUri = this.node.tryGetContext('frontendImageUri');
346
+ const frontendRepoName = `semiont-frontend`;
347
+ const frontendImage = frontendImageUri
348
+ ? ecs.ContainerImage.fromEcrRepository(
349
+ ecr.Repository.fromRepositoryName(this, 'FrontendEcrRepo', frontendRepoName),
350
+ frontendImageUri.split(':')[1] || 'latest'
351
+ )
352
+ : ecs.ContainerImage.fromEcrRepository(
353
+ ecr.Repository.fromRepositoryName(this, 'FrontendEcrRepoDefault', frontendRepoName),
354
+ 'latest'
355
+ );
356
+
357
+ const frontendContainer = frontendTaskDefinition.addContainer('semiont-frontend', {
358
+ image: frontendImage,
359
+ environment: {
360
+ NODE_ENV: this.node.tryGetContext('nodeEnv') || 'production',
361
+ DEPLOYMENT_VERSION: new Date().toISOString(), // Forces new task definition on every deploy
362
+ PORT: '3000',
363
+ HOSTNAME: '0.0.0.0',
364
+ // Public environment variables (available to browser)
365
+ NEXT_PUBLIC_API_URL: `https://${domainName}`,
366
+ NEXT_PUBLIC_SITE_NAME: siteName,
367
+ NEXT_PUBLIC_DOMAIN: domainName,
368
+ // OAuth domains for server-side NextAuth validation
369
+ OAUTH_ALLOWED_DOMAINS: Array.isArray(oauthAllowedDomains) ? oauthAllowedDomains.join(',') : oauthAllowedDomains,
370
+ // NextAuth configuration
371
+ NEXTAUTH_URL: `https://${domainName}`,
372
+ // Backend URL for server-side authentication calls (Service Connect DNS)
373
+ // Used by frontend's NextAuth to communicate with backend container-to-container
374
+ // This is NOT the public URL - it's the internal AWS Service Connect address
375
+ BACKEND_INTERNAL_URL: 'http://backend:4000',
376
+ },
377
+ secrets: {
378
+ NEXTAUTH_SECRET: ecs.Secret.fromSecretsManager(appSecrets, 'nextAuthSecret'),
379
+ GOOGLE_CLIENT_ID: ecs.Secret.fromSecretsManager(googleOAuth, 'clientId'),
380
+ GOOGLE_CLIENT_SECRET: ecs.Secret.fromSecretsManager(googleOAuth, 'clientSecret'),
381
+ },
382
+ logging: ecs.LogDrivers.awsLogs({
383
+ streamPrefix: 'semiont-frontend',
384
+ logGroup,
385
+ }),
386
+ healthCheck: {
387
+ command: ['CMD-SHELL', 'curl -f http://localhost:3000/ || exit 1'],
388
+ interval: cdk.Duration.seconds(30),
389
+ timeout: cdk.Duration.seconds(5),
390
+ retries: 3,
391
+ startPeriod: cdk.Duration.minutes(1),
392
+ },
393
+ });
394
+
395
+ frontendContainer.addPortMappings({
396
+ containerPort: 3000,
397
+ });
398
+
399
+ // Backend ECS Service
400
+ const backendService = new ecs.FargateService(this, 'SemiontBackendService', {
401
+ cluster,
402
+ taskDefinition: backendTaskDefinition,
403
+ desiredCount: 1,
404
+ vpcSubnets: {
405
+ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
406
+ },
407
+ securityGroups: [ecsSecurityGroup],
408
+ assignPublicIp: false,
409
+ healthCheckGracePeriod: cdk.Duration.minutes(2),
410
+ enableExecuteCommand: true,
411
+ circuitBreaker: {
412
+ rollback: true,
413
+ },
414
+ minHealthyPercent: 100,
415
+ maxHealthyPercent: 200,
416
+ serviceConnectConfiguration: {
417
+ services: [
418
+ {
419
+ portMappingName: 'backend',
420
+ dnsName: 'backend',
421
+ port: 4000,
422
+ },
423
+ ],
424
+ },
425
+ });
426
+
427
+ // Frontend ECS Service
428
+ const frontendService = new ecs.FargateService(this, 'SemiontFrontendService', {
429
+ cluster,
430
+ taskDefinition: frontendTaskDefinition,
431
+ desiredCount: 1,
432
+ vpcSubnets: {
433
+ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
434
+ },
435
+ securityGroups: [ecsSecurityGroup],
436
+ assignPublicIp: false,
437
+ healthCheckGracePeriod: cdk.Duration.minutes(2),
438
+ enableExecuteCommand: true,
439
+ circuitBreaker: {
440
+ rollback: true,
441
+ },
442
+ minHealthyPercent: 100,
443
+ maxHealthyPercent: 200,
444
+ serviceConnectConfiguration: {
445
+ // Frontend is a client-only service, it discovers backend but doesn't expose itself
446
+ services: [],
447
+ },
448
+ });
449
+
450
+ // Auto Scaling for Backend
451
+ const backendScaling = backendService.autoScaleTaskCount({
452
+ minCapacity: 1,
453
+ maxCapacity: 10,
454
+ });
455
+
456
+ backendScaling.scaleOnCpuUtilization('BackendCpuScaling', {
457
+ targetUtilizationPercent: 70,
458
+ });
459
+
460
+ backendScaling.scaleOnMemoryUtilization('BackendMemoryScaling', {
461
+ targetUtilizationPercent: 80,
462
+ });
463
+
464
+ // Auto Scaling for Frontend
465
+ const frontendScaling = frontendService.autoScaleTaskCount({
466
+ minCapacity: 1,
467
+ maxCapacity: 5,
468
+ });
469
+
470
+ frontendScaling.scaleOnCpuUtilization('FrontendCpuScaling', {
471
+ targetUtilizationPercent: 70,
472
+ });
473
+
474
+ frontendScaling.scaleOnMemoryUtilization('FrontendMemoryScaling', {
475
+ targetUtilizationPercent: 80,
476
+ });
477
+
478
+ // Route 53 Hosted Zone
479
+ const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
480
+ hostedZoneId: hostedZoneId.valueAsString,
481
+ zoneName: rootDomain,
482
+ });
483
+
484
+ // SSL Certificate
485
+ const certificate = acm.Certificate.fromCertificateArn(this, 'Certificate',
486
+ certificateArn.valueAsString
487
+ );
488
+
489
+ // Application Load Balancer
490
+ const alb = new elbv2.ApplicationLoadBalancer(this, 'SemiontALB', {
491
+ vpc,
492
+ internetFacing: true,
493
+ securityGroup: albSecurityGroup,
494
+ });
495
+
496
+ // HTTPS Listener
497
+ const httpsListener = alb.addListener('HttpsListener', {
498
+ port: 443,
499
+ open: true,
500
+ certificates: [certificate],
501
+ });
502
+
503
+ // NextAuth routes (Priority 10: /api/auth/* -> Frontend)
504
+ httpsListener.addTargets('NextAuth', {
505
+ port: 3000,
506
+ protocol: elbv2.ApplicationProtocol.HTTP,
507
+ targets: [frontendService],
508
+ conditions: [
509
+ elbv2.ListenerCondition.pathPatterns(['/api/auth/*']),
510
+ ],
511
+ healthCheck: {
512
+ path: '/',
513
+ interval: cdk.Duration.seconds(30),
514
+ timeout: cdk.Duration.seconds(10),
515
+ healthyThresholdCount: 2,
516
+ unhealthyThresholdCount: 5,
517
+ healthyHttpCodes: '200',
518
+ },
519
+ priority: 10,
520
+ });
521
+
522
+ // Backend API (Priority 20: /api/* -> Backend)
523
+ httpsListener.addTargets('BackendAPI', {
524
+ port: 4000,
525
+ protocol: elbv2.ApplicationProtocol.HTTP,
526
+ targets: [backendService],
527
+ conditions: [
528
+ elbv2.ListenerCondition.pathPatterns([
529
+ '/api', // API root
530
+ '/api/*' // All API routes
531
+ ]),
532
+ ],
533
+ healthCheck: {
534
+ path: '/api/health',
535
+ interval: cdk.Duration.seconds(30),
536
+ timeout: cdk.Duration.seconds(10),
537
+ healthyThresholdCount: 2,
538
+ unhealthyThresholdCount: 5,
539
+ healthyHttpCodes: '200',
540
+ },
541
+ priority: 20,
542
+ });
543
+
544
+ // Frontend target group (default action for all other paths)
545
+ httpsListener.addTargets('Frontend', {
546
+ port: 3000,
547
+ protocol: elbv2.ApplicationProtocol.HTTP,
548
+ targets: [frontendService],
549
+ healthCheck: {
550
+ path: '/',
551
+ interval: cdk.Duration.seconds(30),
552
+ timeout: cdk.Duration.seconds(10),
553
+ healthyThresholdCount: 2,
554
+ unhealthyThresholdCount: 5,
555
+ healthyHttpCodes: '200',
556
+ },
557
+ });
558
+
559
+ // HTTP Listener (redirect to HTTPS)
560
+ alb.addListener('Listener', {
561
+ port: 80,
562
+ open: true,
563
+ defaultAction: elbv2.ListenerAction.redirect({
564
+ protocol: 'HTTPS',
565
+ port: '443',
566
+ permanent: true,
567
+ }),
568
+ });
569
+
570
+ // Route 53 Record
571
+ new route53.ARecord(this, 'SemiontARecord', {
572
+ zone: hostedZone,
573
+ recordName: 'wiki',
574
+ target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(alb)),
575
+ });
576
+
577
+ // SNS Topic for alerts
578
+ const alertTopic = new sns.Topic(this, 'SemiontAlerts', {
579
+ displayName: 'Semiont Alerts',
580
+ });
581
+
582
+ // CloudWatch Alarms for Backend
583
+ const backendCpuAlarm = new cloudwatch.Alarm(this, 'BackendHighCPUAlarm', {
584
+ metric: backendService.metricCpuUtilization(),
585
+ threshold: 80,
586
+ evaluationPeriods: 2,
587
+ datapointsToAlarm: 2,
588
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
589
+ });
590
+
591
+ backendCpuAlarm.addAlarmAction(
592
+ new cloudwatchActions.SnsAction(alertTopic)
593
+ );
594
+
595
+ const backendMemoryAlarm = new cloudwatch.Alarm(this, 'BackendHighMemoryAlarm', {
596
+ metric: backendService.metricMemoryUtilization(),
597
+ threshold: 85,
598
+ evaluationPeriods: 2,
599
+ datapointsToAlarm: 2,
600
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
601
+ });
602
+
603
+ backendMemoryAlarm.addAlarmAction(
604
+ new cloudwatchActions.SnsAction(alertTopic)
605
+ );
606
+
607
+ // CloudWatch Alarms for Frontend
608
+ const frontendCpuAlarm = new cloudwatch.Alarm(this, 'FrontendHighCPUAlarm', {
609
+ metric: frontendService.metricCpuUtilization(),
610
+ threshold: 80,
611
+ evaluationPeriods: 2,
612
+ datapointsToAlarm: 2,
613
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
614
+ });
615
+
616
+ frontendCpuAlarm.addAlarmAction(
617
+ new cloudwatchActions.SnsAction(alertTopic)
618
+ );
619
+
620
+ const frontendMemoryAlarm = new cloudwatch.Alarm(this, 'FrontendHighMemoryAlarm', {
621
+ metric: frontendService.metricMemoryUtilization(),
622
+ threshold: 85,
623
+ evaluationPeriods: 2,
624
+ datapointsToAlarm: 2,
625
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
626
+ });
627
+
628
+ frontendMemoryAlarm.addAlarmAction(
629
+ new cloudwatchActions.SnsAction(alertTopic)
630
+ );
631
+
632
+ // Cost Budget
633
+ new budgets.CfnBudget(this, 'MonthlyBudget', {
634
+ budget: {
635
+ budgetName: 'Semiont Monthly Budget',
636
+ budgetLimit: {
637
+ amount: 200,
638
+ unit: 'USD',
639
+ },
640
+ timeUnit: 'MONTHLY',
641
+ budgetType: 'COST',
642
+ },
643
+ notificationsWithSubscribers: [
644
+ {
645
+ notification: {
646
+ notificationType: 'ACTUAL',
647
+ comparisonOperator: 'GREATER_THAN',
648
+ threshold: 80,
649
+ thresholdType: 'PERCENTAGE',
650
+ },
651
+ subscribers: [
652
+ {
653
+ subscriptionType: 'SNS',
654
+ address: alertTopic.topicArn,
655
+ },
656
+ ],
657
+ },
658
+ {
659
+ notification: {
660
+ notificationType: 'FORECASTED',
661
+ comparisonOperator: 'GREATER_THAN',
662
+ threshold: 100,
663
+ thresholdType: 'PERCENTAGE',
664
+ },
665
+ subscribers: [
666
+ {
667
+ subscriptionType: 'SNS',
668
+ address: alertTopic.topicArn,
669
+ },
670
+ ],
671
+ },
672
+ ],
673
+ });
674
+
675
+ // WAF Web ACL with enhanced exclusions for uploads
676
+ const webAcl = new wafv2.CfnWebACL(this, 'SemiontWAF', {
677
+ scope: 'REGIONAL',
678
+ defaultAction: { allow: {} },
679
+ rules: [
680
+ // Allow MCP OAuth callbacks with localhost (before other rules)
681
+ {
682
+ name: 'AllowMCPCallbacks',
683
+ priority: 0,
684
+ action: { allow: {} },
685
+ statement: {
686
+ andStatement: {
687
+ statements: [
688
+ {
689
+ byteMatchStatement: {
690
+ searchString: '/auth/mcp-setup',
691
+ fieldToMatch: { uriPath: {} },
692
+ textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
693
+ positionalConstraint: 'STARTS_WITH'
694
+ }
695
+ },
696
+ {
697
+ orStatement: {
698
+ statements: [
699
+ {
700
+ byteMatchStatement: {
701
+ searchString: 'localhost',
702
+ fieldToMatch: { queryString: {} },
703
+ textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
704
+ positionalConstraint: 'CONTAINS'
705
+ }
706
+ },
707
+ {
708
+ byteMatchStatement: {
709
+ searchString: '127.0.0.1',
710
+ fieldToMatch: { queryString: {} },
711
+ textTransformations: [{ priority: 0, type: 'NONE' }],
712
+ positionalConstraint: 'CONTAINS'
713
+ }
714
+ }
715
+ ]
716
+ }
717
+ }
718
+ ]
719
+ }
720
+ },
721
+ visibilityConfig: {
722
+ sampledRequestsEnabled: true,
723
+ cloudWatchMetricsEnabled: true,
724
+ metricName: 'MCPCallbackAllowMetric',
725
+ },
726
+ },
727
+ {
728
+ name: 'AWSManagedRulesCommonRuleSet',
729
+ priority: 10,
730
+ overrideAction: { none: {} },
731
+ statement: {
732
+ managedRuleGroupStatement: {
733
+ vendorName: 'AWS',
734
+ name: 'AWSManagedRulesCommonRuleSet',
735
+ excludedRules: [
736
+ { name: 'SizeRestrictions_BODY' },
737
+ { name: 'GenericRFI_BODY' },
738
+ { name: 'GenericRFI_QUERYARGUMENTS' },
739
+ { name: 'GenericRFI_URIPATH' },
740
+ { name: 'CrossSiteScripting_BODY' },
741
+ { name: 'RestrictedExtensions_URIPATH' },
742
+ { name: 'EC2MetaDataSSRF_BODY' },
743
+ { name: 'NoUserAgent_HEADER' },
744
+ ],
745
+ },
746
+ },
747
+ visibilityConfig: {
748
+ sampledRequestsEnabled: true,
749
+ cloudWatchMetricsEnabled: true,
750
+ metricName: 'CommonRuleSetMetric',
751
+ },
752
+ },
753
+ {
754
+ name: 'AWSManagedRulesKnownBadInputsRuleSet',
755
+ priority: 20,
756
+ overrideAction: { none: {} },
757
+ statement: {
758
+ managedRuleGroupStatement: {
759
+ vendorName: 'AWS',
760
+ name: 'AWSManagedRulesKnownBadInputsRuleSet',
761
+ excludedRules: [
762
+ { name: 'Host_localhost_HEADER' },
763
+ { name: 'PROPFIND_METHOD' },
764
+ { name: 'ExploitablePaths_URIPATH' },
765
+ ],
766
+ },
767
+ },
768
+ visibilityConfig: {
769
+ sampledRequestsEnabled: true,
770
+ cloudWatchMetricsEnabled: true,
771
+ metricName: 'KnownBadInputsRuleSetMetric',
772
+ },
773
+ },
774
+ {
775
+ name: 'RateLimitRule',
776
+ priority: 30,
777
+ action: { block: {} },
778
+ statement: {
779
+ rateBasedStatement: {
780
+ limit: 2000,
781
+ aggregateKeyType: 'IP',
782
+ },
783
+ },
784
+ visibilityConfig: {
785
+ sampledRequestsEnabled: true,
786
+ cloudWatchMetricsEnabled: true,
787
+ metricName: 'RateLimitMetric',
788
+ },
789
+ },
790
+ ],
791
+ visibilityConfig: {
792
+ sampledRequestsEnabled: true,
793
+ cloudWatchMetricsEnabled: true,
794
+ metricName: 'SemiontWAF',
795
+ },
796
+ });
797
+
798
+ // WAF association with ALB
799
+ new wafv2.CfnWebACLAssociation(this, 'WAFAssociation', {
800
+ resourceArn: alb.loadBalancerArn,
801
+ webAclArn: webAcl.attrArn,
802
+ });
803
+
804
+ // CloudWatch Dashboard
805
+ const dashboard = new cloudwatch.Dashboard(this, 'SemiontDashboard', {
806
+ dashboardName: 'Semiont-Monitoring',
807
+ });
808
+
809
+ dashboard.addWidgets(
810
+ new cloudwatch.GraphWidget({
811
+ title: 'Backend Service Metrics',
812
+ left: [backendService.metricCpuUtilization(), backendService.metricMemoryUtilization()],
813
+ width: 12,
814
+ }),
815
+ new cloudwatch.GraphWidget({
816
+ title: 'Frontend Service Metrics',
817
+ left: [frontendService.metricCpuUtilization(), frontendService.metricMemoryUtilization()],
818
+ width: 12,
819
+ }),
820
+ new cloudwatch.GraphWidget({
821
+ title: 'ALB Metrics',
822
+ left: [alb.metrics.requestCount(), alb.metrics.targetResponseTime()],
823
+ width: 12,
824
+ })
825
+ );
826
+
827
+ // Outputs
828
+ new cdk.CfnOutput(this, 'LoadBalancerDNS', {
829
+ value: alb.loadBalancerDnsName,
830
+ description: 'Application Load Balancer DNS Name',
831
+ });
832
+
833
+ new cdk.CfnOutput(this, 'SNSTopicArn', {
834
+ value: alertTopic.topicArn,
835
+ description: 'SNS Topic for alerts',
836
+ });
837
+
838
+ new cdk.CfnOutput(this, 'BackendTaskDefinitionArn', {
839
+ value: backendTaskDefinition.taskDefinitionArn,
840
+ description: 'Backend Task Definition ARN',
841
+ });
842
+
843
+ new cdk.CfnOutput(this, 'FrontendTaskDefinitionArn', {
844
+ value: frontendTaskDefinition.taskDefinitionArn,
845
+ description: 'Frontend Task Definition ARN',
846
+ });
847
+
848
+ new cdk.CfnOutput(this, 'ClusterName', {
849
+ value: cluster.clusterName,
850
+ description: 'ECS Cluster name',
851
+ });
852
+
853
+ new cdk.CfnOutput(this, 'BackendServiceName', {
854
+ value: backendService.serviceName,
855
+ description: 'Backend ECS Service name',
856
+ });
857
+
858
+ new cdk.CfnOutput(this, 'BackendServiceArn', {
859
+ value: backendService.serviceArn,
860
+ description: 'Backend ECS Service ARN',
861
+ });
862
+
863
+ new cdk.CfnOutput(this, 'FrontendServiceName', {
864
+ value: frontendService.serviceName,
865
+ description: 'Frontend ECS Service name',
866
+ });
867
+
868
+ new cdk.CfnOutput(this, 'FrontendServiceArn', {
869
+ value: frontendService.serviceArn,
870
+ description: 'Frontend ECS Service ARN',
871
+ });
872
+
873
+ new cdk.CfnOutput(this, 'LogGroupName', {
874
+ value: logGroup.logGroupName,
875
+ description: 'CloudWatch Log Group name',
876
+ });
877
+
878
+ new cdk.CfnOutput(this, 'CustomDomainUrl', {
879
+ value: `https://${domainName}`,
880
+ description: 'Semiont Custom Domain URL',
881
+ });
882
+
883
+ new cdk.CfnOutput(this, 'WAFWebACLArn', {
884
+ value: webAcl.attrArn,
885
+ description: 'WAF Web ACL ARN',
886
+ });
887
+
888
+ new cdk.CfnOutput(this, 'SiteName', {
889
+ value: siteName,
890
+ description: 'Semiont Site Name',
891
+ });
892
+ }
893
+ }