import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib'; import { HttpApi, HttpMethod, HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2'; import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Runtime } from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as rds from 'aws-cdk-lib/aws-rds'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import type { Construct } from 'constructs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const DB_NAME = 'semantika'; const APP_SCHEME_REDIRECT = 'semantika://redirect'; /** * The entire system: one HTTP API, one Lambda, one database, one user pool, * one application secret. CloudWatch logging comes with Lambda by default. */ export class SemantikaStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // --- Network (required by RDS; one NAT gateway for outbound HTTPS) --- const vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2, natGateways: 1 }); // --- Database: Aurora Serverless v2, PostgreSQL --- const db = new rds.DatabaseCluster(this, 'Database', { engine: rds.DatabaseClusterEngine.auroraPostgres({ version: rds.AuroraPostgresEngineVersion.VER_16_4, }), writer: rds.ClusterInstance.serverlessV2('Writer'), serverlessV2MinCapacity: 0.5, serverlessV2MaxCapacity: 2, defaultDatabaseName: DB_NAME, vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, storageEncrypted: true, removalPolicy: RemovalPolicy.SNAPSHOT, }); // --- Application secret (filled in manually after first deploy) --- const appSecret = new secretsmanager.Secret(this, 'AppSecret', { secretName: 'semantika/app', description: 'OPENAI_API_KEY, APPLE_SHARED_SECRET, GOOGLE_SERVICE_ACCOUNT_JSON for Semantika', }); // --- Authentication: Cognito with Apple, Google and email --- const userPool = new cognito.UserPool(this, 'UserPool', { selfSignUpEnabled: true, signInAliases: { email: true }, autoVerify: { email: true }, passwordPolicy: { minLength: 10 }, accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, removalPolicy: RemovalPolicy.RETAIN, }); const domainPrefix = this.node.tryGetContext('cognitoDomainPrefix') ?? 'semantika'; const domain = userPool.addDomain('Domain', { cognitoDomain: { domainPrefix } }); // Apple/Google federation requires developer credentials; provide them via // CDK context to enable. Email sign-in works without any of this. const providers: cognito.UserPoolClientIdentityProvider[] = [ cognito.UserPoolClientIdentityProvider.COGNITO, ]; const googleClientId = this.node.tryGetContext('googleClientId'); const googleClientSecret = this.node.tryGetContext('googleClientSecret'); if (googleClientId && googleClientSecret) { new cognito.UserPoolIdentityProviderGoogle(this, 'Google', { userPool, clientId: googleClientId, clientSecretValue: secretsmanager.Secret.fromSecretNameV2( this, 'GoogleSecretRef', googleClientSecret, ).secretValue, scopes: ['openid', 'email'], attributeMapping: { email: cognito.ProviderAttribute.GOOGLE_EMAIL }, }); providers.push(cognito.UserPoolClientIdentityProvider.GOOGLE); } const appleTeamId = this.node.tryGetContext('appleTeamId'); const appleKeyId = this.node.tryGetContext('appleKeyId'); const applePrivateKeySecretName = this.node.tryGetContext('applePrivateKeySecretName'); if (appleTeamId && appleKeyId && applePrivateKeySecretName) { new cognito.UserPoolIdentityProviderApple(this, 'Apple', { userPool, clientId: 'com.semantika.app.signin', teamId: appleTeamId, keyId: appleKeyId, privateKeyValue: secretsmanager.Secret.fromSecretNameV2( this, 'AppleKeyRef', applePrivateKeySecretName, ).secretValue, scopes: ['email'], attributeMapping: { email: cognito.ProviderAttribute.APPLE_EMAIL }, }); providers.push(cognito.UserPoolClientIdentityProvider.APPLE); } const userPoolClient = userPool.addClient('MobileClient', { generateSecret: false, authFlows: { userSrp: true }, supportedIdentityProviders: providers, oAuth: { flows: { authorizationCodeGrant: true }, scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL], callbackUrls: [APP_SCHEME_REDIRECT], logoutUrls: [APP_SCHEME_REDIRECT], }, }); // --- The single backend Lambda --- const apiFunction = new NodejsFunction(this, 'Api', { entry: join(repoRoot, 'services', 'api', 'src', 'handler.ts'), runtime: Runtime.NODEJS_22_X, memorySize: 512, timeout: Duration.seconds(30), vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, logRetention: logs.RetentionDays.ONE_MONTH, bundling: { format: OutputFormat.ESM, commandHooks: { beforeBundling: () => [], beforeInstall: () => [], afterBundling: (inputDir, outputDir) => [ `cp -r ${inputDir}/services/api/knowledge ${outputDir}/knowledge`, ], }, }, environment: { APP_SECRET_ARN: appSecret.secretArn, DB_SECRET_ARN: db.secret?.secretArn ?? '', DB_NAME, KNOWLEDGE_DIR: 'knowledge', MESSAGE_CAP: '200', USAGE_RESET_DAYS: '30', }, }); appSecret.grantRead(apiFunction); db.secret?.grantRead(apiFunction); db.connections.allowDefaultPortFrom(apiFunction); // --- HTTP API with Cognito JWT authorization --- const authorizer = new HttpJwtAuthorizer( 'JwtAuthorizer', `https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}`, { jwtAudience: [userPoolClient.userPoolClientId] }, ); const api = new HttpApi(this, 'HttpApi', { defaultAuthorizer: authorizer }); const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction); api.addRoutes({ path: '/me', methods: [HttpMethod.GET, HttpMethod.DELETE], integration }); api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration }); api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration }); // Public routes: the user meets the chat before any registration. const publicAuthorizer = new HttpNoneAuthorizer(); api.addRoutes({ path: '/suggestions', methods: [HttpMethod.GET], integration, authorizer: publicAuthorizer, }); api.addRoutes({ path: '/guest/chat', methods: [HttpMethod.POST], integration, authorizer: publicAuthorizer, }); new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint }); new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId }); new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId }); new CfnOutput(this, 'CognitoDomain', { value: domain.baseUrl() }); } }