From 8bd89a1f9d58a3b94e6a7e04f7d7fa9aafd135e1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 2 Jul 2026 14:17:27 -0300 Subject: [PATCH] feat(infra): proxy /api through CloudFront to the backend 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=` (default console.seahavenind.com). Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.production | 6 ++- README.md | 7 ++-- infra/cdk/README.md | 47 ++++++++++++---------- infra/cdk/bin/app.ts | 2 + infra/cdk/lib/frontend-stack.ts | 70 ++++++++++++++++++++++++--------- 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/.env.production b/.env.production index 463f1b57..81c2f877 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,4 @@ -# Production API URL -VITE_API_URL=http://console.seahavenind.com/api +# Production API base (same-origin). CloudFront proxies /api/* to the backend, +# so the HTTPS SPA reaches the HTTP-only API without mixed-content blocking. +# The backend host is set on the CloudFront /api behavior in infra/cdk. +VITE_API_URL=/api diff --git a/README.md b/README.md index 78037e89..54e668b9 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,13 @@ keys): - Push to `dev` → `.github/workflows/deploy.yml` calls the org reusable `cd-cdk.yaml`, which runs `cdk deploy` (infra) then `scripts/deploy-web.sh` (builds the SPA, syncs `dist/` to S3, invalidates CloudFront). +- The SPA calls its API same-origin at `/api`; CloudFront proxies `/api/*` to + the backend, so the HTTPS app can use the HTTP-only API with no mixed-content + blocking. - First-time provisioning (OIDC provider, CDK bootstrap, first local deploy, the `AWS_DEPLOY_ROLE_ARN` secret) is a one-time admin task — see [`infra/cdk/README.md`](infra/cdk/README.md). -> The deployed build's `VITE_API_URL` comes from `.env.production` and must be an -> **HTTPS** URL ending in `/api` (CloudFront serves HTTPS; HTTP API calls are -> blocked as mixed content). - ## Development proxy During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`). diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 18df72c3..e42946c9 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -3,7 +3,9 @@ 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 fallback) +- **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`. - **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` @@ -24,11 +26,12 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation ## What the stack creates -| Resource | Purpose | -| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) | -| CloudFront distribution | HTTPS, `403/404 → 200 /index.html` SPA fallback, gzip/br | -| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | +| Resource | Purpose | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) | +| CloudFront distribution | HTTPS, gzip/br; default behavior → S3, `/api/*` → backend (HTTP origin) | +| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) | +| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on `cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's @@ -67,17 +70,20 @@ npm ci npx cdk bootstrap aws:///us-east-1 ``` -### 4. Set the dev API URL (gates a _working_ app) +### 4. Confirm the backend API origin -The build reads `VITE_API_URL` from **`.env.production`** (committed; it's a -public URL, not a secret). The reusable workflow can't inject it, so it must -live there. It **must be HTTPS and end in `/api`** — CloudFront serves the app -over HTTPS and browsers block HTTP (mixed-content) API calls. +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: +```bash +# default is fine for dev; otherwise: +npx cdk deploy -c apiOriginDomain= ``` -# .env.production -VITE_API_URL=https:///api -``` + +No mixed-content risk: the browser talks HTTPS to CloudFront, and CloudFront +talks HTTP to the origin. ### 5. First deploy (locally, with admin creds) @@ -128,13 +134,12 @@ the `SiteUrl` output. ## Adding staging / prod later -Separate accounts: deploy this stack there with `-c envName=staging` -(and `-c deployBranch=`), set that repo's `AWS_DEPLOY_ROLE_ARN` secret, -and add a job to `deploy.yml`. Because `.env.production` bakes a single API URL -into every `vite build`, multi-env needs a per-account value — the AWS-native -fit is to have `scripts/deploy-web.sh` do `aws ssm get-parameter` → -`export VITE_API_URL` before the build (one SSM param per account; add -`ssm:GetParameter` to the role). Not needed for dev-only. +Separate accounts: deploy this stack there with `-c envName=staging`, +`-c deployBranch=`, and `-c apiOriginDomain=`; set +that repo's `AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`. Because +the SPA calls `/api` same-origin, the API host is per-environment CloudFront +config (the `apiOriginDomain` context) — the build output is identical across +environments, so nothing env-specific gets baked into `vite build`. ## Notes diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 565aa29c..483c9e08 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -8,11 +8,13 @@ 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"; +const apiOriginDomain = app.node.tryGetContext("apiOriginDomain") ?? "console.seahavenind.com"; const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { envName, githubRepo, deployBranch, + apiOriginDomain, env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index d609e8df..2270d5ae 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -12,6 +12,12 @@ export interface FrontendStackProps extends StackProps { 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; } /** @@ -30,7 +36,7 @@ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { super(scope, id, props); - const { envName, githubRepo, deployBranch } = props; + const { envName, githubRepo, deployBranch, apiOriginDomain } = props; // --- Origin bucket: private, encrypted, no public access ---------------- const bucket = new s3.Bucket(this, "SiteBucket", { @@ -45,7 +51,29 @@ export class FrontendStack extends Stack { autoDeleteObjects: true, }); - // --- CloudFront: OAC origin, HTTPS, SPA fallback ----------------------- + // 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", @@ -58,24 +86,30 @@ export class FrontendStack extends Stack { cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, compress: true, + functionAssociations: [ + { + function: spaRewrite, + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + }, + ], }, - // 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), + 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, }, - { - httpStatus: 404, - responseHttpStatus: 200, - responsePagePath: "/index.html", - ttl: Duration.seconds(0), - }, - ], + }, }); // --- GitHub Actions OIDC deploy role -----------------------------------