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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 -----------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue