mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 10:03:14 +00:00
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.
284 lines
9.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|