import { Aspects, CfnCondition, CfnOutput, CfnParameter, CfnResource, Duration, Fn, RemovalPolicy, Stack, StackProps, Tags, } 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"; import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption"; 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; /** * GitHub Actions environment name (e.g. "staging"). When set, the OIDC * trust uses the EXACT environment subject * `repo::environment:` (StringEquals) instead of the * deploy-branch ref match below. Unset = dev-style branch-ref trust. */ readonly githubEnvironment?: 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; /** * Opt-in safety mode used only during the reviewed Terraform adoption. * Normal dev/staging synthesis remains unchanged when false. */ readonly retainForTerraformAdoption?: boolean; } /** * 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. Manual environment * workflows run `scripts/deploy-web.sh` independently of infrastructure * changes, so this stack only owns infrastructure and the deploy role carries * content-publication permissions. */ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { super(scope, id, props); const { envName, githubRepo, deployBranch, githubEnvironment = "", domainNames, certificateArn, hostedZoneId, hostedZoneName, retainForTerraformAdoption = false, } = props; const manageSiteInfrastructureCondition = retainForTerraformAdoption ? new CfnCondition(this, "ManageSiteInfrastructureCondition", { expression: Fn.conditionEquals( new CfnParameter(this, "ManageSiteInfrastructure", { type: "String", allowedValues: ["true", "false"], description: "Set true only before Terraform adoption. After ownership transfer, always reuse false.", }).valueAsString, "true", ), }) : undefined; 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. // Adoption mode pins the origin ID CloudFormation generated for the live // distribution so the retention deploy is a metadata-only update. Only // environments with a read-back-verified value may enter adoption mode. const adoptionOriginIds: Record = { dev: "shocfrontenddevDistributionOrigin10CCD0EE1", }; const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined; if (retainForTerraformAdoption && !originId) { throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`); } 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, { originId, }), 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`, ); // Trust conditions for the OIDC principal. With a GitHub environment // (staging): exact StringEquals match on both aud and the environment // subject — the staging workflow declares `environment: staging`, so only // runs in that environment can assume the role. Normal dev synthesis keeps // the current branch-ref StringLike trust. The adoption prerequisite // narrows that already-exact value to StringEquals before Terraform import. const oidcConditions = githubEnvironment ? { StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, }, } : retainForTerraformAdoption ? { StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, }, } : { 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}`, }, }; const deployPermissionsBoundary = retainForTerraformAdoption ? iam.ManagedPolicy.fromManagedPolicyArn( this, "GithubDeployPermissionsBoundary", `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, ) : undefined; 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, oidcConditions), permissionsBoundary: deployPermissionsBoundary, }); if (retainForTerraformAdoption) { Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`); } // Preserve dev's legacy CDK capability until the reviewed adoption update // replaces this inline policy. Staging is intentionally narrower: its // content role only publishes application assets to this stack's // bucket/distribution. Infrastructure changes remain administrator-run. if (!githubEnvironment) { 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. let aliasA: route53.ARecord | undefined; let aliasAaaa: route53.AaaaRecord | undefined; 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]; aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target }); aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target, }); } const gateOutput = (output: CfnOutput): CfnOutput => { if (manageSiteInfrastructureCondition) { output.condition = manageSiteInfrastructureCondition; } return output; }; // --- Outputs ----------------------------------------------------------- // scripts/deploy-web.sh reads BucketName + DistributionId from these. gateOutput( new CfnOutput(this, "SiteUrl", { value: hasCustomDomain ? `https://${domainNames[0]}` : `https://${distribution.distributionDomainName}`, description: "Public URL of the deployed SPA", }), ); gateOutput( new CfnOutput(this, "DistributionDomainName", { value: distribution.distributionDomainName, description: "CloudFront domain — point the custom-domain DNS record here", }), ); gateOutput( new CfnOutput(this, "BucketName", { value: bucket.bucketName, }), ); gateOutput( new CfnOutput(this, "DistributionId", { value: distribution.distributionId, }), ); gateOutput( new CfnOutput(this, "DeployRoleArn", { value: deployRole.roleArn, description: "Pinned GitHub OIDC content-deployment role", }), ); if (retainForTerraformAdoption) { const originAccessControl = distribution.node .findAll() .find( (node): node is cloudfront.CfnOriginAccessControl => node instanceof cloudfront.CfnOriginAccessControl, ); if (!originAccessControl || !aliasA || !aliasAaaa) { throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records."); } const originAccessControlConfig = originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty; const rolePolicy = deployRole.node .findAll() .find((node): node is iam.Policy => node instanceof iam.Policy); const autoDeleteProviderRole = this.node .findAll() .find( (node): node is CfnResource => node instanceof CfnResource && node.cfnResourceType === "AWS::IAM::Role" && node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"), ); const autoDeleteProviderHandler = this.node .findAll() .find( (node): node is CfnResource => node instanceof CfnResource && node.cfnResourceType === "AWS::Lambda::Function" && node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler"), ); if (!rolePolicy || !autoDeleteProviderRole || !autoDeleteProviderHandler) { throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); } // The provider Lambda stays unconditioned so it remains after // ManageSiteInfrastructure=false. Its generated Description Refs the // conditioned bucket and CloudFormation rejects that when the condition // is false. Keep a static description. autoDeleteProviderHandler.addPropertyOverride( "Description", "Lambda function for auto-deleting objects in the site S3 bucket.", ); const recordName = domainNames[0]; gateOutput( new CfnOutput(this, "TerraformWorkspaceTag", { value: `shoc-frontend-new-${envName}`, }), ); gateOutput( new CfnOutput(this, "TerraformDeployBoundaryArn", { value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, }), ); gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName })); gateOutput( new CfnOutput(this, "TerraformImportBucketPolicy", { value: bucket.bucketName, }), ); gateOutput( new CfnOutput(this, "TerraformImportDistribution", { value: distribution.distributionId, }), ); gateOutput( new CfnOutput(this, "TerraformImportOriginAccessControl", { value: originAccessControl.attrId, }), ); gateOutput( new CfnOutput(this, "TerraformOriginAccessControlName", { value: originAccessControlConfig.name, }), ); gateOutput( new CfnOutput(this, "TerraformOriginAccessControlDescription", { value: "EMPTY_STRING", description: "Use an empty Terraform string because the generated OAC has no description", }), ); gateOutput( new CfnOutput(this, "TerraformDistributionOriginId", { value: originId!, }), ); gateOutput( new CfnOutput(this, "TerraformImportSpaRewriteFunction", { value: spaRewrite.functionName, }), ); gateOutput( new CfnOutput(this, "TerraformImportAliasA", { value: `${hostedZoneId}_${recordName}_A`, }), ); gateOutput( new CfnOutput(this, "TerraformImportAliasAAAA", { value: `${hostedZoneId}_${recordName}_AAAA`, }), ); gateOutput( new CfnOutput(this, "TerraformImportDeployRole", { value: deployRole.roleName, }), ); gateOutput( new CfnOutput(this, "TerraformImportDeployRolePolicy", { value: `${deployRole.roleName}:${rolePolicy.policyName}`, }), ); gateOutput( new CfnOutput(this, "TerraformDeployInlinePolicyName", { value: rolePolicy.policyName, }), ); gateOutput( new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", { value: autoDeleteProviderRole.getAtt("Arn").toString(), }), ); gateOutput( new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", { value: "SiteBucket/AutoDeleteObjectsCustomResource", description: "CloudFormation custom resource retained to prevent bucket emptying during detachment", }), ); Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition)); } } }