diff --git a/infra/cdk/README.md b/infra/cdk/README.md index e42946c9..ceaa578e 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -3,9 +3,12 @@ AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo, deployed through the org's **reusable** GitHub Actions workflow. -- **Hosting:** private S3 bucket (origin) + CloudFront (CDN, HTTPS, SPA routing) -- **API:** CloudFront proxies `/api/*` to the HTTP-only backend, so the HTTPS - SPA calls it same-origin (no mixed-content blocking). `VITE_API_URL=/api`. +- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom + domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). +- **API:** CloudFront proxies `/api/*` over HTTPS to `api.dev.seahaven.com`, so + the SPA calls it same-origin (`VITE_API_URL=/api`) — no CORS, no mixed content. +- Env-specific values (domain, cert, zone, API host) live in `cdk.json` context + so the CI `cdk deploy` picks them up with no flags. - **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys) - **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's `Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy` @@ -70,20 +73,21 @@ npm ci npx cdk bootstrap aws:///us-east-1 ``` -### 4. Confirm the backend API origin +### 4. Domain, cert, and API origin (already wired for dev) -The app calls its API same-origin at `/api` (`VITE_API_URL=/api` in -`.env.production`), and CloudFront proxies `/api/*` to the backend over HTTP. -The backend host defaults to `console.seahavenind.com`; override it if the dev -API lives elsewhere: +For dev these are set in `cdk.json` context (account `396287094661`): -```bash -# default is fine for dev; otherwise: -npx cdk deploy -c apiOriginDomain= -``` +| Context key | Value | +| --------------------------------------- | ------------------------------------------------------------ | +| `domainNames` | `dev.seahaven.com` | +| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) | +| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` | +| `apiOriginDomain` / `apiOriginProtocol` | `api.dev.seahaven.com` / `https` | -No mixed-content risk: the browser talks HTTPS to CloudFront, and CloudFront -talks HTTP to the origin. +The SPA calls `/api` same-origin; CloudFront proxies `/api/*` over **HTTPS** to +`api.dev.seahaven.com`. The stack creates the apex A/AAAA alias in the hosted +zone (which is in this account, delegated from the parent `seahaven.com` zone). +For staging/prod, override these context keys per environment. ### 5. First deploy (locally, with admin creds) diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 483c9e08..d5df3f11 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -8,13 +8,36 @@ const app = new App(); const envName = app.node.tryGetContext("envName") ?? "dev"; const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; + +// API origin the CloudFront /api behavior proxies to. +// Option A (interim, default): the HTTP-only console.seahavenind.com backend. +// Option B: -c apiOriginDomain= -c apiOriginProtocol=https const apiOriginDomain = app.node.tryGetContext("apiOriginDomain") ?? "console.seahavenind.com"; +const apiOriginProtocol = + app.node.tryGetContext("apiOriginProtocol") === "https" ? "https" : "http"; + +// Custom domain (Option B). Comma-separated, e.g. -c domainNames=console.seahaven.com +// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. +const domainNames = (app.node.tryGetContext("domainNames") ?? "") + .split(",") + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0); +const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; + +// Route 53 hosted zone (this account) for the custom-domain alias record. +const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; +const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { envName, githubRepo, deployBranch, apiOriginDomain, + apiOriginProtocol, + domainNames, + certificateArn, + hostedZoneId, + hostedZoneName, env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json index fd73f4b1..b346a8ef 100644 --- a/infra/cdk/cdk.json +++ b/infra/cdk/cdk.json @@ -8,6 +8,14 @@ "@aws-cdk/aws-iam:minimizePolicies": true, "@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, - "@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true + "@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true, + + "//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.", + "domainNames": "dev.seahaven.com", + "certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00", + "apiOriginDomain": "api.dev.seahaven.com", + "apiOriginProtocol": "https", + "hostedZoneId": "Z07671212N75U4YLPWZR8", + "hostedZoneName": "dev.seahaven.com" } } diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index 2270d5ae..bf9f94c1 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -4,6 +4,9 @@ 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. */ @@ -13,11 +16,36 @@ export interface FrontendStackProps extends StackProps { /** 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). + * Hostname of the backend API. CloudFront proxies `/api/*` to it (see + * `apiOriginProtocol`) so the SPA can call it same-origin at `/api`. */ readonly apiOriginDomain: string; + /** + * Protocol CloudFront uses to reach the API origin. + * - "http" (Option A, interim): HTTP-only backend (console.seahavenind.com) + * - "https" (Option B): the seahaven.com HTTPS backend + */ + readonly apiOriginProtocol: "http" | "https"; + /** + * Custom domain(s) for the distribution, e.g. ["console.seahaven.com"]. + * Empty = serve on the default *.cloudfront.net domain (Option A). + */ + 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; } /** @@ -36,7 +64,25 @@ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { super(scope, id, props); - const { envName, githubRepo, deployBranch, apiOriginDomain } = props; + const { + envName, + githubRepo, + deployBranch, + apiOriginDomain, + apiOriginProtocol, + domainNames, + certificateArn, + hostedZoneId, + hostedZoneName, + } = props; + + const useHttpsApiOrigin = apiOriginProtocol === "https"; + 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", { @@ -79,6 +125,15 @@ export class FrontendStack extends Stack { 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), @@ -94,12 +149,16 @@ export class FrontendStack extends Stack { ], }, additionalBehaviors: { - // Proxy API calls to the HTTP-only backend. The SPA calls same-origin - // `/api/...` over HTTPS; CloudFront forwards to the origin over HTTP. + // Proxy API calls to the backend. The SPA calls same-origin `/api/...` + // over HTTPS; CloudFront forwards to the origin over HTTP (Option A) or + // HTTPS (Option B). Either way there's no mixed content or CORS. "/api/*": { origin: new origins.HttpOrigin(apiOriginDomain, { - protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, + protocolPolicy: useHttpsApiOrigin + ? cloudfront.OriginProtocolPolicy.HTTPS_ONLY + : cloudfront.OriginProtocolPolicy.HTTP_ONLY, httpPort: 80, + httpsPort: 443, }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, @@ -172,12 +231,34 @@ export class FrontendStack extends Stack { }), ); + // --- 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: `https://${distribution.distributionDomainName}`, + 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, });