mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
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:
parent
6fa2fea602
commit
8bd89a1f9d
5 changed files with 87 additions and 45 deletions
|
|
@ -1,2 +1,4 @@
|
||||||
# Production API URL
|
# Production API base (same-origin). CloudFront proxies /api/* to the backend,
|
||||||
VITE_API_URL=http://console.seahavenind.com/api
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,13 @@ keys):
|
||||||
- Push to `dev` → `.github/workflows/deploy.yml` calls the org reusable
|
- Push to `dev` → `.github/workflows/deploy.yml` calls the org reusable
|
||||||
`cd-cdk.yaml`, which runs `cdk deploy` (infra) then `scripts/deploy-web.sh`
|
`cd-cdk.yaml`, which runs `cdk deploy` (infra) then `scripts/deploy-web.sh`
|
||||||
(builds the SPA, syncs `dist/` to S3, invalidates CloudFront).
|
(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
|
- First-time provisioning (OIDC provider, CDK bootstrap, first local deploy, the
|
||||||
`AWS_DEPLOY_ROLE_ARN` secret) is a one-time admin task — see
|
`AWS_DEPLOY_ROLE_ARN` secret) is a one-time admin task — see
|
||||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
[`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
|
## Development proxy
|
||||||
|
|
||||||
During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`).
|
During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`).
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
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.
|
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)
|
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
||||||
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
|
- **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`
|
`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
|
## What the stack creates
|
||||||
|
|
||||||
| Resource | Purpose |
|
| Resource | Purpose |
|
||||||
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
|
| 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 |
|
| CloudFront distribution | HTTPS, gzip/br; default behavior → S3, `/api/*` → backend (HTTP origin) |
|
||||||
| 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` |
|
| 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
|
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
|
`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
|
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
|
The app calls its API same-origin at `/api` (`VITE_API_URL=/api` in
|
||||||
public URL, not a secret). The reusable workflow can't inject it, so it must
|
`.env.production`), and CloudFront proxies `/api/*` to the backend over HTTP.
|
||||||
live there. It **must be HTTPS and end in `/api`** — CloudFront serves the app
|
The backend host defaults to `console.seahavenind.com`; override it if the dev
|
||||||
over HTTPS and browsers block HTTP (mixed-content) API calls.
|
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)
|
### 5. First deploy (locally, with admin creds)
|
||||||
|
|
||||||
|
|
@ -128,13 +134,12 @@ the `SiteUrl` output.
|
||||||
|
|
||||||
## Adding staging / prod later
|
## Adding staging / prod later
|
||||||
|
|
||||||
Separate accounts: deploy this stack there with `-c envName=staging`
|
Separate accounts: deploy this stack there with `-c envName=staging`,
|
||||||
(and `-c deployBranch=<branch>`), set that repo's `AWS_DEPLOY_ROLE_ARN` secret,
|
`-c deployBranch=<branch>`, and `-c apiOriginDomain=<that-env's-api-host>`; set
|
||||||
and add a job to `deploy.yml`. Because `.env.production` bakes a single API URL
|
that repo's `AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`. Because
|
||||||
into every `vite build`, multi-env needs a per-account value — the AWS-native
|
the SPA calls `/api` same-origin, the API host is per-environment CloudFront
|
||||||
fit is to have `scripts/deploy-web.sh` do `aws ssm get-parameter` →
|
config (the `apiOriginDomain` context) — the build output is identical across
|
||||||
`export VITE_API_URL` before the build (one SSM param per account; add
|
environments, so nothing env-specific gets baked into `vite build`.
|
||||||
`ssm:GetParameter` to the role). Not needed for dev-only.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ const app = new App();
|
||||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||||
|
const apiOriginDomain = app.node.tryGetContext("apiOriginDomain") ?? "console.seahavenind.com";
|
||||||
|
|
||||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||||
envName,
|
envName,
|
||||||
githubRepo,
|
githubRepo,
|
||||||
deployBranch,
|
deployBranch,
|
||||||
|
apiOriginDomain,
|
||||||
env: {
|
env: {
|
||||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ export interface FrontendStackProps extends StackProps {
|
||||||
readonly githubRepo: string;
|
readonly githubRepo: string;
|
||||||
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
||||||
readonly deployBranch: string;
|
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) {
|
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||||
super(scope, id, props);
|
super(scope, id, props);
|
||||||
|
|
||||||
const { envName, githubRepo, deployBranch } = props;
|
const { envName, githubRepo, deployBranch, apiOriginDomain } = props;
|
||||||
|
|
||||||
// --- Origin bucket: private, encrypted, no public access ----------------
|
// --- Origin bucket: private, encrypted, no public access ----------------
|
||||||
const bucket = new s3.Bucket(this, "SiteBucket", {
|
const bucket = new s3.Bucket(this, "SiteBucket", {
|
||||||
|
|
@ -45,7 +51,29 @@ export class FrontendStack extends Stack {
|
||||||
autoDeleteObjects: true,
|
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", {
|
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
||||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||||
defaultRootObject: "index.html",
|
defaultRootObject: "index.html",
|
||||||
|
|
@ -58,24 +86,30 @@ export class FrontendStack extends Stack {
|
||||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||||
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
||||||
compress: true,
|
compress: true,
|
||||||
|
functionAssociations: [
|
||||||
|
{
|
||||||
|
function: spaRewrite,
|
||||||
|
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// With a private bucket + OAC, a missing key returns 403 (not 404), so the
|
additionalBehaviors: {
|
||||||
// 403 -> index.html mapping is what makes react-router deep links work on
|
// Proxy API calls to the HTTP-only backend. The SPA calls same-origin
|
||||||
// hard refresh. 404 is mapped too for completeness.
|
// `/api/...` over HTTPS; CloudFront forwards to the origin over HTTP.
|
||||||
errorResponses: [
|
"/api/*": {
|
||||||
{
|
origin: new origins.HttpOrigin(apiOriginDomain, {
|
||||||
httpStatus: 403,
|
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
|
||||||
responseHttpStatus: 200,
|
httpPort: 80,
|
||||||
responsePagePath: "/index.html",
|
}),
|
||||||
ttl: Duration.seconds(0),
|
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 -----------------------------------
|
// --- GitHub Actions OIDC deploy role -----------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue