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=<host>` (default
console.seahavenind.com).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexandre Brandizzi 2026-07-02 14:17:27 -03:00
parent 6fa2fea602
commit 8bd89a1f9d
5 changed files with 87 additions and 45 deletions

View file

@ -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

View file

@ -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`).

View file

@ -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://<ACCOUNT_ID>/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=<dev-api-host>
```
# .env.production
VITE_API_URL=https://<dev-api-host>/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=<branch>`), 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=<branch>`, and `-c apiOriginDomain=<that-env's-api-host>`; 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

View file

@ -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",

View file

@ -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 -----------------------------------