mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
The SPA is served over HTTPS by CloudFront but the backend (console.seahavenind.com) is HTTP-only, so direct API calls would be blocked as mixed content. Add a CloudFront /api/* behavior that proxies to the backend over HTTP (browser <-> CloudFront is HTTPS; CloudFront <-> origin is HTTP) and set VITE_API_URL=/api (same-origin). Because distribution-level customErrorResponses are global and would rewrite real /api 403/404s into the SPA shell, replace them with a viewer-request CloudFront Function scoped to the S3 (default) behavior that rewrites extensionless paths to /index.html. /api/* carries no function association. Backend host is configurable via `-c apiOriginDomain=<host>` (default console.seahavenind.com). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
8.3 KiB
TypeScript
192 lines
8.3 KiB
TypeScript
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",
|
|
});
|
|
}
|
|
}
|