diff --git a/.env.production b/.env.production index 81c2f877..1183d018 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,4 @@ -# 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 +# Production API base — the SPA calls the backend directly over HTTPS. +# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging +# and prod builds must override VITE_API_URL per environment (api.staging..., etc.). +VITE_API_URL=https://api.dev.seahaven.com/api diff --git a/README.md b/README.md index 54e668b9..f3926216 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,10 @@ 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. +- Served on the custom domain `dev.seahaven.com`; the SPA calls the backend + directly over HTTPS at `VITE_API_URL` (`https://api.dev.seahaven.com/api`, + cross-origin — the backend allows CORS). `VITE_API_URL` is baked into the + build, so it is per-environment. - 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). diff --git a/infra/cdk/README.md b/infra/cdk/README.md index ceaa578e..37384c2e 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -5,10 +5,12 @@ deployed through the org's **reusable** GitHub Actions workflow. - **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. +- **API:** the SPA calls the backend **directly** over HTTPS at + `https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend + allows CORS). CloudFront serves static content only — no `/api` proxy. +- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy` + picks them up with no flags. `VITE_API_URL` is baked into the build, so it's + per-environment (see the note under "Adding staging / prod"). - **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` @@ -32,7 +34,7 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation | 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 distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-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` | @@ -73,21 +75,20 @@ npm ci npx cdk bootstrap aws:///us-east-1 ``` -### 4. Domain, cert, and API origin (already wired for dev) +### 4. Domain, cert, and API URL (already wired for dev) -For dev these are set in `cdk.json` context (account `396287094661`): +Domain/cert/zone are set in `cdk.json` context (account `396287094661`): -| 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` | +| Context key | Value | +| --------------------------------- | ------------------------------------------------------------ | +| `domainNames` | `dev.seahaven.com` | +| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) | +| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` | -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. +The stack creates the apex A/AAAA alias in the hosted zone (in this account, +delegated from the parent `seahaven.com` zone). The **API URL is not infra** — +it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`), +baked into the build. Per-environment; override for staging/prod. ### 5. First deploy (locally, with admin creds) @@ -138,12 +139,16 @@ the `SiteUrl` output. ## Adding staging / prod later -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`. +Separate accounts: deploy this stack there with per-env `domainNames`, +`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's +`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`. + +Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is +baked into `vite build`** — so each environment needs its own build with its own +API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment +in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than +relying on the committed `.env.production` (which carries the dev value). The +backend must also allow CORS from each frontend origin. ## Notes diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index d5df3f11..9ef86c8e 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -9,14 +9,7 @@ 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 +// Custom domain. Comma-separated, e.g. -c domainNames=dev.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(",") @@ -32,8 +25,6 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { envName, githubRepo, deployBranch, - apiOriginDomain, - apiOriginProtocol, domainNames, certificateArn, hostedZoneId, diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json index b346a8ef..aaecf396 100644 --- a/infra/cdk/cdk.json +++ b/infra/cdk/cdk.json @@ -13,8 +13,6 @@ "//": "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 bf9f94c1..be3a558c 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -16,19 +16,8 @@ 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 (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). + * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. + * Empty = serve on the default *.cloudfront.net domain. */ readonly domainNames: string[]; /** @@ -68,15 +57,12 @@ export class FrontendStack extends Stack { 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( @@ -97,11 +83,9 @@ export class FrontendStack extends Stack { autoDeleteObjects: true, }); - // 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. + // SPA client-side routing: rewrite extensionless paths (e.g. /work-orders) + // to /index.html so deep links resolve. Done with a CloudFront Function + // rather than customErrorResponses so real asset 404s stay 404s. const spaRewrite = new cloudfront.Function(this, "SpaRewrite", { comment: "SPA routing: rewrite extensionless paths to /index.html", code: cloudfront.FunctionCode.fromInline( @@ -119,7 +103,9 @@ export class FrontendStack extends Stack { ), }); - // --- CloudFront: S3 (SPA) default behavior + /api proxy behavior ------- + // --- CloudFront: serves the static SPA from S3 ------------------------- + // The SPA calls the backend directly at its absolute HTTPS URL + // (VITE_API_URL, cross-origin), so CloudFront hosts only static content. const distribution = new cloudfront.Distribution(this, "Distribution", { comment: `SeaHaven SHOC frontend (${envName})`, defaultRootObject: "index.html", @@ -148,27 +134,6 @@ export class FrontendStack extends Stack { }, ], }, - additionalBehaviors: { - // 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: useHttpsApiOrigin - ? cloudfront.OriginProtocolPolicy.HTTPS_ONLY - : cloudfront.OriginProtocolPolicy.HTTP_ONLY, - httpPort: 80, - httpsPort: 443, - }), - 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, - }, - }, }); // --- GitHub Actions OIDC deploy role -----------------------------------