import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as path from 'path'; export interface SlackHandlerProps { accountId: string; region: string; agentId: string; agentAliasId: string; conversationTable: dynamodb.Table; unansweredTable: dynamodb.Table; wildcardCertArn: string; vpc: ec2.IVpc; lambdaSecurityGroup: ec2.ISecurityGroup; } export class SlackHandlerConstruct extends Construct { public readonly processorLambda: lambdaNodejs.NodejsFunction; public readonly appHomeLambda: lambdaNodejs.NodejsFunction; public readonly api: apigatewayv2.HttpApi; constructor(scope: Construct, id: string, props: SlackHandlerProps) { super(scope, id); const slackSecret = secretsmanager.Secret.fromSecretNameV2( this, 'SlackSecret', 'seahaven/slack/credentials', ); const bundling: lambdaNodejs.BundlingOptions = { externalModules: ['@aws-sdk/*'], minify: true, sourceMap: false, }; // ── Processor Lambda ────────────────────────────────────────────────────── this.processorLambda = new lambdaNodejs.NodejsFunction(this, 'ProcessorFn', { functionName: 'seahaven-slack-processor', entry: path.join(__dirname, '../../lambda/slack-processor/index.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, logRetention: logs.RetentionDays.TWO_MONTHS, timeout: cdk.Duration.minutes(5), memorySize: 512, environment: { AGENT_ID: props.agentId, AGENT_ALIAS_ID: props.agentAliasId, CONVERSATION_TABLE: props.conversationTable.tableName, UNANSWERED_TABLE: props.unansweredTable.tableName, SLACK_SECRET_ARN: slackSecret.secretArn, REGION: props.region, }, bundling, }); slackSecret.grantRead(this.processorLambda); props.conversationTable.grantReadWriteData(this.processorLambda); props.unansweredTable.grantWriteData(this.processorLambda); this.processorLambda.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:InvokeAgent'], resources: [ `arn:aws:bedrock:${props.region}:${props.accountId}:agent/${props.agentId}`, `arn:aws:bedrock:${props.region}:${props.accountId}:agent-alias/${props.agentId}/*`, ], })); // ── App Home Lambda ─────────────────────────────────────────────────────── this.appHomeLambda = new lambdaNodejs.NodejsFunction(this, 'AppHomeFn', { functionName: 'seahaven-app-home', entry: path.join(__dirname, '../../lambda/app-home/index.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, logRetention: logs.RetentionDays.TWO_MONTHS, timeout: cdk.Duration.seconds(10), memorySize: 256, environment: { SLACK_SECRET_ARN: slackSecret.secretArn, }, bundling, }); slackSecret.grantRead(this.appHomeLambda); // ── HTTP API (API Gateway v2) — QBO OAuth routes only ───────────────────── this.api = new apigatewayv2.HttpApi(this, 'Api', { apiName: 'seahaven-slack-webhook', description: 'Sea Haven bot — QBO OAuth routes', }); // ── QBO OAuth Lambda ───────────────────────────────────────────────────── const qboSecret = secretsmanager.Secret.fromSecretNameV2( this, 'QBOSecret', 'seahaven/qbo/oauth', ); const qboOAuthLambda = new lambdaNodejs.NodejsFunction(this, 'QBOOAuthFn', { functionName: 'seahaven-qbo-oauth', entry: path.join(__dirname, '../../lambda/qbo-oauth/index.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, logRetention: logs.RetentionDays.TWO_MONTHS, timeout: cdk.Duration.seconds(15), memorySize: 256, environment: { QBO_SECRET_ARN: qboSecret.secretArn, REDIRECT_URI: 'https://bot.seahaven.com/qbo/callback', }, vpc: props.vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, securityGroups: [props.lambdaSecurityGroup], bundling, }); qboSecret.grantRead(qboOAuthLambda); qboSecret.grantWrite(qboOAuthLambda); const qboOAuthIntegration = new HttpLambdaIntegration('QBOOAuthIntegration', qboOAuthLambda); for (const qboPath of ['/qbo/connect', '/qbo/callback', '/qbo/disconnect', '/qbo/launch']) { this.api.addRoutes({ path: qboPath, methods: [apigatewayv2.HttpMethod.GET], integration: qboOAuthIntegration, }); } // ── Access logging + throttling (audit M-18) ────────────────────────────── const defaultStage = this.api.defaultStage!.node.defaultChild as apigatewayv2.CfnStage; defaultStage.addPropertyOverride('DefaultRouteSettings', { ThrottlingBurstLimit: 50, ThrottlingRateLimit: 100, }); const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogGroup', { logGroupName: '/aws/apigateway/seahaven-slack-webhook', retention: logs.RetentionDays.THREE_MONTHS, removalPolicy: cdk.RemovalPolicy.DESTROY, }); defaultStage.addPropertyOverride('AccessLogSettings', { DestinationArn: apiAccessLogGroup.logGroupArn, Format: JSON.stringify({ requestId: '$context.requestId', ip: '$context.identity.sourceIp', requestTime: '$context.requestTime', method: '$context.httpMethod', routeKey: '$context.routeKey', status: '$context.status', protocol: '$context.protocol', responseLength: '$context.responseLength', integrationError: '$context.integrationErrorMessage', }), }); // ── Custom domain: bot.seahaven.com ─────────────────────────────────────── const certificate = acm.Certificate.fromCertificateArn( this, 'WildcardCert', props.wildcardCertArn, ); const hostedZone = route53.HostedZone.fromLookup(this, 'SeahavenZone', { domainName: 'seahaven.com', }); const customDomain = new apigatewayv2.DomainName(this, 'CustomDomain', { domainName: 'bot.seahaven.com', certificate, }); new apigatewayv2.ApiMapping(this, 'ApiMapping', { api: this.api, domainName: customDomain, stage: this.api.defaultStage!, }); new route53.ARecord(this, 'BotDnsRecord', { zone: hostedZone, recordName: 'bot', target: route53.RecordTarget.fromAlias( new route53Targets.ApiGatewayv2DomainProperties( customDomain.regionalDomainName, customDomain.regionalHostedZoneId, ), ), }); } }