diff --git a/.env.production b/.env.production index 463f1b57..1183d018 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 — 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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..4942324e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy + +# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`. +# +# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs +# `cdk deploy` (provisioning the infra in infra/cdk) and then the +# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates +# CloudFront. Both run as the OIDC deploy role created by the stack. +# +# When staging/prod accounts exist, add jobs keyed to their branches and their +# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow. + +on: + push: + branches: [dev] + workflow_dispatch: {} + +# OIDC needs id-token: write — it is never in the default token set and cannot +# be granted to the reusable workflow unless the caller has it. +permissions: + id-token: write + contents: read + +concurrency: + group: deploy-dev + cancel-in-progress: false + +jobs: + deploy: + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main + with: + node-version: "24" + region: us-east-1 + cdk-dir: infra/cdk + stack-name: shoc-frontend-dev + post-deploy-script: scripts/deploy-web.sh + secrets: + deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} diff --git a/.gitignore b/.gitignore index 5f7e4c5a..73605dce 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,12 @@ seed-data.sql .eslintcache # typescript -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +# cdk (infra/cdk) +infra/cdk/node_modules +infra/cdk/cdk.out +infra/cdk/cdk.context.json +infra/cdk/*.d.ts +infra/cdk/bin/*.js +infra/cdk/lib/*.js \ No newline at end of file diff --git a/README.md b/README.md index c5d10b6f..f3926216 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,24 @@ GitHub Actions workflow (`.github/workflows/ci.yml`) runs on push and pull reque Local pre-commit hooks (Husky + lint-staged) run ESLint and Prettier on staged files. +## Deployment (CI/CD) + +The app is hosted on **AWS S3 + CloudFront**, provisioned by an **AWS CDK** app +local to this repo ([`infra/cdk/`](infra/cdk/README.md)). Deployment runs +through the org's reusable GitHub Actions workflow via **OIDC** (no stored AWS +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). +- 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). + ## 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 new file mode 100644 index 00000000..37384c2e --- /dev/null +++ b/infra/cdk/README.md @@ -0,0 +1,161 @@ +# Infrastructure & CI/CD — SeaHaven SHOC frontend + +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, served on the custom + domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). +- **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` + (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). +- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is + created by this stack, not added to the central `oidc-deploy-roles.yaml`. +- **Environments:** `dev` only today, deployed on push to the `dev` branch. + +``` +infra/cdk/ + bin/app.ts entry point (reads -c context) + lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role +scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation +.github/workflows/ + ci.yml quality gates (lint / build / test / e2e) + deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) +``` + +## What the stack creates + +| Resource | Purpose | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) | +| 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` | + +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 +pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`), +and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a +singleton account resource — the stack only _imports_ it (created in step 2), +so `cdk destroy` can't delete a resource shared by other roles. + +--- + +## One-time setup (run by a human with admin AWS creds) + +### 1. Authenticate to the AWS account + +```bash +aws configure # or: aws sso login --profile +aws sts get-caller-identity # confirm the right account + region (us-east-1) +``` + +### 2. Ensure the GitHub OIDC provider exists (once per account) + +```bash +aws iam list-open-id-connect-providers +# If none ends in token.actions.githubusercontent.com, create it (thumbprint is +# no longer required — AWS validates GitHub against its own trust store): +aws iam create-open-id-connect-provider \ + --url https://token.actions.githubusercontent.com \ + --client-id-list sts.amazonaws.com +``` + +### 3. CDK bootstrap (once per account/region) + +```bash +cd infra/cdk +npm ci +npx cdk bootstrap aws:///us-east-1 +``` + +### 4. Domain, cert, and API URL (already wired for dev) + +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` | + +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) + +The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it +locally. This provisions infra + the role: + +```bash +cd infra/cdk +npx cdk deploy +``` + +Note the `DeployRoleArn` output. Then push the first content (or just push to +`dev` and let CI do everything from here on): + +```bash +# from repo root, optional manual first content publish: +STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh +``` + +### 6. Set the one GitHub secret + +`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable): + +```bash +REPO=Sea-Haven-Industries/shoc-frontend-new +gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \ + --body "arn:aws:iam:::role/githubdeploy-shoc-frontend-new-dev" +``` + +(Or **Settings → Secrets and variables → Actions → Secrets**.) + +### 7. From now on: push to `dev` + +```bash +git push origin dev +``` + +`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs +`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open +the `SiteUrl` output. + +> First-run verification: this first push is what actually exercises the role's +> permissions and the OIDC trust through the reusable workflow (the local +> bootstrap used admin creds and tested none of that). Watch for +> credential/OIDC errors and a green post-deploy step. + +--- + +## Adding staging / prod later + +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 + +- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + + `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. +- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still + deploys (matches the org's push-time-CD model). Gating deploy on CI is a + follow-up, not part of enabling CICD. +- **Local npm is pinned to v6**; the committed `package-lock.json` is + lockfileVersion 1. CI (Node 24 / npm 11) reads it fine via `npm ci`. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts new file mode 100644 index 00000000..9ef86c8e --- /dev/null +++ b/infra/cdk/bin/app.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { App, Tags } from "aws-cdk-lib"; +import { FrontendStack } from "../lib/frontend-stack"; + +const app = new App(); + +// Defaults match the dev setup; override via `-c key=value` on the CLI. +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"; + +// 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(",") + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0); +const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; + +// Route 53 hosted zone (this account) for the custom-domain alias record. +const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; +const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; + +const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { + envName, + githubRepo, + deployBranch, + domainNames, + certificateArn, + hostedZoneId, + hostedZoneName, + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", + }, +}); + +Tags.of(stack).add("Project", "shoc-frontend"); +Tags.of(stack).add("Environment", envName); +Tags.of(stack).add("ManagedBy", "cdk"); diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json new file mode 100644 index 00000000..aaecf396 --- /dev/null +++ b/infra/cdk/cdk.json @@ -0,0 +1,19 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"] + }, + "context": { + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true, + + "//": "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", + "hostedZoneId": "Z07671212N75U4YLPWZR8", + "hostedZoneName": "dev.seahaven.com" + } +} diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts new file mode 100644 index 00000000..be3a558c --- /dev/null +++ b/infra/cdk/lib/frontend-stack.ts @@ -0,0 +1,238 @@ +import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import * as targets from "aws-cdk-lib/aws-route53-targets"; + +export interface FrontendStackProps extends StackProps { + /** Environment label, e.g. "dev". Used in names/tags. */ + readonly envName: string; + /** GitHub repo in owner/name form, for OIDC trust scoping. */ + readonly githubRepo: string; + /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ + readonly deployBranch: string; + /** + * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. + * Empty = serve on the default *.cloudfront.net domain. + */ + readonly domainNames: string[]; + /** + * ARN of an ACM certificate (us-east-1, SAME account as this stack) covering + * `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot + * use a certificate from another account, so for Option B the cert must live + * in whichever account this stack deploys to. + */ + readonly certificateArn: string; + /** + * Route 53 hosted zone (in THIS account) to create the custom-domain alias + * record in. Empty = don't manage DNS (add the record manually). When set, + * hostedZoneName must also be provided. + */ + readonly hostedZoneId: string; + /** Name of the hosted zone above, e.g. "dev.seahaven.com". */ + readonly hostedZoneName: string; +} + +/** + * Static SPA hosting for the SeaHaven SHOC frontend: + * - private S3 bucket (no public access; CloudFront reads it via OAC) + * - CloudFront distribution (HTTPS, SPA deep-link fallback) + * - a GitHub Actions OIDC deploy role + * + * Content (the built `dist/`) is NOT uploaded here. The org's reusable + * `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to + * build the SPA, sync it to this bucket, and invalidate CloudFront — so this + * stack only owns the infrastructure, and the deploy role carries the + * permissions those post-deploy steps need. + */ +export class FrontendStack extends Stack { + constructor(scope: Construct, id: string, props: FrontendStackProps) { + super(scope, id, props); + + const { + envName, + githubRepo, + deployBranch, + domainNames, + certificateArn, + hostedZoneId, + hostedZoneName, + } = props; + + const hasCustomDomain = domainNames.length > 0; + if (hasCustomDomain && !certificateArn) { + throw new Error( + "certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).", + ); + } + + // --- Origin bucket: private, encrypted, no public access ---------------- + const bucket = new s3.Bucket(this, "SiteBucket", { + bucketName: `seahaven-shoc-frontend-${envName}`, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + versioned: true, + // dev artifacts are reproducible from the build — safe to tear down. + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + // 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( + [ + "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: 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", + priceClass: cloudfront.PriceClass.PRICE_CLASS_100, + httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, + // Option B: serve on the custom domain(s) with the ACM cert. When unset, + // CloudFront uses its default *.cloudfront.net domain + certificate. + domainNames: hasCustomDomain ? domainNames : undefined, + certificate: hasCustomDomain + ? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn) + : undefined, + minimumProtocolVersion: hasCustomDomain + ? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021 + : undefined, + defaultBehavior: { + // withOriginAccessControl wires up OAC + the bucket policy automatically. + origin: origins.S3BucketOrigin.withOriginAccessControl(bucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, + compress: true, + functionAssociations: [ + { + function: spaRewrite, + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }); + + // --- GitHub Actions OIDC deploy role ----------------------------------- + // The OIDC provider is a singleton account-global resource, created once + // out-of-band (see README step 2) — we only IMPORT it here so this stack's + // lifecycle (including `cdk destroy`) never deletes a resource shared by + // every role in the account. + const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn( + this, + "GitHubOidcProvider", + `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, + ); + + const deployRole = new iam.Role(this, "GithubDeployRole", { + roleName: `githubdeploy-shoc-frontend-new-${envName}`, + description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, + maxSessionDuration: Duration.hours(1), + assumedBy: new iam.OpenIdConnectPrincipal(provider, { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }), + }); + + // The whole `cd-cdk.yaml` job runs as this role. Permissions it needs: + // 1. assume the CDK bootstrap roles -> `cdk deploy` + // 2. describe the stack -> cd-cdk pre-flight / health-check / output reads + // 3. read/write the bucket -> post-deploy `aws s3 sync` + // 4. invalidate the distribution -> post-deploy cache bust + deployRole.addToPolicy( + new iam.PolicyStatement({ + sid: "AssumeCdkBootstrapRoles", + actions: ["sts:AssumeRole"], + resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], + }), + ); + deployRole.addToPolicy( + new iam.PolicyStatement({ + sid: "DescribeStack", + actions: ["cloudformation:DescribeStacks"], + resources: [ + `arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`, + ], + }), + ); + bucket.grantReadWrite(deployRole); + deployRole.addToPolicy( + new iam.PolicyStatement({ + sid: "InvalidateDistribution", + actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"], + resources: [ + `arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`, + ], + }), + ); + + // --- DNS: point the custom domain at CloudFront ------------------------ + // Only when a hosted zone is supplied (it must be in THIS account). Creates + // A + AAAA aliases; for the zone apex, recordName is the zone itself. + if (hostedZoneId && hasCustomDomain) { + const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", { + hostedZoneId, + zoneName: hostedZoneName, + }); + const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)); + // apex record when the domain equals the zone name. + const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0]; + + new route53.ARecord(this, "AliasA", { zone, recordName, target }); + new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target }); + } + + // --- Outputs ----------------------------------------------------------- + // scripts/deploy-web.sh reads BucketName + DistributionId from these. + new CfnOutput(this, "SiteUrl", { + value: hasCustomDomain + ? `https://${domainNames[0]}` + : `https://${distribution.distributionDomainName}`, + description: "Public URL of the deployed SPA", + }); + new CfnOutput(this, "DistributionDomainName", { + value: distribution.distributionDomainName, + description: "CloudFront domain — point the custom-domain DNS record here", + }); + new CfnOutput(this, "BucketName", { + value: bucket.bucketName, + }); + new CfnOutput(this, "DistributionId", { + value: distribution.distributionId, + }); + new CfnOutput(this, "DeployRoleArn", { + value: deployRole.roleArn, + description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN", + }); + } +} diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json new file mode 100644 index 00000000..e0d1123c --- /dev/null +++ b/infra/cdk/package-lock.json @@ -0,0 +1,410 @@ +{ + "name": "shoc-frontend-infra", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@aws-cdk/asset-awscli-v1": { + "version": "2.2.282", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", + "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==" + }, + "@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", + "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==" + }, + "@aws-cdk/cloud-assembly-schema": { + "version": "54.5.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz", + "integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==", + "requires": { + "jsonschema": "^1.5.0", + "semver": "^7.8.4" + }, + "dependencies": { + "jsonschema": { + "version": "1.5.0", + "bundled": true + }, + "semver": { + "version": "7.8.4", + "bundled": true + } + } + }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + } + }, + "acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true + }, + "acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "requires": { + "acorn": "^8.11.0" + } + }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "aws-cdk": { + "version": "2.1128.1", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.1.tgz", + "integrity": "sha512-y9OHn5/BOcIiq409vPvpypMIr7/8M1ScFe8IkFMSCN1/GI/5c73fQ4pfzNq+VDkj86T5zxs7BQ1qU2lQQytdXA==", + "dev": true + }, + "aws-cdk-lib": { + "version": "2.260.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz", + "integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==", + "requires": { + "@aws-cdk/asset-awscli-v1": "2.2.282", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", + "@aws-cdk/cloud-assembly-api": "^2.2.5", + "@aws-cdk/cloud-assembly-schema": "^54.0.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.5", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.5", + "punycode": "^2.3.1", + "semver": "^7.8.1", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "dependencies": { + "@aws-cdk/cloud-assembly-api": { + "version": "2.2.5", + "bundled": true, + "requires": { + "jsonschema": "^1.5.0", + "semver": "^7.8.0" + } + }, + "@balena/dockerignore": { + "version": "1.0.2", + "bundled": true + }, + "ajv": { + "version": "8.20.0", + "bundled": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "bundled": true + }, + "ansi-styles": { + "version": "4.3.0", + "bundled": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "astral-regex": { + "version": "2.0.0", + "bundled": true + }, + "balanced-match": { + "version": "4.0.4", + "bundled": true + }, + "brace-expansion": { + "version": "5.0.6", + "bundled": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "case": { + "version": "1.6.3", + "bundled": true + }, + "color-convert": { + "version": "2.0.1", + "bundled": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "bundled": true + }, + "emoji-regex": { + "version": "8.0.0", + "bundled": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "bundled": true + }, + "fast-uri": { + "version": "3.1.2", + "bundled": true + }, + "fs-extra": { + "version": "11.3.5", + "bundled": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "graceful-fs": { + "version": "4.2.11", + "bundled": true + }, + "ignore": { + "version": "5.3.2", + "bundled": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "bundled": true + }, + "json-schema-traverse": { + "version": "1.0.0", + "bundled": true + }, + "jsonfile": { + "version": "6.2.1", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonschema": { + "version": "1.5.0", + "bundled": true + }, + "lodash.truncate": { + "version": "4.4.2", + "bundled": true + }, + "mime-db": { + "version": "1.52.0", + "bundled": true + }, + "mime-types": { + "version": "2.1.35", + "bundled": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "minimatch": { + "version": "10.2.5", + "bundled": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "punycode": { + "version": "2.3.1", + "bundled": true + }, + "require-from-string": { + "version": "2.0.2", + "bundled": true + }, + "semver": { + "version": "7.8.1", + "bundled": true + }, + "slice-ansi": { + "version": "4.0.0", + "bundled": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "string-width": { + "version": "4.2.3", + "bundled": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "bundled": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "table": { + "version": "6.9.0", + "bundled": true, + "requires": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + } + }, + "universalify": { + "version": "2.0.1", + "bundled": true + }, + "yaml": { + "version": "1.10.3", + "bundled": true + } + } + }, + "constructs": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==" + }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true + }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + } + }, + "typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true + }, + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + } + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json new file mode 100644 index 00000000..1ca0dfd1 --- /dev/null +++ b/infra/cdk/package.json @@ -0,0 +1,25 @@ +{ + "name": "shoc-frontend-infra", + "version": "0.1.0", + "private": true, + "description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the SeaHaven SHOC frontend.", + "bin": { + "app": "bin/app.ts" + }, + "scripts": { + "build": "tsc", + "synth": "cdk synth", + "diff": "cdk diff", + "deploy": "cdk deploy" + }, + "devDependencies": { + "@types/node": "^22.15.30", + "aws-cdk": "^2.1021.0", + "ts-node": "^10.9.2", + "typescript": "~5.8.3" + }, + "dependencies": { + "aws-cdk-lib": "^2.220.0", + "constructs": "^10.4.2" + } +} diff --git a/infra/cdk/tsconfig.json b/infra/cdk/tsconfig.json new file mode 100644 index 00000000..37092ab0 --- /dev/null +++ b/infra/cdk/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["bin/**/*.ts", "lib/**/*.ts"], + "exclude": ["node_modules", "cdk.out"] +} diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh new file mode 100755 index 00000000..12c1b021 --- /dev/null +++ b/scripts/deploy-web.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Post-deploy step for the org reusable workflow `cd-cdk.yaml` +# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`). +# +# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub +# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with +# the right cache headers, and invalidates CloudFront. +# +# Runs from the repo root. Reads the bucket + distribution from stack outputs, +# so it has no hardcoded resource IDs. +set -euo pipefail + +STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" +REGION="${AWS_REGION:-us-east-1}" + +echo "Building SPA (VITE_API_URL comes from .env.production)..." +npm ci +npm run build + +echo "Reading stack outputs from ${STACK_NAME}..." +stack_output() { + aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text +} + +BUCKET="$(stack_output BucketName)" +DIST_ID="$(stack_output DistributionId)" + +if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then + echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 + exit 1 +fi + +echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." +# Everything except index.html: long-lived + immutable, prune stale objects. +aws s3 sync dist/ "s3://${BUCKET}/" \ + --delete \ + --exclude "index.html" \ + --cache-control "public,max-age=31536000,immutable" + +echo "Uploading index.html (never cached)..." +aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" + +echo "Invalidating CloudFront ${DIST_ID}..." +aws cloudfront create-invalidation \ + --distribution-id "${DIST_ID}" \ + --paths "/*" + +echo "Web deploy complete."