proposal-system/infra/lib/foundation-stack.ts
Adam Moussa da00d27049 Add email/password login, fix Cognito config, enable mobile auto-deploy
Apple review requires a test account login path that doesn't depend on
Google OAuth. Add amazon-cognito-identity-js for direct SRP auth with a
native email/password form on the login screen. Fill in the empty Cognito
client ID and pool ID, fix the Cognito domain prefix, and align CDK
callback URLs with the app's actual URL scheme. Enable push-triggered
mobile deploys, add CDK outputs for client IDs, fix stale README
references, and add mobile/README.md.
2026-05-20 11:36:51 -04:00

284 lines
9.4 KiB
TypeScript

import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import { Construct } from 'constructs';
export class FoundationStack extends cdk.Stack {
public readonly vpc: ec2.IVpc;
public readonly lambdaSecurityGroup: ec2.ISecurityGroup;
public readonly dbSecret: secretsmanager.ISecret;
public readonly uploadsBucket: s3.IBucket;
public readonly generatedBucket: s3.IBucket;
public readonly libraryBucket: s3.IBucket;
public readonly jobsQueue: sqs.IQueue;
public readonly userPool: cognito.IUserPool;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// VPC: 2 AZs, public + private subnets, single NAT Gateway
this.vpc = new ec2.Vpc(this, 'Vpc', {
vpcName: 'proposal-system-vpc',
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
name: 'public',
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
},
{
name: 'private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
],
});
// VPC Endpoints
this.vpc.addGatewayEndpoint('S3Endpoint', {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
this.vpc.addInterfaceEndpoint('SecretsManagerEndpoint', {
service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
});
// Security Groups
this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', {
vpc: this.vpc,
securityGroupName: 'proposal-system-lambda-sg',
description: 'Security group for proposal system Lambda functions',
allowAllOutbound: true,
});
const rdsSg = new ec2.SecurityGroup(this, 'RdsSg', {
vpc: this.vpc,
securityGroupName: 'proposal-system-rds-sg',
description: 'Security group for proposal system RDS instance',
allowAllOutbound: false,
});
rdsSg.addIngressRule(
this.lambdaSecurityGroup,
ec2.Port.tcp(5432),
'Allow PostgreSQL from Lambda SG'
);
// RDS PostgreSQL 15
const dbInstance = new rds.DatabaseInstance(this, 'Database', {
instanceIdentifier: 'proposal-system-db',
engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_15,
}),
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T4G,
ec2.InstanceSize.SMALL
),
vpc: this.vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [rdsSg],
multiAz: false,
allocatedStorage: 20,
maxAllocatedStorage: 100,
storageEncrypted: true,
backupRetention: cdk.Duration.days(7),
deletionProtection: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
databaseName: 'proposals',
credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', {
secretName: 'proposal-system/db-credentials',
}),
publiclyAccessible: false,
});
this.dbSecret = dbInstance.secret!;
// S3 Buckets
this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', {
bucketName: `proposal-system-uploads-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
{
transitions: [
{
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
transitionAfter: cdk.Duration.days(90),
},
],
},
],
cors: [
{
allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST],
allowedOrigins: ['*'],
allowedHeaders: ['*'],
maxAge: 3600,
},
],
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
cdk.Tags.of(this.uploadsBucket).add('Purpose', 'Vendor PDFs and dispatcher attachments');
cdk.Tags.of(this.uploadsBucket).add('ManagedBy', 'proposal-system');
this.generatedBucket = new s3.Bucket(this, 'GeneratedBucket', {
bucketName: `proposal-system-generated-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
cdk.Tags.of(this.generatedBucket).add('Purpose', 'Generated proposal PDFs');
cdk.Tags.of(this.generatedBucket).add('ManagedBy', 'proposal-system');
this.libraryBucket = new s3.Bucket(this, 'LibraryBucket', {
bucketName: `proposal-system-library-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
cdk.Tags.of(this.libraryBucket).add('Purpose', 'Historical proposal library for RAG');
cdk.Tags.of(this.libraryBucket).add('ManagedBy', 'proposal-system');
// SQS Queue + DLQ
const dlq = new sqs.Queue(this, 'JobsDlq', {
queueName: 'proposal-system-jobs-dlq',
retentionPeriod: cdk.Duration.days(14),
});
this.jobsQueue = new sqs.Queue(this, 'JobsQueue', {
queueName: 'proposal-system-jobs',
visibilityTimeout: cdk.Duration.seconds(180),
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 3,
},
});
// Cognito User Pool
const userPool = new cognito.UserPool(this, 'UserPool', {
userPoolName: 'proposal-system-auth',
selfSignUpEnabled: false,
signInAliases: { email: true },
standardAttributes: {
email: { required: true, mutable: true },
fullname: { required: true, mutable: true },
},
passwordPolicy: {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireDigits: true,
requireSymbols: false,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
this.userPool = userPool;
// Cognito Groups
new cognito.CfnUserPoolGroup(this, 'DispatchersGroup', {
userPoolId: userPool.userPoolId,
groupName: 'dispatchers',
description: 'Dispatchers who submit proposal requests',
});
new cognito.CfnUserPoolGroup(this, 'AdminsGroup', {
userPoolId: userPool.userPoolId,
groupName: 'admins',
description: 'Admins who review and approve proposals',
});
new cognito.CfnUserPoolGroup(this, 'SysadminsGroup', {
userPoolId: userPool.userPoolId,
groupName: 'sysadmins',
description: 'System administrators',
});
// Cognito Domain
userPool.addDomain('CognitoDomain', {
cognitoDomain: { domainPrefix: 'proposal-system-seahaven' },
});
// Web App Client (PKCE)
const webClient = userPool.addClient('WebClient', {
userPoolClientName: 'proposal-system-web',
generateSecret: false,
authFlows: {
userSrp: true,
},
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
],
callbackUrls: [
'https://proposals.seahaven.com/callback',
'http://localhost:5173/callback',
],
logoutUrls: [
'https://proposals.seahaven.com',
'http://localhost:5173',
],
},
});
// Mobile App Client (PKCE)
const mobileClient = userPool.addClient('MobileClient', {
userPoolClientName: 'proposal-system-mobile',
generateSecret: false,
authFlows: {
userSrp: true,
},
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
],
callbackUrls: ['com.seahavenind.proposals://auth/callback'],
logoutUrls: ['com.seahavenind.proposals://auth/logout'],
},
});
// CloudWatch Log Groups
const logGroupNames = [
'proposal-system-api',
'proposal-system-pdf-extract',
'proposal-system-pdf-generate',
'proposal-system-library-ingest',
];
for (const name of logGroupNames) {
new logs.LogGroup(this, `LogGroup-${name}`, {
logGroupName: `/aws/lambda/${name}`,
retention: logs.RetentionDays.TWO_MONTHS,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
}
// Outputs
new cdk.CfnOutput(this, 'VpcId', { value: this.vpc.vpcId });
new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new cdk.CfnOutput(this, 'UserPoolArn', { value: userPool.userPoolArn });
new cdk.CfnOutput(this, 'UploadsBucketName', { value: this.uploadsBucket.bucketName });
new cdk.CfnOutput(this, 'GeneratedBucketName', { value: this.generatedBucket.bucketName });
new cdk.CfnOutput(this, 'LibraryBucketName', { value: this.libraryBucket.bucketName });
new cdk.CfnOutput(this, 'JobsQueueUrl', { value: this.jobsQueue.queueUrl });
new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn });
new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId });
new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId });
}
}