import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as sns from 'aws-cdk-lib/aws-sns'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; import * as bedrock from 'aws-cdk-lib/aws-bedrock'; import * as rds from 'aws-cdk-lib/aws-rds'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions'; import * as cr from 'aws-cdk-lib/custom-resources'; import { Construct } from 'constructs'; import { EnvConfig } from './config'; export interface ComputeStackProps extends cdk.StackProps { config: EnvConfig; vpc: ec2.IVpc; lambdaSecurityGroup: ec2.ISecurityGroup; dbSecret: secretsmanager.ISecret; dbCluster: rds.IDatabaseCluster; uploadsBucket: s3.IBucket; generatedBucket: s3.IBucket; libraryBucket: s3.IBucket; jobsQueue: sqs.IQueue; userPool: cognito.IUserPool; alarmTopic: sns.ITopic; webClientId: string; mobileClientId: string; } export class ComputeStack extends cdk.Stack { constructor(scope: Construct, id: string, props: ComputeStackProps) { super(scope, id, props); const { config } = props; const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; // Internal API key for Lambda-to-API calls (stored in Secrets Manager) const internalApiKeySecret = new secretsmanager.Secret(this, 'InternalApiKeySecret', { secretName: `proposal-system/internal-api-key${config.stackSuffix}`, generateSecretString: { excludePunctuation: true, passwordLength: 48, }, }); // Bedrock Knowledge Base vector store: Aurora PostgreSQL + pgvector (PR3 — // replaced OpenSearch Serverless). A dedicated bedrock_user role queries the // pgvector table; the aurora-pgvector-init custom resource creates the schema. const dbCluster = props.dbCluster; // Credentials Bedrock uses to query the pgvector table as bedrock_user. // SQL-unsafe characters are excluded so the bootstrap can inline the password. const bedrockUserSecret = new secretsmanager.Secret(this, 'BedrockUserSecret', { secretName: `proposal-system/bedrock-user${config.stackSuffix}`, generateSecretString: { secretStringTemplate: JSON.stringify({ username: 'bedrock_user' }), generateStringKey: 'password', excludePunctuation: true, passwordLength: 32, }, }); // Bedrock KB execution role const kbRole = new iam.Role(this, 'KnowledgeBaseRole', { roleName: `proposal-system-kb-role${config.stackSuffix}`, assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'), }); kbRole.addToPolicy(new iam.PolicyStatement({ actions: ['s3:GetObject', 's3:ListBucket'], resources: [props.libraryBucket.bucketArn, `${props.libraryBucket.bucketArn}/*`], })); kbRole.addToPolicy(new iam.PolicyStatement({ actions: ['bedrock:InvokeModel'], resources: [`arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`], })); // Bedrock validates the Aurora vector-store config at KB-create time by calling // rds:DescribeDBClusters — required or CreateKnowledgeBase 400s with "storage // configuration provided is invalid" / rds:DescribeDBClusters AccessDenied. // NOTE: rds:DescribeDBClusters does NOT support resource-level permissions (it is an // account/region list action), so it must be Resource:* — scoping to the cluster ARN // grants nothing. Read-only metadata; the role's sensitive actions stay scoped. kbRole.addToPolicy(new iam.PolicyStatement({ actions: ['rds:DescribeDBClusters'], resources: ['*'], })); // KB queries the pgvector table via the RDS Data API as bedrock_user. kbRole.addToPolicy(new iam.PolicyStatement({ actions: [ 'rds-data:ExecuteStatement', 'rds-data:BatchExecuteStatement', 'rds-data:BeginTransaction', 'rds-data:CommitTransaction', 'rds-data:RollbackTransaction', ], resources: [dbCluster.clusterArn], })); bedrockUserSecret.grantRead(kbRole); // One-time bootstrap: enable pgvector + create the bedrock_integration schema, // table, indexes, and bedrock_user role (via the RDS Data API as master). const pgvectorInitFn = new lambda.Function(this, 'PgVectorInit', { functionName: `proposal-system-pgvector-init${config.stackSuffix}`, runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', code: lambda.Code.fromAsset('../lambdas/aurora-pgvector-init'), timeout: cdk.Duration.minutes(5), environment: { CLUSTER_ARN: dbCluster.clusterArn, MASTER_SECRET_ARN: props.dbSecret.secretArn, BEDROCK_SECRET_ARN: bedrockUserSecret.secretArn, DATABASE: 'proposals', EMBED_DIM: '1024', }, logRetention: logs.RetentionDays.TWO_MONTHS, }); dbCluster.grantDataApiAccess(pgvectorInitFn); bedrockUserSecret.grantRead(pgvectorInitFn); const pgvectorProvider = new cr.Provider(this, 'PgVectorInitProvider', { onEventHandler: pgvectorInitFn, }); const pgvectorInit = new cdk.CustomResource(this, 'PgVectorInitResource', { serviceToken: pgvectorProvider.serviceToken, properties: { // Bump to force the bootstrap to re-run when the schema/logic changes. Version: '1', }, }); const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', { name: `proposal-system-kb${config.stackSuffix}`, roleArn: kbRole.roleArn, knowledgeBaseConfiguration: { type: 'VECTOR', vectorKnowledgeBaseConfiguration: { embeddingModelArn: `arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`, }, }, storageConfiguration: { type: 'RDS', rdsConfiguration: { resourceArn: dbCluster.clusterArn, credentialsSecretArn: bedrockUserSecret.secretArn, databaseName: 'proposals', tableName: 'bedrock_integration.bedrock_kb', fieldMapping: { primaryKeyField: 'id', vectorField: 'embedding', textField: 'chunks', metadataField: 'metadata', }, }, }, }); knowledgeBase.node.addDependency(pgvectorInit); // KB Data Source (S3 library bucket) const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', { name: `proposal-system-library${config.stackSuffix}`, knowledgeBaseId: knowledgeBase.attrKnowledgeBaseId, dataSourceConfiguration: { type: 'S3', s3Configuration: { bucketArn: props.libraryBucket.bucketArn, }, }, vectorIngestionConfiguration: { chunkingConfiguration: { chunkingStrategy: 'FIXED_SIZE', fixedSizeChunkingConfiguration: { maxTokens: 512, overlapPercentage: 20, }, }, }, }); // .NET 8 API Lambda const apiFunction = new lambda.Function(this, 'ApiFunction', { functionName: `proposal-system-api${config.stackSuffix}`, runtime: lambda.Runtime.DOTNET_8, architecture: lambda.Architecture.ARM_64, handler: 'ProposalSystem.Api', code: lambda.Code.fromAsset('../api/src/ProposalSystem.Api/bin/Release/net8.0/linux-arm64/publish'), memorySize: 1024, timeout: cdk.Duration.seconds(30), vpc: props.vpc, vpcSubnets: privateSubnets, securityGroups: [props.lambdaSecurityGroup], environment: { ASPNETCORE_ENVIRONMENT: 'Production', DB_SECRET_ARN: props.dbSecret.secretArn, UPLOADS_BUCKET: props.uploadsBucket.bucketName, GENERATED_BUCKET: props.generatedBucket.bucketName, LIBRARY_BUCKET: props.libraryBucket.bucketName, JOBS_QUEUE_URL: props.jobsQueue.queueUrl, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, Auth__Authority: `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`, Auth__ClientId: props.webClientId, Auth__CognitoDomain: `${config.cognitoDomainPrefix}.auth.${this.region}.amazoncognito.com`, COGNITO_WEB_CLIENT_ID: props.webClientId, COGNITO_MOBILE_CLIENT_ID: props.mobileClientId, SES_FROM_ADDRESS: 'proposals@seahaven.com', }, tracing: lambda.Tracing.ACTIVE, logRetention: logs.RetentionDays.TWO_MONTHS, }); // API Lambda permissions props.dbSecret.grantRead(apiFunction); internalApiKeySecret.grantRead(apiFunction); props.uploadsBucket.grantReadWrite(apiFunction); props.generatedBucket.grantRead(apiFunction); props.jobsQueue.grantSendMessages(apiFunction); apiFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['cognito-idp:AdminGetUser', 'cognito-idp:AdminListGroupsForUser'], resources: [props.userPool.userPoolArn], })); // SES sender: proposals@seahaven.com. seahaven.com is the org's PRIMARY sending // domain for all workload stacks. The domain identity is an ACCOUNT-SHARED resource // managed OUT OF BAND in seahaven-prod (verified via Easy DKIM in the seahaven.com // Route53 zone; production-access request submitted 2026-07-15) — this stack // references it rather than owning it, so it is not re-created per stack and does not // depend on cross-account DNS at deploy time. Do NOT create an EmailIdentity here // (CloudFormation would fail "already exists" against the out-of-band identity). // TODO: codify the seahaven.com identity in the seahaven-prod account baseline. const sesFromAddress = 'proposals@seahaven.com'; // Least-privilege SES send: scope to the seahaven.com domain identity ARN. A domain // identity authorizes sending as ANY user@seahaven.com, so the ses:FromAddress // condition is REQUIRED (not optional) to pin the Lambda to proposals@seahaven.com. const senderIdentityArn = `arn:aws:ses:${this.region}:${this.account}:identity/seahaven.com`; apiFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['ses:SendEmail', 'ses:SendRawEmail'], resources: [senderIdentityArn], conditions: { StringEquals: { 'ses:FromAddress': sesFromAddress }, }, })); // Fix: LAM-C1/INF-H1 — require IAM auth on Function URL (was authType NONE). // NOTE: Lambda HTTP clients (suggestions, pdf-extract, pdf-generate, library-ingest) // must use SigV4 signing when calling this URL. The API key header alone is no longer // sufficient for authentication at the transport layer. const apiFunctionUrl = apiFunction.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM, }); // API Gateway HTTP API const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', { apiName: `proposal-system-gateway${config.stackSuffix}`, corsPreflight: { allowOrigins: config.apiCorsOrigins, allowMethods: [ apigatewayv2.CorsHttpMethod.GET, apigatewayv2.CorsHttpMethod.POST, apigatewayv2.CorsHttpMethod.PUT, apigatewayv2.CorsHttpMethod.DELETE, apigatewayv2.CorsHttpMethod.OPTIONS, ], allowHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'], maxAge: cdk.Duration.hours(1), }, }); const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogs', { logGroupName: `/aws/apigateway/proposal-system${config.stackSuffix}`, retention: logs.RetentionDays.TWO_MONTHS, removalPolicy: cdk.RemovalPolicy.DESTROY, }); const defaultStage = httpApi.defaultStage!.node.defaultChild as apigatewayv2.CfnStage; defaultStage.defaultRouteSettings = { throttlingBurstLimit: 50, throttlingRateLimit: 100, }; defaultStage.accessLogSettings = { destinationArn: apiAccessLogGroup.logGroupArn, format: JSON.stringify({ requestId: '$context.requestId', ip: '$context.identity.sourceIp', method: '$context.httpMethod', path: '$context.path', status: '$context.status', latency: '$context.responseLatency', userAgent: '$context.identity.userAgent', }), }; const apiIntegration = new apigatewayv2Integrations.HttpLambdaIntegration( 'ApiIntegration', apiFunction ); const jwtAuthorizer = new apigatewayv2Authorizers.HttpJwtAuthorizer( 'CognitoAuthorizer', `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`, { jwtAudience: [props.webClientId, props.mobileClientId] }, ); httpApi.addRoutes({ path: '/api/health', methods: [apigatewayv2.HttpMethod.GET], integration: apiIntegration, }); httpApi.addRoutes({ path: '/api/auth/{proxy+}', methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.OPTIONS], integration: apiIntegration, }); httpApi.addRoutes({ path: '/swagger/{proxy+}', methods: [apigatewayv2.HttpMethod.GET], integration: apiIntegration, }); httpApi.addRoutes({ path: '/swagger', methods: [apigatewayv2.HttpMethod.GET], integration: apiIntegration, }); httpApi.addRoutes({ path: '/{proxy+}', methods: [ apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.PUT, apigatewayv2.HttpMethod.DELETE, apigatewayv2.HttpMethod.PATCH, ], integration: apiIntegration, authorizer: jwtAuthorizer, }); // Fix (v1 PR1): bundle pip dependencies into each Python Lambda asset (previously // bare fromAsset shipped no deps -> ImportError at cold start). --platform/--only-binary // fetches manylinux aarch64 wheels so the ARM64 functions get correct binaries // regardless of the build-host architecture. const pythonBundling = { image: lambda.Runtime.PYTHON_3_12.bundlingImage, command: [ 'bash', '-c', 'pip install -r requirements.txt --platform manylinux2014_aarch64 --python-version 3.12 --implementation cp --abi cp312 --only-binary=:all: --target /asset-output && cp -au . /asset-output', ], }; // Python Lambda: Suggestions Engine const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', { functionName: `proposal-system-suggestions${config.stackSuffix}`, runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', code: lambda.Code.fromAsset('../lambdas/suggestions', { bundling: pythonBundling }), memorySize: 512, timeout: cdk.Duration.seconds(60), vpc: props.vpc, vpcSubnets: privateSubnets, securityGroups: [props.lambdaSecurityGroup], environment: { KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId, MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', API_BASE_URL: apiFunctionUrl.url, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, logRetention: logs.RetentionDays.TWO_MONTHS, }); internalApiKeySecret.grantRead(suggestionsFunction); // Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth apiFunctionUrl.grantInvokeUrl(suggestionsFunction); // Fix: INF-M1 — scope Bedrock model ARN to the specific inference profile used // (was wildcard anthropic.claude-*). Lambda MODEL_ID is a cross-region inference profile. suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:InvokeModel'], resources: [ `arn:aws:bedrock:us-east-1:${this.account}:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0`, `arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0`, ], })); suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:Retrieve'], resources: [knowledgeBase.attrKnowledgeBaseArn], })); // Python Lambda: PDF Extract const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', { functionName: `proposal-system-pdf-extract${config.stackSuffix}`, runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', code: lambda.Code.fromAsset('../lambdas/pdf-extract', { bundling: pythonBundling }), memorySize: 1024, timeout: cdk.Duration.seconds(120), vpc: props.vpc, vpcSubnets: privateSubnets, securityGroups: [props.lambdaSecurityGroup], environment: { UPLOADS_BUCKET: props.uploadsBucket.bucketName, MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', API_BASE_URL: apiFunctionUrl.url, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, logRetention: logs.RetentionDays.TWO_MONTHS, }); internalApiKeySecret.grantRead(pdfExtractFunction); // Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth apiFunctionUrl.grantInvokeUrl(pdfExtractFunction); props.uploadsBucket.grantRead(pdfExtractFunction); // Fix: INF-M1 — scope Bedrock model ARN to the specific inference profile used pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:InvokeModel'], resources: [ `arn:aws:bedrock:us-east-1:${this.account}:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0`, `arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0`, ], })); // Python Lambda: PDF Generate const pdfGenerateFunction = new lambda.Function(this, 'PdfGenerateFunction', { functionName: `proposal-system-pdf-generate${config.stackSuffix}`, runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', code: lambda.Code.fromAsset('../lambdas/pdf-generate', { bundling: pythonBundling }), memorySize: 512, timeout: cdk.Duration.seconds(30), vpc: props.vpc, vpcSubnets: privateSubnets, securityGroups: [props.lambdaSecurityGroup], environment: { GENERATED_BUCKET: props.generatedBucket.bucketName, API_BASE_URL: apiFunctionUrl.url, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, logRetention: logs.RetentionDays.TWO_MONTHS, }); internalApiKeySecret.grantRead(pdfGenerateFunction); // Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth apiFunctionUrl.grantInvokeUrl(pdfGenerateFunction); props.generatedBucket.grantWrite(pdfGenerateFunction); // Python Lambda: Library Ingest const libraryIngestFunction = new lambda.Function(this, 'LibraryIngestFunction', { functionName: `proposal-system-library-ingest${config.stackSuffix}`, runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', code: lambda.Code.fromAsset('../lambdas/library-ingest', { bundling: pythonBundling }), memorySize: 512, timeout: cdk.Duration.seconds(60), vpc: props.vpc, vpcSubnets: privateSubnets, securityGroups: [props.lambdaSecurityGroup], environment: { LIBRARY_BUCKET: props.libraryBucket.bucketName, KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId, DATA_SOURCE_ID: dataSource.attrDataSourceId, API_BASE_URL: apiFunctionUrl.url, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, logRetention: logs.RetentionDays.TWO_MONTHS, }); internalApiKeySecret.grantRead(libraryIngestFunction); // Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth apiFunctionUrl.grantInvokeUrl(libraryIngestFunction); props.libraryBucket.grantWrite(libraryIngestFunction); libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:StartIngestionJob'], resources: [knowledgeBase.attrKnowledgeBaseArn], })); // SQS Event Sources with message filtering suggestionsFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { batchSize: 1, reportBatchItemFailures: true, filters: [ lambda.FilterCriteria.filter({ body: { jobType: lambda.FilterRule.isEqual('suggestions') }, }), ], })); pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { batchSize: 1, reportBatchItemFailures: true, filters: [ lambda.FilterCriteria.filter({ body: { jobType: lambda.FilterRule.isEqual('pdf-extract') }, }), ], })); pdfGenerateFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { batchSize: 1, reportBatchItemFailures: true, filters: [ lambda.FilterCriteria.filter({ body: { jobType: lambda.FilterRule.isEqual('pdf-generate') }, }), ], })); libraryIngestFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { batchSize: 1, reportBatchItemFailures: true, filters: [ lambda.FilterCriteria.filter({ body: { jobType: lambda.FilterRule.isEqual('library-ingest') }, }), ], })); // CloudWatch Alarms const alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic); const lambdaFunctions = [ { fn: apiFunction, name: 'api' }, { fn: suggestionsFunction, name: 'suggestions' }, { fn: pdfExtractFunction, name: 'pdf-extract' }, { fn: pdfGenerateFunction, name: 'pdf-generate' }, { fn: libraryIngestFunction, name: 'library-ingest' }, ]; for (const { fn, name } of lambdaFunctions) { const alarm = new cloudwatch.Alarm(this, `LambdaErrors-${name}`, { alarmName: `proposal-system-${name}-errors${config.stackSuffix}`, alarmDescription: `Lambda errors for ${name}`, metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }), threshold: 1, evaluationPeriods: 1, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); alarm.addAlarmAction(alarmAction); } const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', { alarmName: `proposal-system-api-5xx${config.stackSuffix}`, alarmDescription: 'API Gateway 5xx errors', metric: new cloudwatch.Metric({ namespace: 'AWS/ApiGateway', metricName: '5xx', dimensionsMap: { ApiId: httpApi.httpApiId }, statistic: 'Sum', period: cdk.Duration.minutes(5), }), threshold: 5, evaluationPeriods: 1, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); api5xxAlarm.addAlarmAction(alarmAction); // Outputs new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint }); new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn }); new cdk.CfnOutput(this, 'KnowledgeBaseId', { value: knowledgeBase.attrKnowledgeBaseId }); new cdk.CfnOutput(this, 'DataSourceId', { value: dataSource.attrDataSourceId }); } }