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; /** * Hostname of the backend API. CloudFront proxies `/api/*` to it over HTTP * so the HTTPS SPA can call an HTTP-only backend without mixed-content * blocking (browser <-> CloudFront is HTTPS; CloudFront <-> origin is HTTP). */ readonly apiOriginDomain: 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, apiOriginDomain } = 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, }); // SPA client-side routing, scoped to the S3 (default) behavior only: // rewrite extensionless paths (e.g. /work-orders) to /index.html. This is // done with a CloudFront Function rather than distribution-wide // customErrorResponses, because those are global and would also turn // legitimate /api 403/404 responses into the SPA shell. 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: S3 (SPA) default behavior + /api proxy behavior ------- 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, functionAssociations: [ { function: spaRewrite, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, }, ], }, additionalBehaviors: { // Proxy API calls to the HTTP-only backend. The SPA calls same-origin // `/api/...` over HTTPS; CloudFront forwards to the origin over HTTP. "/api/*": { origin: new origins.HttpOrigin(apiOriginDomain, { protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, httpPort: 80, }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, // Forward everything the viewer sent except Host (CloudFront sets Host // to the origin domain so the backend's routing isn't confused). originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER, compress: true, }, }, }); // --- 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", }); } }