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"; 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; } /** * Static SPA hosting for the SeaHaven 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 } = props; // --- 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, }); // --- CloudFront: OAC origin, HTTPS, SPA fallback ----------------------- 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, 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, }, // With a private bucket + OAC, a missing key returns 403 (not 404), so the // 403 -> index.html mapping is what makes react-router deep links work on // hard refresh. 404 is mapped too for completeness. errorResponses: [ { httpStatus: 403, responseHttpStatus: 200, responsePagePath: "/index.html", ttl: Duration.seconds(0), }, { httpStatus: 404, responseHttpStatus: 200, responsePagePath: "/index.html", ttl: Duration.seconds(0), }, ], }); // --- 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}`, ], }), ); // --- Outputs ----------------------------------------------------------- // scripts/deploy-web.sh reads BucketName + DistributionId from these. new CfnOutput(this, "SiteUrl", { value: `https://${distribution.distributionDomainName}`, description: "Public URL of the deployed SPA", }); 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", }); } }