2026-04-11 23:15:15 -04:00
|
|
|
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';
|
2026-04-30 16:38:54 -04:00
|
|
|
import * as logs from 'aws-cdk-lib/aws-logs';
|
2026-04-11 23:15:15 -04:00
|
|
|
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';
|
2026-04-13 19:51:00 -04:00
|
|
|
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
2026-04-11 23:15:15 -04:00
|
|
|
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;
|
2026-04-30 16:38:54 -04:00
|
|
|
unansweredTable: dynamodb.Table;
|
2026-04-11 23:15:15 -04:00
|
|
|
wildcardCertArn: string;
|
2026-04-13 19:51:00 -04:00
|
|
|
vpc: ec2.IVpc;
|
|
|
|
|
lambdaSecurityGroup: ec2.ISecurityGroup;
|
2026-04-11 23:15:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class SlackHandlerConstruct extends Construct {
|
|
|
|
|
public readonly processorLambda: lambdaNodejs.NodejsFunction;
|
2026-04-30 16:38:54 -04:00
|
|
|
public readonly appHomeLambda: lambdaNodejs.NodejsFunction;
|
2026-04-11 23:15:15 -04:00
|
|
|
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',
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-30 16:38:54 -04:00
|
|
|
const bundling: lambdaNodejs.BundlingOptions = {
|
|
|
|
|
externalModules: ['@aws-sdk/*'],
|
|
|
|
|
minify: true,
|
|
|
|
|
sourceMap: false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-11 23:15:15 -04:00
|
|
|
// ── Processor Lambda ──────────────────────────────────────────────────────
|
|
|
|
|
this.processorLambda = new lambdaNodejs.NodejsFunction(this, 'ProcessorFn', {
|
|
|
|
|
functionName: 'seahaven-slack-processor',
|
|
|
|
|
entry: path.join(__dirname, '../../lambda/slack-processor/index.ts'),
|
|
|
|
|
handler: 'handler',
|
2026-07-04 05:26:03 +00:00
|
|
|
runtime: lambda.Runtime.NODEJS_24_X,
|
2026-04-30 16:38:54 -04:00
|
|
|
architecture: lambda.Architecture.ARM_64,
|
|
|
|
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
2026-04-11 23:15:15 -04:00
|
|
|
timeout: cdk.Duration.minutes(5),
|
|
|
|
|
memorySize: 512,
|
|
|
|
|
environment: {
|
|
|
|
|
AGENT_ID: props.agentId,
|
|
|
|
|
AGENT_ALIAS_ID: props.agentAliasId,
|
|
|
|
|
CONVERSATION_TABLE: props.conversationTable.tableName,
|
2026-04-30 16:38:54 -04:00
|
|
|
UNANSWERED_TABLE: props.unansweredTable.tableName,
|
2026-04-11 23:15:15 -04:00
|
|
|
SLACK_SECRET_ARN: slackSecret.secretArn,
|
|
|
|
|
REGION: props.region,
|
|
|
|
|
},
|
2026-04-30 16:38:54 -04:00
|
|
|
bundling,
|
2026-04-11 23:15:15 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
slackSecret.grantRead(this.processorLambda);
|
|
|
|
|
props.conversationTable.grantReadWriteData(this.processorLambda);
|
2026-04-30 16:38:54 -04:00
|
|
|
props.unansweredTable.grantWriteData(this.processorLambda);
|
2026-04-11 23:15:15 -04:00
|
|
|
|
|
|
|
|
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}/*`,
|
|
|
|
|
],
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-30 16:38:54 -04:00
|
|
|
// ── App Home Lambda ───────────────────────────────────────────────────────
|
|
|
|
|
this.appHomeLambda = new lambdaNodejs.NodejsFunction(this, 'AppHomeFn', {
|
|
|
|
|
functionName: 'seahaven-app-home',
|
|
|
|
|
entry: path.join(__dirname, '../../lambda/app-home/index.ts'),
|
2026-04-11 23:15:15 -04:00
|
|
|
handler: 'handler',
|
2026-07-04 05:26:03 +00:00
|
|
|
runtime: lambda.Runtime.NODEJS_24_X,
|
2026-04-30 16:38:54 -04:00
|
|
|
architecture: lambda.Architecture.ARM_64,
|
|
|
|
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
2026-04-11 23:15:15 -04:00
|
|
|
timeout: cdk.Duration.seconds(10),
|
|
|
|
|
memorySize: 256,
|
|
|
|
|
environment: {
|
|
|
|
|
SLACK_SECRET_ARN: slackSecret.secretArn,
|
|
|
|
|
},
|
2026-04-30 16:38:54 -04:00
|
|
|
bundling,
|
2026-04-11 23:15:15 -04:00
|
|
|
});
|
|
|
|
|
|
2026-04-30 16:38:54 -04:00
|
|
|
slackSecret.grantRead(this.appHomeLambda);
|
2026-04-11 23:15:15 -04:00
|
|
|
|
2026-04-30 16:38:54 -04:00
|
|
|
// ── HTTP API (API Gateway v2) — QBO OAuth routes only ─────────────────────
|
2026-04-11 23:15:15 -04:00
|
|
|
this.api = new apigatewayv2.HttpApi(this, 'Api', {
|
|
|
|
|
apiName: 'seahaven-slack-webhook',
|
2026-04-30 16:38:54 -04:00
|
|
|
description: 'Sea Haven bot — QBO OAuth routes',
|
2026-04-11 23:15:15 -04:00
|
|
|
});
|
|
|
|
|
|
2026-04-13 19:31:13 -04:00
|
|
|
// ── 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',
|
2026-07-04 05:26:03 +00:00
|
|
|
runtime: lambda.Runtime.NODEJS_24_X,
|
2026-04-30 16:38:54 -04:00
|
|
|
architecture: lambda.Architecture.ARM_64,
|
|
|
|
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
2026-04-13 19:31:13 -04:00
|
|
|
timeout: cdk.Duration.seconds(15),
|
|
|
|
|
memorySize: 256,
|
|
|
|
|
environment: {
|
|
|
|
|
QBO_SECRET_ARN: qboSecret.secretArn,
|
|
|
|
|
REDIRECT_URI: 'https://bot.seahaven.com/qbo/callback',
|
|
|
|
|
},
|
2026-04-13 19:51:00 -04:00
|
|
|
vpc: props.vpc,
|
|
|
|
|
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
|
|
|
securityGroups: [props.lambdaSecurityGroup],
|
2026-04-30 16:38:54 -04:00
|
|
|
bundling,
|
2026-04-13 19:31:13 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 17:47:44 -04:00
|
|
|
// ── 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',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 23:15:15 -04:00
|
|
|
// ── 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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|