import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib"; import { Construct } from "constructs"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; import * as iam from "aws-cdk-lib/aws-iam"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as targets from "aws-cdk-lib/aws-route53-targets"; export interface FrontendStackProps extends StackProps { /** Environment label, e.g. "dev". Used in names/tags. */ readonly envName: string; /** GitHub repo in owner/name form, for OIDC trust scoping. */ readonly githubRepo: string; /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ readonly deployBranch: string; /** * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. * Empty = serve on the default *.cloudfront.net domain. */ readonly domainNames: string[]; /** * ARN of an ACM certificate (us-east-1, SAME account as this stack) covering * `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot * use a certificate from another account, so for Option B the cert must live * in whichever account this stack deploys to. */ readonly certificateArn: string; /** * Route 53 hosted zone (in THIS account) to create the custom-domain alias * record in. Empty = don't manage DNS (add the record manually). When set, * hostedZoneName must also be provided. */ readonly hostedZoneId: string; /** Name of the hosted zone above, e.g. "dev.seahaven.com". */ readonly hostedZoneName: string; } /** * Static SPA hosting for the Sea Haven SHOC frontend: * - private S3 bucket (no public access; CloudFront reads it via OAC) * - CloudFront distribution (HTTPS, SPA deep-link fallback) * - a GitHub Actions OIDC deploy role * * Content (the built `dist/`) is NOT uploaded here. The org's reusable * `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to * build the SPA, sync it to this bucket, and invalidate CloudFront — so this * stack only owns the infrastructure, and the deploy role carries the * permissions those post-deploy steps need. */ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { super(scope, id, props); const { envName, githubRepo, deployBranch, domainNames, certificateArn, hostedZoneId, hostedZoneName, } = props; const hasCustomDomain = domainNames.length > 0; if (hasCustomDomain && !certificateArn) { throw new Error( "certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).", ); } // --- Origin bucket: private, encrypted, no public access ---------------- const bucket = new s3.Bucket(this, "SiteBucket", { bucketName: `seahaven-shoc-frontend-${envName}`, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, encryption: s3.BucketEncryption.S3_MANAGED, enforceSSL: true, versioned: true, // dev artifacts are reproducible from the build — safe to tear down. removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, }); // SPA client-side routing: rewrite extensionless paths (e.g. /work-orders) // to /index.html so deep links resolve. Done with a CloudFront Function // rather than customErrorResponses so real asset 404s stay 404s. const spaRewrite = new cloudfront.Function(this, "SpaRewrite", { comment: "SPA routing: rewrite extensionless paths to /index.html", code: cloudfront.FunctionCode.fromInline( [ "function handler(event) {", " var request = event.request;", " var uri = request.uri;", " // No file extension after the last slash -> a client-side route.", " if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {", " request.uri = '/index.html';", " }", " return request;", "}", ].join("\n"), ), }); // --- CloudFront: serves the static SPA from S3 ------------------------- // The SPA calls the backend directly at its absolute HTTPS URL // (VITE_API_URL, cross-origin), so CloudFront hosts only static content. const distribution = new cloudfront.Distribution(this, "Distribution", { comment: `SeaHaven SHOC frontend (${envName})`, defaultRootObject: "index.html", priceClass: cloudfront.PriceClass.PRICE_CLASS_100, httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, // Option B: serve on the custom domain(s) with the ACM cert. When unset, // CloudFront uses its default *.cloudfront.net domain + certificate. domainNames: hasCustomDomain ? domainNames : undefined, certificate: hasCustomDomain ? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn) : undefined, minimumProtocolVersion: hasCustomDomain ? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021 : undefined, defaultBehavior: { // withOriginAccessControl wires up OAC + the bucket policy automatically. origin: origins.S3BucketOrigin.withOriginAccessControl(bucket), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, compress: true, functionAssociations: [ { function: spaRewrite, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, }, ], }, }); // --- GitHub Actions OIDC deploy role ----------------------------------- // The OIDC provider is a singleton account-global resource, created once // out-of-band (see README step 2) — we only IMPORT it here so this stack's // lifecycle (including `cdk destroy`) never deletes a resource shared by // every role in the account. const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn( this, "GitHubOidcProvider", `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, ); const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), assumedBy: new iam.OpenIdConnectPrincipal(provider, { StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", }, StringLike: { // Tightly scoped: only pushes to this repo's deploy branch. For a // reusable-workflow run the OIDC `sub` is still caller-based, so this // matches even though the deploy job lives in the `.github` repo. "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, }, }), }); // The whole `cd-cdk.yaml` job runs as this role. Permissions it needs: // 1. assume the CDK bootstrap roles -> `cdk deploy` // 2. describe the stack -> cd-cdk pre-flight / health-check / output reads // 3. read/write the bucket -> post-deploy `aws s3 sync` // 4. invalidate the distribution -> post-deploy cache bust deployRole.addToPolicy( new iam.PolicyStatement({ sid: "AssumeCdkBootstrapRoles", actions: ["sts:AssumeRole"], resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ sid: "DescribeStack", actions: ["cloudformation:DescribeStacks"], resources: [ `arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`, ], }), ); bucket.grantReadWrite(deployRole); deployRole.addToPolicy( new iam.PolicyStatement({ sid: "InvalidateDistribution", actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"], resources: [ `arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`, ], }), ); // --- DNS: point the custom domain at CloudFront ------------------------ // Only when a hosted zone is supplied (it must be in THIS account). Creates // A + AAAA aliases; for the zone apex, recordName is the zone itself. if (hostedZoneId && hasCustomDomain) { const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", { hostedZoneId, zoneName: hostedZoneName, }); const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)); // apex record when the domain equals the zone name. const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0]; new route53.ARecord(this, "AliasA", { zone, recordName, target }); new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target }); } // --- Outputs ----------------------------------------------------------- // scripts/deploy-web.sh reads BucketName + DistributionId from these. new CfnOutput(this, "SiteUrl", { value: hasCustomDomain ? `https://${domainNames[0]}` : `https://${distribution.distributionDomainName}`, description: "Public URL of the deployed SPA", }); new CfnOutput(this, "DistributionDomainName", { value: distribution.distributionDomainName, description: "CloudFront domain — point the custom-domain DNS record here", }); new CfnOutput(this, "BucketName", { value: bucket.bucketName, }); new CfnOutput(this, "DistributionId", { value: distribution.distributionId, }); new CfnOutput(this, "DeployRoleArn", { value: deployRole.roleArn, description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN", }); } }