diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml
new file mode 100644
index 0000000..6393492
--- /dev/null
+++ b/.github/workflows/deploy-api.yaml
@@ -0,0 +1,271 @@
+name: Deploy API
+
+# Fargate image CD. GitHub Actions builds the API image, pushes to ECR, and
+# registers a new task definition. Terraform owns the cluster, service, ALB,
+# and ignores container_definitions / task_definition.
+#
+# push to main -> GitHub Environment dev, at github.sha
+# workflow_dispatch -> GitHub Environment dev. The workflow file must be main.
+# inputs.ref is only the image source. Deploy scripts stay
+# on github.sha, which is the trusted workflow commit.
+#
+# Cluster, service, ECR, and task env come from SSM after assuming the
+# Environment's DEPLOY_ROLE_ARN. Terraform owns /seahaven-ap/deploy/task-environment;
+# this workflow applies that JSON and writes GIT_SHA. Nothing here creates an HCP run.
+# Prod is AP-12.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "packages/api/**"
+ - "packages/shared/**"
+ - "package.json"
+ - "package-lock.json"
+ - "Dockerfile"
+ - ".dockerignore"
+ - "scripts/patch-ecs-task-def.py"
+ - ".github/workflows/deploy-api.yaml"
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: "Target Environment"
+ required: true
+ type: choice
+ options: [dev]
+ ref:
+ description: "Git ref to build and deploy (branch or SHA). Empty means the workflow ref."
+ required: false
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+
+jobs:
+ target:
+ name: Resolve target
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ environment: ${{ steps.resolve.outputs.environment }}
+ ref: ${{ steps.resolve.outputs.ref }}
+ steps:
+ - id: resolve
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ GITHUB_REF_NAME_IN: ${{ github.ref }}
+ GITHUB_SHA_IN: ${{ github.sha }}
+ INPUT_ENVIRONMENT: ${{ inputs.environment }}
+ INPUT_REF: ${{ inputs.ref }}
+ run: |
+ set -euo pipefail
+ case "${EVENT_NAME}" in
+ push)
+ if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
+ echo "push deploys only run from main" >&2
+ exit 1
+ fi
+ environment=dev
+ ref="${GITHUB_SHA_IN}"
+ ;;
+ workflow_dispatch)
+ if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
+ echo "workflow_dispatch deploys only run from main" >&2
+ exit 1
+ fi
+ environment="${INPUT_ENVIRONMENT:-dev}"
+ if [ "${environment}" != "dev" ]; then
+ echo "only GitHub Environment dev is allowed" >&2
+ exit 1
+ fi
+ ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
+ ;;
+ *)
+ echo "unsupported event ${EVENT_NAME}" >&2
+ exit 1
+ ;;
+ esac
+ {
+ echo "environment=${environment}"
+ echo "ref=${ref}"
+ } >> "${GITHUB_OUTPUT}"
+ echo "Deploying ${ref} to ${environment}"
+
+ deploy:
+ name: Deploy API to ${{ needs.target.outputs.environment }}
+ needs: target
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ environment: ${{ needs.target.outputs.environment }}
+ concurrency:
+ group: deploy-api-${{ needs.target.outputs.environment }}
+ cancel-in-progress: false
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ AWS_REGION: us-east-1
+ DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
+ steps:
+ - name: Checkout trusted workflow
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+ path: ci
+
+ - name: Checkout image source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.target.outputs.ref }}
+ persist-credentials: false
+ path: src
+
+ - name: Resolve commit
+ id: commit
+ working-directory: src
+ run: |
+ set -euo pipefail
+ sha="$(git rev-parse HEAD)"
+ echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
+ echo "Building ${sha}"
+
+ - name: Require deploy role
+ run: |
+ set -euo pipefail
+ if [ -z "${DEPLOY_ROLE_ARN}" ]; then
+ echo "DEPLOY_ROLE_ARN is empty. Create GitHub Environment dev and set it after the seahaven-ap-dev apply, then re-run." >&2
+ exit 1
+ fi
+
+ - name: Configure AWS credentials using OIDC
+ uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
+ with:
+ role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
+ aws-region: us-east-1
+ audience: sts.amazonaws.com
+
+ - name: Get deploy parameters
+ id: deploy
+ run: |
+ set -euo pipefail
+ get_param() {
+ local name="$1" err value
+ err="$(mktemp)"
+ if ! value="$(aws ssm get-parameter --name "${name}" --query Parameter.Value --output text 2>"${err}")"; then
+ if grep -q ParameterNotFound "${err}"; then
+ echo "SSM parameter ${name} does not exist yet. Apply the seahaven-ap-dev workspace, then re-run this workflow." >&2
+ else
+ cat "${err}" >&2
+ fi
+ rm -f "${err}"
+ exit 1
+ fi
+ rm -f "${err}"
+ printf '%s\n' "${value}"
+ }
+ CLUSTER=$(get_param /seahaven-ap/deploy/cluster)
+ SERVICE=$(get_param /seahaven-ap/deploy/service)
+ FAMILY=$(get_param /seahaven-ap/deploy/task-family)
+ ECR=$(get_param /seahaven-ap/deploy/ecr-repository)
+ CONTAINER=$(get_param /seahaven-ap/deploy/container-name)
+ DIST_ID=$(get_param /seahaven-ap/deploy/distribution-id)
+ DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
+ {
+ echo "cluster=${CLUSTER}"
+ echo "service=${SERVICE}"
+ echo "family=${FAMILY}"
+ echo "ecr=${ECR}"
+ echo "container=${CONTAINER}"
+ echo "site_url=https://${DOMAIN}"
+ } >> "${GITHUB_OUTPUT}"
+
+ - name: Login to Amazon ECR
+ uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
+
+ - name: Build image
+ env:
+ ECR: ${{ steps.deploy.outputs.ecr }}
+ GIT_SHA: ${{ steps.commit.outputs.sha }}
+ ENVIRONMENT: ${{ needs.target.outputs.environment }}
+ working-directory: src
+ run: |
+ set -euo pipefail
+ docker build \
+ --platform linux/amd64 \
+ --build-arg "GIT_SHA=${GIT_SHA}" \
+ -t "${ECR}:${GIT_SHA}" \
+ -t "${ECR}:${ENVIRONMENT}" \
+ .
+
+ - name: Push image
+ env:
+ ECR: ${{ steps.deploy.outputs.ecr }}
+ GIT_SHA: ${{ steps.commit.outputs.sha }}
+ ENVIRONMENT: ${{ needs.target.outputs.environment }}
+ run: |
+ set -euo pipefail
+ docker push "${ECR}:${GIT_SHA}"
+ docker push "${ECR}:${ENVIRONMENT}"
+
+ - name: Register task definition, migrate, and update service
+ env:
+ CLUSTER: ${{ steps.deploy.outputs.cluster }}
+ SERVICE: ${{ steps.deploy.outputs.service }}
+ FAMILY: ${{ steps.deploy.outputs.family }}
+ CONTAINER: ${{ steps.deploy.outputs.container }}
+ IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
+ GIT_SHA: ${{ steps.commit.outputs.sha }}
+ run: |
+ set -euo pipefail
+ TASK_ENV_JSON="$(aws ssm get-parameter \
+ --name /seahaven-ap/deploy/task-environment \
+ --with-decryption \
+ --query Parameter.Value \
+ --output text)"
+ export TASK_ENV_JSON
+ aws ecs describe-task-definition \
+ --task-definition "${FAMILY}" \
+ --query taskDefinition \
+ --output json \
+ | python3 "${GITHUB_WORKSPACE}/ci/scripts/patch-ecs-task-def.py" > /tmp/task-def.json
+ REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
+ NET="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \
+ --query 'services[0].networkConfiguration.awsvpcConfiguration' --output json)"
+ export NET
+ SUBNETS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["subnets"]))')"
+ SGS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["securityGroups"]))')"
+ RUN_JSON="$(aws ecs run-task \
+ --cluster "${CLUSTER}" \
+ --task-definition "${FAMILY}:${REV}" \
+ --launch-type FARGATE \
+ --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SGS}],assignPublicIp=ENABLED}" \
+ --overrides "{\"containerOverrides\":[{\"name\":\"${CONTAINER}\",\"command\":[\"node\",\"packages/api/dist/db/migrate.js\"],\"environment\":[{\"name\":\"DEV_AUTH_BYPASS\",\"value\":\"false\"}]}]}" \
+ --output json)"
+ TASK_ARN="$(printf '%s' "${RUN_JSON}" | python3 -c 'import json,sys; data=json.load(sys.stdin); tasks=data.get("tasks") or []; print(tasks[0].get("taskArn") or "" if tasks else "")')"
+ if [ -z "${TASK_ARN}" ] || [ "${TASK_ARN}" = "None" ]; then
+ echo "ecs run-task did not start a migrate task" >&2
+ printf '%s' "${RUN_JSON}" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(json.dumps(data.get("failures") or [], indent=2))' >&2
+ exit 1
+ fi
+ aws ecs wait tasks-stopped --cluster "${CLUSTER}" --tasks "${TASK_ARN}"
+ EXIT="$(aws ecs describe-tasks --cluster "${CLUSTER}" --tasks "${TASK_ARN}" \
+ --query 'tasks[0].containers[0].exitCode' --output text)"
+ if [ "${EXIT}" != "0" ]; then
+ echo "migrate task ${TASK_ARN} exited ${EXIT}" >&2
+ exit 1
+ fi
+ aws ecs update-service \
+ --cluster "${CLUSTER}" \
+ --service "${SERVICE}" \
+ --task-definition "${FAMILY}:${REV}" \
+ --force-new-deployment \
+ >/dev/null
+ aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
+
+ - name: Verify API health
+ env:
+ SITE_URL: ${{ steps.deploy.outputs.site_url }}
+ EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
+ run: bash "${GITHUB_WORKSPACE}/ci/scripts/verify-api-health.sh"
diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml
new file mode 100644
index 0000000..a496646
--- /dev/null
+++ b/.github/workflows/deploy-web.yaml
@@ -0,0 +1,199 @@
+name: Deploy Web
+
+# SPA CD. GitHub Actions syncs the committed placeholder to the S3 origin
+# bucket root, then invalidates CloudFront. Terraform owns the bucket and the
+# distribution and never touches content. Do not run a SPA production build.
+#
+# push to main -> GitHub Environment dev, at github.sha
+# workflow_dispatch -> GitHub Environment dev. The workflow file must be main.
+# inputs.ref selects the placeholder tree to publish.
+# Job steps are the workflow file, not scripts from that ref.
+#
+# Nothing here creates an HCP run. Prod is AP-12.
+
+on:
+ push:
+ branches: [main]
+ paths-ignore:
+ - "terraform/**"
+ - "packages/api/**"
+ - "packages/shared/**"
+ - "docs/**"
+ - "**/*.md"
+ - "Dockerfile"
+ - ".dockerignore"
+ - "scripts/verify-api-health.sh"
+ - "scripts/test-verify-api-health.sh"
+ - "scripts/test-terraform-dev-only.py"
+ - "scripts/patch-ecs-task-def.py"
+ - ".github/workflows/deploy-api.yaml"
+ - ".github/workflows/ci.yaml"
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: "Target Environment"
+ required: true
+ type: choice
+ options: [dev]
+ ref:
+ description: "Git ref to deploy (branch or SHA). Empty means the workflow ref."
+ required: false
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+
+jobs:
+ target:
+ name: Resolve target
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ environment: ${{ steps.resolve.outputs.environment }}
+ ref: ${{ steps.resolve.outputs.ref }}
+ steps:
+ - id: resolve
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ GITHUB_REF_NAME_IN: ${{ github.ref }}
+ GITHUB_SHA_IN: ${{ github.sha }}
+ INPUT_ENVIRONMENT: ${{ inputs.environment }}
+ INPUT_REF: ${{ inputs.ref }}
+ run: |
+ set -euo pipefail
+ case "${EVENT_NAME}" in
+ push)
+ if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
+ echo "push deploys only run from main" >&2
+ exit 1
+ fi
+ environment=dev
+ ref="${GITHUB_SHA_IN}"
+ ;;
+ workflow_dispatch)
+ if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
+ echo "workflow_dispatch deploys only run from main" >&2
+ exit 1
+ fi
+ environment="${INPUT_ENVIRONMENT:-dev}"
+ if [ "${environment}" != "dev" ]; then
+ echo "only GitHub Environment dev is allowed" >&2
+ exit 1
+ fi
+ ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
+ ;;
+ *)
+ echo "unsupported event ${EVENT_NAME}" >&2
+ exit 1
+ ;;
+ esac
+ {
+ echo "environment=${environment}"
+ echo "ref=${ref}"
+ } >> "${GITHUB_OUTPUT}"
+ echo "Deploying ${ref} to ${environment}"
+
+ deploy:
+ name: Deploy SPA to ${{ needs.target.outputs.environment }}
+ needs: target
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ environment: ${{ needs.target.outputs.environment }}
+ concurrency:
+ group: deploy-web-${{ needs.target.outputs.environment }}
+ cancel-in-progress: false
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ AWS_REGION: us-east-1
+ DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.target.outputs.ref }}
+ persist-credentials: false
+
+ - name: Resolve commit
+ id: commit
+ run: |
+ set -euo pipefail
+ sha="$(git rev-parse HEAD)"
+ echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
+ echo "Deploying ${sha}"
+ test -f placeholder/index.html
+
+ - name: Require deploy role
+ run: |
+ set -euo pipefail
+ if [ -z "${DEPLOY_ROLE_ARN}" ]; then
+ echo "DEPLOY_ROLE_ARN is empty. Create GitHub Environment dev and set it after the seahaven-ap-dev apply, then re-run." >&2
+ exit 1
+ fi
+
+ - name: Configure AWS credentials using OIDC
+ uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
+ with:
+ role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
+ aws-region: us-east-1
+ audience: sts.amazonaws.com
+
+ - name: Get deploy parameters
+ id: deploy
+ run: |
+ set -euo pipefail
+ get_param() {
+ local name="$1" err value
+ err="$(mktemp)"
+ if ! value="$(aws ssm get-parameter --name "${name}" --query Parameter.Value --output text 2>"${err}")"; then
+ if grep -q ParameterNotFound "${err}"; then
+ echo "SSM parameter ${name} does not exist yet. Apply the seahaven-ap-dev workspace, then re-run this workflow." >&2
+ else
+ cat "${err}" >&2
+ fi
+ rm -f "${err}"
+ exit 1
+ fi
+ rm -f "${err}"
+ printf '%s\n' "${value}"
+ }
+ BUCKET=$(get_param /seahaven-ap/deploy/bucket)
+ DIST_ID=$(get_param /seahaven-ap/deploy/distribution-id)
+ DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
+ {
+ echo "bucket=${BUCKET}"
+ echo "distribution_id=${DIST_ID}"
+ echo "site_url=https://${DOMAIN}"
+ } >> "${GITHUB_OUTPUT}"
+
+ - name: Sync placeholder/ to the bucket root
+ env:
+ SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
+ run: |
+ set -euo pipefail
+ aws s3 sync placeholder/ "s3://${SITE_BUCKET}/" \
+ --exclude "index.html" \
+ --cache-control "public,max-age=31536000,immutable"
+ aws s3 cp placeholder/index.html "s3://${SITE_BUCKET}/index.html" \
+ --cache-control "no-cache,no-store,must-revalidate" \
+ --content-type "text/html"
+ aws s3 sync placeholder/ "s3://${SITE_BUCKET}/" \
+ --delete \
+ --exclude "index.html" \
+ --cache-control "public,max-age=31536000,immutable"
+ aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
+
+ - name: Invalidate CloudFront
+ env:
+ DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
+ run: |
+ set -euo pipefail
+ invalidation_id="$(aws cloudfront create-invalidation \
+ --distribution-id "${DISTRIBUTION_ID}" \
+ --paths "/*" \
+ --query Invalidation.Id --output text)"
+ echo "Invalidation ${invalidation_id} created; waiting"
+ aws cloudfront wait invalidation-completed \
+ --distribution-id "${DISTRIBUTION_ID}" \
+ --id "${invalidation_id}"
diff --git a/README.md b/README.md
index 480e36c..a7b2f3a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Sea Haven AP
-Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin.
+Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. Hosted origin on seahaven-dev is the CloudFront default domain after HCP apply; GitHub Environment `dev` deploys the placeholder and API image.
## Workspace layout
@@ -54,9 +54,24 @@ npm run lint:api
npm run docs:preview # builds HTML via redocly build-docs and opens it
```
+## Hosted seahaven-dev
+
+HCP Terraform workspace `seahaven-ap-dev` (project `seahaven-dev`) uses working directory `terraform` and a `terraform/**` VCS trigger on `main`. GitHub Environment `dev` holds `DEPLOY_ROLE_ARN` for `githubdeploy-seahaven-ap`.
+
+The first apply creates secret `seahaven-ap/google-oidc` with `client_id` and `client_secret` set to `replace-me`. Replace both values in Secrets Manager, then re-run the HCP apply. A `terraform/**` change on `main` starts that apply. The Google IdP is not registered while the placeholder is still current.
+
+The merge that adds these workflows can start Deploy Web and Deploy API before that apply has written `/seahaven-ap/deploy/*` and before GitHub Environment `dev` has `DEPLOY_ROLE_ARN`. Those runs fail on purpose until both exist. Re-run them after the apply.
+
+- `.github/workflows/deploy-web.yaml` syncs `placeholder/` to the web bucket. It does not run `vite build`.
+- `.github/workflows/deploy-api.yaml` builds the API image with `GIT_SHA`, registers the task definition from `/seahaven-ap/deploy/task-environment`, migrates, and checks `GET /api/health`.
+
+Terraform `environment` is `dev` only. Prod hostname and GitHub Environment `prod` are AP-12.
+
## Verify
```bash
npm run verify
npm run test:e2e
+python3 scripts/test-terraform-dev-only.py
+bash scripts/test-verify-api-health.sh
```
diff --git a/placeholder/index.html b/placeholder/index.html
new file mode 100644
index 0000000..6a86066
--- /dev/null
+++ b/placeholder/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Sea Haven AP
+
+
+
+ Sea Haven AP
+
+ Accounts payable origin for seahaven-dev. The live SPA is not published on this distribution
+ yet.
+
+
+
+
diff --git a/scripts/patch-ecs-task-def.py b/scripts/patch-ecs-task-def.py
new file mode 100755
index 0000000..a8f8391
--- /dev/null
+++ b/scripts/patch-ecs-task-def.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+"""Apply Terraform-owned task env onto an ECS task definition JSON.
+
+Reads describe-task-definition JSON on stdin. Writes register-task-definition
+input on stdout. IMAGE, GIT_SHA, CONTAINER, and TASK_ENV_JSON must be set.
+TASK_ENV_JSON is the SecureString at /seahaven-ap/deploy/task-environment.
+GIT_SHA is owned by GitHub and always overwrites the map.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+
+
+def patch_task_definition(td: dict, *, image: str, sha: str, container_name: str, env_map: dict) -> dict:
+ if not isinstance(env_map, dict) or not env_map:
+ raise SystemExit("TASK_ENV_JSON must be a non-empty JSON object")
+ owned = {str(key): str(value) for key, value in env_map.items()}
+ owned.pop("GIT_SHA", None)
+ owned["GIT_SHA"] = sha
+
+ matched = False
+ for container in td.get("containerDefinitions") or []:
+ if container.get("name") != container_name:
+ continue
+ matched = True
+ container["image"] = image
+ container["environment"] = [{"name": key, "value": value} for key, value in owned.items()]
+ container["stopTimeout"] = 60
+ container.pop("command", None)
+ if not matched:
+ raise SystemExit(f"container {container_name!r} not found in task definition")
+ return td
+
+
+def main() -> None:
+ image = os.environ["IMAGE"]
+ sha = os.environ["GIT_SHA"]
+ name = os.environ["CONTAINER"]
+ env_map = json.loads(os.environ["TASK_ENV_JSON"])
+ td = json.load(sys.stdin)
+ for key in (
+ "taskDefinitionArn",
+ "revision",
+ "status",
+ "requiresAttributes",
+ "compatibilities",
+ "registeredAt",
+ "registeredBy",
+ "deregisteredAt",
+ ):
+ td.pop(key, None)
+ json.dump(patch_task_definition(td, image=image, sha=sha, container_name=name, env_map=env_map), sys.stdout)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py
new file mode 100755
index 0000000..92d8957
--- /dev/null
+++ b/scripts/test-terraform-dev-only.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Guard seahaven-dev-only Terraform and deploy workflows (AP-9/10/11)."""
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_no_hcp_iam_and_no_prod():
+ tf_dir = ROOT / "terraform"
+ assert not (tf_dir / "hcp_iam.tf").exists()
+ assert not (tf_dir / "acm.tf").exists()
+ joined = "\n".join(p.read_text() for p in sorted(tf_dir.glob("*.tf")))
+ for needle in (
+ "environment:prod",
+ "seahaven-ap-prod",
+ "seahaven-prod",
+ "ap.seahaven.com",
+ "011934824531",
+ "afterhours",
+ "hcptf-bootstrap",
+ 'contains(["dev", "prod"]',
+ ):
+ assert needle not in joined, needle
+ variables = (tf_dir / "variables.tf").read_text()
+ assert 'var.environment == "dev"' in variables
+ locals_tf = (tf_dir / "locals.tf").read_text()
+ assert "vpc_cidr" in locals_tf and "10.63.0.0/16" in locals_tf
+ assert "hcp_workspace" in locals_tf and "seahaven-ap-dev" in locals_tf
+ ecs = (tf_dir / "ecs.tf").read_text()
+ assert "ignore_changes = [container_definitions]" in ecs
+ assert "ignore_changes = [task_definition, desired_count]" in ecs
+ assert 'path = "/api/health"' in ecs
+ assert "public.ecr.aws/docker/library/node:24-alpine" in ecs
+ assert 'tagStatus = "untagged"' in ecs
+ assert 'tagStatus = "any"' not in ecs
+ cloudfront = (tf_dir / "cloudfront.tf").read_text()
+ assert "cloudfront_default_certificate = true" in cloudfront
+ assert "aliases" not in cloudfront
+ alarms = (tf_dir / "alarms.tf").read_text()
+ assert alarms.count("alarm_actions = [local.site_alerts_arn]") == 2
+ assert "insufficient_data_actions" not in alarms
+ assert "ok_actions" not in alarms
+ locals_tf = (tf_dir / "locals.tf").read_text()
+ assert (
+ 'site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"'
+ in locals_tf
+ )
+ cognito = (tf_dir / "cognito.tf").read_text()
+ assert 'supported_identity_providers = ["COGNITO", "Google"]' in cognito
+ assert '"ALLOW_USER_SRP_AUTH"' in cognito
+ assert "aws_secretsmanager_secret_version.google_oidc" in cognito
+ assert 'local.google_oidc_client_id != "replace-me"' in cognito
+ assert 'local.google_oidc_client_secret != "replace-me"' in cognito
+ readme = (ROOT / "README.md").read_text()
+ assert "Replace both values in Secrets Manager, then re-run the HCP apply." in readme
+ assert "Those runs fail on purpose until both exist." in readme
+ secrets = (tf_dir / "secrets.tf").read_text()
+ assert 'resource "aws_secretsmanager_secret_version" "google_oidc"' in secrets
+ assert "ignore_changes = [secret_string]" in secrets
+ github = (tf_dir / "iam_github_deploy.tf").read_text()
+ assert "environment:dev" in github
+ assert "environment:prod" not in github
+ assert "refs/tags/" not in github
+ assert "deploy-web.yaml@refs/heads/" in github
+ assert "deploy-api.yaml@refs/heads/" in github
+
+
+def test_deploy_workflows_are_dev_only():
+ for name in ("deploy-web.yaml", "deploy-api.yaml"):
+ text = (ROOT / ".github" / "workflows" / name).read_text()
+ assert "release:" not in text
+ assert "options: [dev]" in text
+ assert "options: [dev, prod]" not in text
+ assert "environment:prod" not in text
+ assert "cancel-in-progress: false" in text
+ assert "environment: ${{ needs.target.outputs.environment }}" in text
+ assert "DEPLOY_ROLE_ARN is empty" in text
+ assert "does not exist yet. Apply the seahaven-ap-dev workspace" in text
+ web = (ROOT / ".github" / "workflows" / "deploy-web.yaml").read_text()
+ assert "vite build" not in web
+ assert "placeholder/" in web
+ assert "npm run build" not in web
+ api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
+ assert "/seahaven-ap/deploy/" in api
+ assert "GIT_SHA" in api
+ assert "verify-api-health.sh" in api
+ assert "packages/api/dist/db/migrate.js" in api
+ assert "DEV_AUTH_BYPASS" in api
+ assert '\\"value\\":\\"false\\"' in api
+
+
+if __name__ == "__main__":
+ test_no_hcp_iam_and_no_prod()
+ test_deploy_workflows_are_dev_only()
+ print("PASS: seahaven-dev terraform and deploy workflow guards")
diff --git a/scripts/test-verify-api-health.sh b/scripts/test-verify-api-health.sh
new file mode 100755
index 0000000..b841ade
--- /dev/null
+++ b/scripts/test-verify-api-health.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Stubbed curl tests for scripts/verify-api-health.sh.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VERIFY="${ROOT}/scripts/verify-api-health.sh"
+SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+failures=0
+
+assert_exit() {
+ local name="$1" expected="$2" got="$3" log="$4"
+ if [[ "${got}" != "${expected}" ]]; then
+ echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2
+ sed -n '1,80p' "${log}" >&2
+ failures=$((failures + 1))
+ else
+ echo "PASS: ${name}"
+ fi
+}
+
+run_with_curl() {
+ local name="$1" expected="$2" curl_body="$3" must="${4:-}" forbid="${5:-}"
+ local dir
+ dir="$(mktemp -d)"
+ cat > "${dir}/curl" << CURL
+#!/usr/bin/env bash
+set -euo pipefail
+output=""
+write_out=""
+args=("\$@")
+i=0
+while [[ \$i -lt \${#args[@]} ]]; do
+ arg="\${args[\$i]}"
+ case "\${arg}" in
+ -o) i=\$((i + 1)); output="\${args[\$i]}" ;;
+ -w) i=\$((i + 1)); write_out="\${args[\$i]}" ;;
+ esac
+ i=\$((i + 1))
+done
+${curl_body}
+CURL
+ chmod +x "${dir}/curl"
+ export PATH="${dir}:${PATH}"
+ export SITE_URL="https://d111111abcdef8.cloudfront.net"
+ export EXPECTED_SHA="${SHA}"
+ export BUDGET=2
+ export INTERVAL=0
+ local log="${dir}/log.txt"
+ set +e
+ bash "${VERIFY}" > "${log}" 2>&1
+ local code=$?
+ set -e
+ assert_exit "${name}" "${expected}" "${code}" "${log}"
+ if [[ -n "${must}" ]] && ! grep -F "${must}" "${log}" >/dev/null; then
+ echo "FAIL: ${name}: log missing ${must}" >&2
+ sed -n '1,80p' "${log}" >&2
+ failures=$((failures + 1))
+ fi
+ if [[ -n "${forbid}" ]] && grep -F "${forbid}" "${log}" >/dev/null; then
+ echo "FAIL: ${name}: log contains ${forbid}" >&2
+ sed -n '1,80p' "${log}" >&2
+ failures=$((failures + 1))
+ fi
+ rm -rf "${dir}"
+}
+
+run_with_curl "matching-sha" 0 '
+[[ -n "${output}" ]] && printf "{\"stage\":\"dev\",\"sha\":\"'"${SHA}"'\"}\n" > "${output}"
+[[ -n "${write_out}" ]] && printf "200"
+exit 0
+'
+
+run_with_curl "wrong-sha" 1 '
+[[ -n "${output}" ]] && printf "{\"stage\":\"dev\",\"sha\":\"unknown\"}\n" > "${output}"
+[[ -n "${write_out}" ]] && printf "200"
+exit 0
+'
+
+run_with_curl "health-503" 1 '
+[[ -n "${output}" ]] && printf "{\"message\":\"nope\"}\n" > "${output}"
+[[ -n "${write_out}" ]] && printf "503"
+exit 0
+'
+
+run_with_curl "curl-failure" 1 '
+[[ -n "${write_out}" ]] && printf "000"
+exit 1
+' 'http=000 sha=' 'http=000000'
+
+if [[ "${failures}" -ne 0 ]]; then
+ echo "FAIL: ${failures} verify-api-health cases failed" >&2
+ exit 1
+fi
+echo "PASS: API health verify checks"
diff --git a/scripts/verify-api-health.sh b/scripts/verify-api-health.sh
new file mode 100755
index 0000000..de09b4f
--- /dev/null
+++ b/scripts/verify-api-health.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# Verify the API origin through CloudFront /api/health.
+set -euo pipefail
+
+SITE_URL="${SITE_URL:-}"
+EXPECTED_SHA="${EXPECTED_SHA:-}"
+BUDGET="${BUDGET:-20}"
+INTERVAL="${INTERVAL:-5}"
+
+if [[ -z "${SITE_URL}" || -z "${EXPECTED_SHA}" ]]; then
+ echo "Usage: SITE_URL EXPECTED_SHA must be set." >&2
+ exit 2
+fi
+
+SITE_URL="${SITE_URL%/}"
+last_code="unreachable"
+last_sha="unreachable"
+
+attempt=0
+while [[ "${attempt}" -lt "${BUDGET}" ]]; do
+ attempt=$((attempt + 1))
+ tmp="$(mktemp)"
+ last_code="$(curl -sS --max-time 30 -o "${tmp}" -w '%{http_code}' "${SITE_URL}/api/health" || true)"
+ last_sha="$(python3 -c 'import json,sys
+try:
+ print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "")
+except Exception:
+ print("")
+' "${tmp}")"
+ rm -f "${tmp}"
+ echo "poll ${attempt}/${BUDGET}: http=${last_code} sha=${last_sha}"
+ if [[ "${last_code}" == "200" && "${last_sha}" == "${EXPECTED_SHA}" ]]; then
+ echo "PASS: GET /api/health is 200 with sha ${EXPECTED_SHA}"
+ exit 0
+ fi
+ sleep "${INTERVAL}"
+done
+
+echo "FAIL: GET /api/health did not converge to sha ${EXPECTED_SHA} (last http=${last_code} sha=${last_sha})." >&2
+exit 1
diff --git a/terraform/.gitignore b/terraform/.gitignore
new file mode 100644
index 0000000..de3ad37
--- /dev/null
+++ b/terraform/.gitignore
@@ -0,0 +1,11 @@
+.terraform/
+*.tfstate
+*.tfstate.*
+crash.log
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+*.tfvars
+*.tfvars.json
+build/
diff --git a/terraform/alarms.tf b/terraform/alarms.tf
new file mode 100644
index 0000000..1322798
--- /dev/null
+++ b/terraform/alarms.tf
@@ -0,0 +1,36 @@
+resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
+ alarm_name = "${local.project}-alb-5xx"
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 1
+ metric_name = "HTTPCode_Target_5XX_Count"
+ namespace = "AWS/ApplicationELB"
+ period = 60
+ statistic = "Sum"
+ threshold = 0
+ treat_missing_data = "notBreaching"
+ alarm_description = "ALB target 5xx for seahaven-ap."
+ alarm_actions = [local.site_alerts_arn]
+
+ dimensions = {
+ LoadBalancer = aws_lb.api.arn_suffix
+ }
+}
+
+resource "aws_cloudwatch_metric_alarm" "ecs_cpu" {
+ alarm_name = "${local.project}-ecs-cpu"
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 2
+ metric_name = "CPUUtilization"
+ namespace = "AWS/ECS"
+ period = 300
+ statistic = "Average"
+ threshold = 80
+ treat_missing_data = "notBreaching"
+ alarm_description = "seahaven-ap ECS CPU above 80 percent."
+ alarm_actions = [local.site_alerts_arn]
+
+ dimensions = {
+ ClusterName = aws_ecs_cluster.api.name
+ ServiceName = aws_ecs_service.api.name
+ }
+}
diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf
new file mode 100644
index 0000000..dccd6d6
--- /dev/null
+++ b/terraform/artifacts.tf
@@ -0,0 +1,101 @@
+resource "aws_s3_bucket" "artifacts" {
+ bucket = local.artifacts_bucket_name
+
+ tags = {
+ Purpose = "Cognito pre-signup packages for seahaven-ap"
+ }
+}
+
+resource "aws_s3_bucket_public_access_block" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_ownership_controls" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ rule {
+ object_ownership = "BucketOwnerEnforced"
+ }
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ rule {
+ apply_server_side_encryption_by_default {
+ sse_algorithm = "AES256"
+ }
+ }
+}
+
+resource "aws_s3_bucket_versioning" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ versioning_configuration {
+ status = "Enabled"
+ }
+}
+
+resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ rule {
+ id = "expire-noncurrent-packages"
+ status = "Enabled"
+
+ filter {}
+
+ noncurrent_version_expiration {
+ noncurrent_days = 180
+ }
+ }
+
+ rule {
+ id = "abort-incomplete-multipart"
+ status = "Enabled"
+
+ filter {}
+
+ abort_incomplete_multipart_upload {
+ days_after_initiation = 7
+ }
+ }
+
+ depends_on = [aws_s3_bucket_versioning.artifacts]
+}
+
+data "aws_iam_policy_document" "artifacts" {
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+
+ actions = ["s3:*"]
+ resources = [
+ aws_s3_bucket.artifacts.arn,
+ "${aws_s3_bucket.artifacts.arn}/*",
+ ]
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
+
+resource "aws_s3_bucket_policy" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+ policy = data.aws_iam_policy_document.artifacts.json
+
+ depends_on = [aws_s3_bucket_public_access_block.artifacts]
+}
diff --git a/terraform/aurora.tf b/terraform/aurora.tf
new file mode 100644
index 0000000..589f363
--- /dev/null
+++ b/terraform/aurora.tf
@@ -0,0 +1,64 @@
+resource "aws_security_group" "aurora" {
+ name = "${local.project}-aurora"
+ description = "Aurora for seahaven-ap"
+ vpc_id = local.vpc_id
+
+ ingress {
+ description = "Postgres from Fargate"
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ security_groups = [aws_security_group.api.id]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+resource "aws_db_subnet_group" "api" {
+ name = local.project
+ subnet_ids = local.private_subnet_ids
+
+ tags = {
+ Name = "${local.project}-db"
+ }
+}
+
+resource "aws_rds_cluster" "api" {
+ cluster_identifier = local.project
+ engine = "aurora-postgresql"
+ engine_mode = "provisioned"
+ engine_version = "16.6"
+ database_name = "seahaven_ap"
+ master_username = "seahaven"
+ master_password = random_password.db.result
+ db_subnet_group_name = aws_db_subnet_group.api.name
+ vpc_security_group_ids = [aws_security_group.aurora.id]
+ storage_encrypted = true
+ backup_retention_period = 1
+ skip_final_snapshot = true
+ apply_immediately = true
+ copy_tags_to_snapshot = true
+ enable_http_endpoint = false
+
+ serverlessv2_scaling_configuration {
+ min_capacity = 0.5
+ max_capacity = 1
+ }
+
+ tags = {
+ Name = local.project
+ }
+}
+
+resource "aws_rds_cluster_instance" "api" {
+ identifier = "${local.project}-1"
+ cluster_identifier = aws_rds_cluster.api.id
+ instance_class = "db.serverless"
+ engine = aws_rds_cluster.api.engine
+ engine_version = aws_rds_cluster.api.engine_version
+}
diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf
new file mode 100644
index 0000000..a7b3b81
--- /dev/null
+++ b/terraform/cloudfront.tf
@@ -0,0 +1,113 @@
+resource "random_password" "origin_verify" {
+ length = 32
+ special = false
+}
+
+resource "aws_cloudfront_origin_access_control" "web" {
+ name = "${local.project}-${var.environment}-oac"
+ description = "OAC for ${local.web_bucket_name}"
+ origin_access_control_origin_type = "s3"
+ signing_behavior = "always"
+ signing_protocol = "sigv4"
+}
+
+resource "aws_cloudfront_function" "spa_rewrite" {
+ name = "${local.project}-${var.environment}-spa-rewrite"
+ runtime = "cloudfront-js-1.0"
+ comment = "SPA routing: rewrite extensionless paths to /index.html"
+ publish = true
+ code = local.spa_rewrite_code
+
+ lifecycle {
+ ignore_changes = [publish]
+ }
+}
+
+resource "aws_cloudfront_function" "spa_security_headers" {
+ name = "${local.project}-${var.environment}-spa-security-headers"
+ runtime = "cloudfront-js-1.0"
+ comment = "SPA CSP and Permissions-Policy"
+ publish = true
+ code = local.spa_security_headers_code
+
+ lifecycle {
+ ignore_changes = [publish]
+ }
+}
+
+resource "aws_cloudfront_distribution" "web" {
+ enabled = true
+ is_ipv6_enabled = true
+ http_version = "http2and3"
+ comment = "${local.project} ${var.environment} SPA"
+ default_root_object = "index.html"
+ price_class = "PriceClass_100"
+ web_acl_id = data.aws_ssm_parameter.app_web_acl_arn.value
+
+ origin {
+ origin_id = local.s3_origin_id
+ domain_name = aws_s3_bucket.web.bucket_regional_domain_name
+ origin_access_control_id = aws_cloudfront_origin_access_control.web.id
+ }
+
+ origin {
+ origin_id = local.api_origin_id
+ domain_name = aws_lb.api.dns_name
+ custom_header {
+ name = "X-Origin-Verify"
+ value = random_password.origin_verify.result
+ }
+ custom_origin_config {
+ http_port = 80
+ https_port = 443
+ origin_protocol_policy = "http-only"
+ origin_ssl_protocols = ["TLSv1.2"]
+ }
+ }
+
+ ordered_cache_behavior {
+ path_pattern = "/api/*"
+ target_origin_id = local.api_origin_id
+ viewer_protocol_policy = "redirect-to-https"
+ allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+ cache_policy_id = local.cache_policy_caching_disabled
+ origin_request_policy_id = local.origin_request_all_viewer_except_host
+ response_headers_policy_id = local.response_headers_security_headers
+ }
+
+ default_cache_behavior {
+ target_origin_id = local.s3_origin_id
+ viewer_protocol_policy = "redirect-to-https"
+ allowed_methods = ["GET", "HEAD", "OPTIONS"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+ cache_policy_id = local.cache_policy_caching_optimized
+ response_headers_policy_id = local.response_headers_security_headers
+
+ function_association {
+ event_type = "viewer-request"
+ function_arn = aws_cloudfront_function.spa_rewrite.arn
+ }
+
+ function_association {
+ event_type = "viewer-response"
+ function_arn = aws_cloudfront_function.spa_security_headers.arn
+ }
+ }
+
+ restrictions {
+ geo_restriction {
+ restriction_type = "none"
+ }
+ }
+
+ viewer_certificate {
+ cloudfront_default_certificate = true
+ }
+
+ lifecycle {
+ prevent_destroy = true
+ }
+}
diff --git a/terraform/cognito.tf b/terraform/cognito.tf
new file mode 100644
index 0000000..3c6c0c4
--- /dev/null
+++ b/terraform/cognito.tf
@@ -0,0 +1,199 @@
+locals {
+ cognito_prefix_domain = "${local.project}-${var.environment}"
+ google_oidc = jsondecode(data.aws_secretsmanager_secret_version.google_oidc.secret_string)
+ google_oidc_client_id = local.google_oidc.client_id
+ google_oidc_client_secret = sensitive(local.google_oidc.client_secret)
+
+ app_origin = "https://${aws_cloudfront_distribution.web.domain_name}"
+
+ portal_callback_urls = [
+ "${local.app_origin}/api/auth/callback",
+ "http://127.0.0.1:8787/api/auth/callback",
+ ]
+ portal_logout_urls = [
+ local.app_origin,
+ "http://127.0.0.1:3000/",
+ ]
+
+ cognito_pool_id = aws_cognito_user_pool.portal.id
+ cognito_issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.portal.id}"
+ cognito_client_id = aws_cognito_user_pool_client.portal.id
+ cognito_hosted_domain = "${aws_cognito_user_pool_domain.prefix.domain}.auth.${var.aws_region}.amazoncognito.com"
+}
+
+data "aws_secretsmanager_secret_version" "google_oidc" {
+ secret_id = aws_secretsmanager_secret.google_oidc.id
+
+ depends_on = [aws_secretsmanager_secret_version.google_oidc]
+}
+
+data "archive_file" "cognito_presignup" {
+ type = "zip"
+ source_file = "${path.module}/lambda/cognito-presignup/index.mjs"
+ output_path = "${path.module}/build/packages/cognito-presignup.zip"
+}
+
+resource "aws_s3_object" "cognito_presignup" {
+ bucket = aws_s3_bucket.artifacts.id
+ key = "functions/cognito-presignup.zip"
+ content_base64 = filebase64(data.archive_file.cognito_presignup.output_path)
+ source_hash = data.archive_file.cognito_presignup.output_base64sha256
+}
+
+resource "aws_cloudwatch_log_group" "cognito_presignup" {
+ name = "/aws/lambda/${local.project}-cognito-presignup"
+ retention_in_days = 14
+}
+
+data "aws_iam_policy_document" "lambda_assume" {
+ statement {
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["lambda.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role" "cognito_presignup" {
+ name = "${local.project}-cognito-presignup"
+ path = "/tf-managed/"
+ assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
+ permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn
+}
+
+resource "aws_iam_role_policy_attachment" "cognito_presignup_basic" {
+ role = aws_iam_role.cognito_presignup.name
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+}
+
+resource "aws_lambda_function" "cognito_presignup" {
+ function_name = "${local.project}-cognito-presignup"
+ role = aws_iam_role.cognito_presignup.arn
+ handler = "index.handler"
+ runtime = "nodejs24.x"
+ architectures = ["arm64"]
+ memory_size = 128
+ timeout = 5
+
+ s3_bucket = aws_s3_bucket.artifacts.id
+ s3_key = aws_s3_object.cognito_presignup.key
+ source_code_hash = data.archive_file.cognito_presignup.output_base64sha256
+
+ depends_on = [
+ aws_cloudwatch_log_group.cognito_presignup,
+ aws_iam_role_policy_attachment.cognito_presignup_basic,
+ ]
+}
+
+resource "aws_cognito_user_pool" "portal" {
+ name = local.project
+
+ username_attributes = ["email"]
+ auto_verified_attributes = ["email"]
+ mfa_configuration = "OFF"
+
+ admin_create_user_config {
+ allow_admin_create_user_only = true
+ }
+
+ password_policy {
+ minimum_length = 32
+ require_lowercase = true
+ require_numbers = true
+ require_symbols = true
+ require_uppercase = true
+ temporary_password_validity_days = 1
+ }
+
+ account_recovery_setting {
+ recovery_mechanism {
+ name = "verified_email"
+ priority = 1
+ }
+ }
+
+ lambda_config {
+ pre_sign_up = aws_lambda_function.cognito_presignup.arn
+ }
+
+ tags = {
+ Project = local.project
+ }
+}
+
+resource "aws_lambda_permission" "cognito_presignup" {
+ statement_id = "AllowCognitoInvoke"
+ action = "lambda:InvokeFunction"
+ function_name = aws_lambda_function.cognito_presignup.function_name
+ principal = "cognito-idp.amazonaws.com"
+ source_arn = aws_cognito_user_pool.portal.arn
+ source_account = local.account_id
+}
+
+resource "aws_cognito_identity_provider" "google" {
+ user_pool_id = aws_cognito_user_pool.portal.id
+ provider_name = "Google"
+ provider_type = "Google"
+
+ lifecycle {
+ precondition {
+ condition = local.google_oidc_client_id != "replace-me" && local.google_oidc_client_secret != "replace-me"
+ error_message = "seahaven-ap/google-oidc still has the placeholder. Replace client_id and client_secret in Secrets Manager, then re-run the HCP apply."
+ }
+ }
+
+ provider_details = {
+ client_id = local.google_oidc_client_id
+ client_secret = local.google_oidc_client_secret
+ authorize_scopes = "openid email profile"
+ attributes_url = "https://people.googleapis.com/v1/people/me?personFields="
+ attributes_url_add_attributes = "true"
+ authorize_url = "https://accounts.google.com/o/oauth2/v2/auth"
+ oidc_issuer = "https://accounts.google.com"
+ token_url = "https://www.googleapis.com/oauth2/v4/token"
+ token_request_method = "POST"
+ }
+
+ attribute_mapping = {
+ email = "email"
+ name = "name"
+ username = "sub"
+ }
+}
+
+resource "aws_cognito_user_pool_client" "portal" {
+ name = local.project
+ user_pool_id = aws_cognito_user_pool.portal.id
+
+ generate_secret = false
+ allowed_oauth_flows_user_pool_client = true
+ allowed_oauth_flows = ["code"]
+ allowed_oauth_scopes = ["openid", "email", "profile"]
+ supported_identity_providers = ["COGNITO", "Google"]
+ explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"]
+ enable_token_revocation = true
+ prevent_user_existence_errors = "ENABLED"
+
+ callback_urls = local.portal_callback_urls
+ logout_urls = local.portal_logout_urls
+
+ access_token_validity = 1
+ id_token_validity = 1
+ refresh_token_validity = 8
+
+ token_validity_units {
+ access_token = "hours"
+ id_token = "hours"
+ refresh_token = "hours"
+ }
+
+ depends_on = [aws_cognito_identity_provider.google]
+}
+
+resource "aws_cognito_user_pool_domain" "prefix" {
+ domain = local.cognito_prefix_domain
+ user_pool_id = aws_cognito_user_pool.portal.id
+}
diff --git a/terraform/data.tf b/terraform/data.tf
new file mode 100644
index 0000000..3176f1d
--- /dev/null
+++ b/terraform/data.tf
@@ -0,0 +1,40 @@
+data "aws_caller_identity" "current" {}
+
+check "correct_account" {
+ assert {
+ condition = data.aws_caller_identity.current.account_id == local.account_id
+ error_message = "This configuration targets account ${local.account_id} (${var.environment}), but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
+ }
+}
+
+data "aws_ssm_parameter" "app_web_acl_arn" {
+ name = "/seahaven/waf/app-web-acl-arn"
+}
+
+data "aws_iam_policy" "ecs_task_boundary" {
+ name = "seahaven-ap-ecs-task-boundary"
+}
+
+data "aws_iam_policy" "github_deploy_boundary" {
+ name = "seahaven-ap-githubdeploy-boundary"
+}
+
+check "existing_vpc_pair" {
+ assert {
+ condition = (
+ (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0) &&
+ (var.existing_vpc_id == "") == (length(var.existing_private_subnet_ids) == 0)
+ )
+ error_message = "existing_vpc_id, existing_public_subnet_ids, and existing_private_subnet_ids must all be set or all be empty."
+ }
+}
+
+check "existing_subnets_in_vpc" {
+ assert {
+ condition = alltrue(concat(
+ [for subnet in data.aws_subnet.existing_public : subnet.vpc_id == var.existing_vpc_id],
+ [for subnet in data.aws_subnet.existing_private : subnet.vpc_id == var.existing_vpc_id],
+ ))
+ error_message = "Every existing subnet ID must belong to existing_vpc_id."
+ }
+}
diff --git a/terraform/documents.tf b/terraform/documents.tf
new file mode 100644
index 0000000..4bd43cd
--- /dev/null
+++ b/terraform/documents.tf
@@ -0,0 +1,73 @@
+resource "aws_s3_bucket" "documents" {
+ bucket = local.documents_bucket_name
+
+ tags = {
+ Purpose = "seahaven-ap-invoice-documents"
+ }
+}
+
+resource "aws_s3_bucket_public_access_block" "documents" {
+ bucket = aws_s3_bucket.documents.id
+
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_ownership_controls" "documents" {
+ bucket = aws_s3_bucket.documents.id
+
+ rule {
+ object_ownership = "BucketOwnerEnforced"
+ }
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "documents" {
+ bucket = aws_s3_bucket.documents.id
+
+ rule {
+ apply_server_side_encryption_by_default {
+ sse_algorithm = "AES256"
+ }
+ }
+}
+
+resource "aws_s3_bucket_versioning" "documents" {
+ bucket = aws_s3_bucket.documents.id
+
+ versioning_configuration {
+ status = "Enabled"
+ }
+}
+
+data "aws_iam_policy_document" "documents" {
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+
+ actions = ["s3:*"]
+ resources = [
+ aws_s3_bucket.documents.arn,
+ "${aws_s3_bucket.documents.arn}/*",
+ ]
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
+
+resource "aws_s3_bucket_policy" "documents" {
+ bucket = aws_s3_bucket.documents.id
+ policy = data.aws_iam_policy_document.documents.json
+
+ depends_on = [aws_s3_bucket_public_access_block.documents]
+}
diff --git a/terraform/ecs.tf b/terraform/ecs.tf
new file mode 100644
index 0000000..437fd21
--- /dev/null
+++ b/terraform/ecs.tf
@@ -0,0 +1,229 @@
+resource "aws_ecr_repository" "api" {
+ name = local.project
+ image_tag_mutability = "MUTABLE"
+ force_delete = true
+
+ image_scanning_configuration {
+ scan_on_push = true
+ }
+
+ encryption_configuration {
+ encryption_type = "AES256"
+ }
+}
+
+resource "aws_ecr_lifecycle_policy" "api" {
+ repository = aws_ecr_repository.api.name
+
+ policy = jsonencode({
+ rules = [
+ {
+ rulePriority = 1
+ description = "Expire untagged images. SHA tags stay so registered task revisions can roll back."
+ selection = {
+ tagStatus = "untagged"
+ countType = "sinceImagePushed"
+ countUnit = "days"
+ countNumber = 14
+ }
+ action = {
+ type = "expire"
+ }
+ }
+ ]
+ })
+}
+
+resource "aws_security_group" "alb" {
+ name = "${local.project}-alb"
+ description = "ALB for seahaven-ap (CloudFront origin only)"
+ vpc_id = local.vpc_id
+
+ ingress {
+ description = "HTTP from CloudFront origin-facing prefix list"
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront_origin.id]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+resource "aws_security_group" "api" {
+ name = "${local.project}-api"
+ description = "Fargate tasks for seahaven-ap"
+ vpc_id = local.vpc_id
+
+ ingress {
+ description = "From ALB"
+ from_port = 8080
+ to_port = 8080
+ protocol = "tcp"
+ security_groups = [aws_security_group.alb.id]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+resource "aws_lb" "api" {
+ name = local.project
+ load_balancer_type = "application"
+ idle_timeout = 120
+ security_groups = [aws_security_group.alb.id]
+ subnets = local.public_subnet_ids
+ drop_invalid_header_fields = true
+}
+
+resource "aws_lb_target_group" "api" {
+ name = "${local.project}-api"
+ port = 8080
+ protocol = "HTTP"
+ vpc_id = local.vpc_id
+ target_type = "ip"
+
+ health_check {
+ enabled = true
+ path = "/api/health"
+ matcher = "200"
+ interval = 30
+ timeout = 5
+ healthy_threshold = 2
+ unhealthy_threshold = 3
+ }
+}
+
+resource "aws_lb_listener" "http" {
+ load_balancer_arn = aws_lb.api.arn
+ port = 80
+ protocol = "HTTP"
+
+ default_action {
+ type = "forward"
+ target_group_arn = aws_lb_target_group.api.arn
+ }
+}
+
+resource "aws_ecs_cluster" "api" {
+ name = local.project
+
+ setting {
+ name = "containerInsights"
+ value = "disabled"
+ }
+}
+
+locals {
+ api_container_name = "api"
+ bootstrap_command = [
+ "node",
+ "-e",
+ "require('http').createServer((q,s)=>{const p=(q.url||'').split('?')[0];const ok=q.method==='GET'&&p==='/api/health';const b=Buffer.from(ok?JSON.stringify({stage:'bootstrap',sha:'bootstrap'}):'');s.writeHead(ok?200:503,ok?{'content-type':'application/json','content-length':b.length}:{});s.end(b)}).listen(8080)",
+ ]
+
+ database_url = "postgresql://seahaven:${urlencode(random_password.db.result)}@${aws_rds_cluster.api.endpoint}:5432/seahaven_ap"
+
+ api_environment_map = {
+ NODE_ENV = "production"
+ STAGE = var.environment
+ API_PORT = "8080"
+ DATABASE_DRIVER = "postgres"
+ DATABASE_URL = local.database_url
+ AWS_REGION = var.aws_region
+ COGNITO_ISSUER = local.cognito_issuer
+ COGNITO_AUDIENCE = local.cognito_client_id
+ COGNITO_DOMAIN = local.cognito_hosted_domain
+ APP_ORIGIN = local.app_origin
+ ORIGIN_VERIFY_SECRET = random_password.origin_verify.result
+ DOCUMENTS_BUCKET = aws_s3_bucket.documents.id
+ }
+
+ api_environment = concat(
+ [for name, value in local.api_environment_map : { name = name, value = value }],
+ [{ name = "GIT_SHA", value = "bootstrap" }],
+ )
+}
+
+resource "aws_ecs_task_definition" "api" {
+ family = local.project
+ requires_compatibilities = ["FARGATE"]
+ network_mode = "awsvpc"
+ cpu = "512"
+ memory = "1024"
+ execution_role_arn = aws_iam_role.ecs_execution.arn
+ task_role_arn = aws_iam_role.ecs_task.arn
+
+ runtime_platform {
+ operating_system_family = "LINUX"
+ cpu_architecture = "X86_64"
+ }
+
+ container_definitions = jsonencode([
+ {
+ name = local.api_container_name
+ image = "public.ecr.aws/docker/library/node:24-alpine"
+ essential = true
+ command = local.bootstrap_command
+ portMappings = [
+ {
+ containerPort = 8080
+ protocol = "tcp"
+ }
+ ]
+ environment = local.api_environment
+ stopTimeout = 60
+ logConfiguration = {
+ logDriver = "awslogs"
+ options = {
+ "awslogs-group" = aws_cloudwatch_log_group.api.name
+ "awslogs-region" = var.aws_region
+ "awslogs-stream-prefix" = "ecs"
+ }
+ }
+ }
+ ])
+
+ lifecycle {
+ ignore_changes = [container_definitions]
+ }
+}
+
+resource "aws_ecs_service" "api" {
+ name = local.project
+ cluster = aws_ecs_cluster.api.id
+ task_definition = aws_ecs_task_definition.api.arn
+ desired_count = 1
+ launch_type = "FARGATE"
+
+ network_configuration {
+ subnets = local.public_subnet_ids
+ security_groups = [aws_security_group.api.id]
+ assign_public_ip = true
+ }
+
+ load_balancer {
+ target_group_arn = aws_lb_target_group.api.arn
+ container_name = local.api_container_name
+ container_port = 8080
+ }
+
+ health_check_grace_period_seconds = 60
+ deployment_minimum_healthy_percent = 0
+ deployment_maximum_percent = 200
+
+ lifecycle {
+ ignore_changes = [task_definition, desired_count]
+ }
+
+ depends_on = [aws_lb_listener.http]
+}
diff --git a/terraform/iam_ecs.tf b/terraform/iam_ecs.tf
new file mode 100644
index 0000000..f1ff969
--- /dev/null
+++ b/terraform/iam_ecs.tf
@@ -0,0 +1,107 @@
+data "aws_iam_policy_document" "ecs_assume" {
+ statement {
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["ecs-tasks.amazonaws.com"]
+ }
+ }
+}
+
+data "aws_iam_policy_document" "ecs_task" {
+ statement {
+ sid = "ReadProjectParameters"
+ effect = "Allow"
+ actions = ["ssm:GetParameter", "ssm:GetParameters"]
+ resources = ["arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*"]
+ }
+
+ statement {
+ sid = "ReadProjectSecrets"
+ effect = "Allow"
+ actions = ["secretsmanager:GetSecretValue"]
+ resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:seahaven-ap/*"]
+ }
+
+ statement {
+ sid = "EcrAuth"
+ effect = "Allow"
+ actions = ["ecr:GetAuthorizationToken"]
+ resources = ["*"]
+ }
+
+ statement {
+ sid = "EcrPull"
+ effect = "Allow"
+ actions = [
+ "ecr:BatchCheckLayerAvailability",
+ "ecr:BatchGetImage",
+ "ecr:GetDownloadUrlForLayer",
+ ]
+ resources = [aws_ecr_repository.api.arn]
+ }
+
+ statement {
+ sid = "TaskLogs"
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ "logs:CreateLogGroup",
+ ]
+ resources = [
+ aws_cloudwatch_log_group.api.arn,
+ "${aws_cloudwatch_log_group.api.arn}:*",
+ ]
+ }
+
+ statement {
+ sid = "DocumentsBucket"
+ effect = "Allow"
+ actions = [
+ "s3:ListBucket",
+ "s3:GetBucketLocation",
+ ]
+ resources = [aws_s3_bucket.documents.arn]
+ }
+
+ statement {
+ sid = "DocumentsObjects"
+ effect = "Allow"
+ actions = [
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject",
+ "s3:AbortMultipartUpload",
+ "s3:ListMultipartUploadParts",
+ ]
+ resources = ["${aws_s3_bucket.documents.arn}/*"]
+ }
+}
+
+resource "aws_iam_role" "ecs_execution" {
+ name = "${local.project}-ecs-exec"
+ path = "/tf-managed/"
+ assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
+ permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn
+}
+
+resource "aws_iam_role_policy_attachment" "ecs_execution" {
+ role = aws_iam_role.ecs_execution.name
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
+}
+
+resource "aws_iam_role" "ecs_task" {
+ name = "${local.project}-ecs-task"
+ path = "/tf-managed/"
+ assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
+ permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn
+}
+
+resource "aws_iam_role_policy" "ecs_task" {
+ name = "api-runtime"
+ role = aws_iam_role.ecs_task.id
+ policy = data.aws_iam_policy_document.ecs_task.json
+}
diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf
new file mode 100644
index 0000000..2c7295d
--- /dev/null
+++ b/terraform/iam_github_deploy.tf
@@ -0,0 +1,195 @@
+data "aws_iam_policy_document" "github_deploy_assume" {
+ statement {
+ sid = "GithubDeployOidc"
+ effect = "Allow"
+ actions = ["sts:AssumeRoleWithWebIdentity"]
+
+ principals {
+ type = "Federated"
+ identifiers = [local.github_oidc_provider_arn]
+ }
+
+ condition {
+ test = "StringEquals"
+ variable = "token.actions.githubusercontent.com:aud"
+ values = ["sts.amazonaws.com"]
+ }
+
+ condition {
+ test = "StringEquals"
+ variable = "token.actions.githubusercontent.com:sub"
+ values = ["repo:Sea-Haven-Industries@183236204/seahaven-ap@1330218238:environment:dev"]
+ }
+
+ condition {
+ test = "StringEquals"
+ variable = "token.actions.githubusercontent.com:job_workflow_ref"
+ values = [
+ "${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/${var.github_deploy_branch}",
+ "${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}",
+ ]
+ }
+ }
+}
+
+resource "aws_iam_role" "github_deploy" {
+ name = local.deploy_role
+ path = "/tf-managed/"
+ description = "GitHub Actions SPA and API deploy role for ${var.github_repo} Environment dev"
+ assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
+ permissions_boundary = data.aws_iam_policy.github_deploy_boundary.arn
+ max_session_duration = 3600
+}
+
+data "aws_iam_policy_document" "github_deploy" {
+ statement {
+ sid = "ListWebBucket"
+ effect = "Allow"
+ actions = [
+ "s3:GetBucketLocation",
+ "s3:ListBucket",
+ ]
+ resources = [aws_s3_bucket.web.arn]
+ }
+
+ statement {
+ sid = "SyncWebBucket"
+ effect = "Allow"
+ actions = [
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject",
+ ]
+ resources = ["${aws_s3_bucket.web.arn}/*"]
+ }
+
+ statement {
+ sid = "InvalidateDistribution"
+ effect = "Allow"
+ actions = [
+ "cloudfront:CreateInvalidation",
+ "cloudfront:GetInvalidation",
+ "cloudfront:GetDistribution",
+ ]
+ resources = [aws_cloudfront_distribution.web.arn]
+ }
+
+ statement {
+ sid = "EcrAuth"
+ effect = "Allow"
+ actions = [
+ "ecr:GetAuthorizationToken",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ sid = "EcrPush"
+ effect = "Allow"
+ actions = [
+ "ecr:BatchCheckLayerAvailability",
+ "ecr:BatchGetImage",
+ "ecr:CompleteLayerUpload",
+ "ecr:GetDownloadUrlForLayer",
+ "ecr:InitiateLayerUpload",
+ "ecr:PutImage",
+ "ecr:UploadLayerPart",
+ "ecr:DescribeRepositories",
+ "ecr:DescribeImages",
+ ]
+ resources = [aws_ecr_repository.api.arn]
+ }
+
+ statement {
+ sid = "EcsRegisterTaskDefinition"
+ effect = "Allow"
+ actions = [
+ "ecs:DescribeTaskDefinition",
+ "ecs:RegisterTaskDefinition",
+ ]
+ resources = ["*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "aws:RequestedRegion"
+ values = [var.aws_region]
+ }
+ }
+
+ statement {
+ sid = "EcsUpdateService"
+ effect = "Allow"
+ actions = [
+ "ecs:DescribeServices",
+ "ecs:DescribeTasks",
+ "ecs:ListTasks",
+ "ecs:RunTask",
+ "ecs:StopTask",
+ "ecs:TagResource",
+ "ecs:UpdateService",
+ ]
+ resources = [
+ aws_ecs_cluster.api.arn,
+ aws_ecs_service.api.id,
+ "arn:aws:ecs:${var.aws_region}:${local.account_id}:task/${local.project}/*",
+ "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}",
+ "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}:*",
+ ]
+ }
+
+ statement {
+ sid = "PassTaskRoles"
+ effect = "Allow"
+ actions = ["iam:PassRole"]
+ resources = [
+ aws_iam_role.ecs_task.arn,
+ aws_iam_role.ecs_execution.arn,
+ ]
+
+ condition {
+ test = "StringEquals"
+ variable = "iam:PassedToService"
+ values = ["ecs-tasks.amazonaws.com"]
+ }
+ }
+
+ statement {
+ sid = "DeployParams"
+ effect = "Allow"
+ actions = [
+ "ssm:GetParameter",
+ ]
+ resources = [
+ aws_ssm_parameter.deploy_bucket.arn,
+ aws_ssm_parameter.deploy_distribution_id.arn,
+ aws_ssm_parameter.deploy_cluster.arn,
+ aws_ssm_parameter.deploy_service.arn,
+ aws_ssm_parameter.deploy_task_family.arn,
+ aws_ssm_parameter.deploy_ecr_repository.arn,
+ aws_ssm_parameter.deploy_container_name.arn,
+ aws_ssm_parameter.deploy_task_environment.arn,
+ ]
+ }
+
+ statement {
+ sid = "DecryptTaskEnvironment"
+ effect = "Allow"
+ actions = ["kms:Decrypt"]
+ resources = [
+ "arn:aws:kms:${var.aws_region}:${local.account_id}:alias/aws/ssm",
+ "arn:aws:kms:${var.aws_region}:${local.account_id}:key/*",
+ ]
+
+ condition {
+ test = "StringEquals"
+ variable = "kms:ViaService"
+ values = ["ssm.${var.aws_region}.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role_policy" "github_deploy" {
+ name = "seahaven-ap-spa-api-deploy"
+ role = aws_iam_role.github_deploy.id
+ policy = data.aws_iam_policy_document.github_deploy.json
+}
diff --git a/terraform/lambda/cognito-presignup/index.mjs b/terraform/lambda/cognito-presignup/index.mjs
new file mode 100644
index 0000000..cbaf7c1
--- /dev/null
+++ b/terraform/lambda/cognito-presignup/index.mjs
@@ -0,0 +1,17 @@
+const ALLOWED_DOMAINS = new Set(["seahavenind.com", "seahaven.com"]);
+
+export async function handler(event) {
+ const email = String(event?.request?.userAttributes?.email ?? "")
+ .trim()
+ .toLowerCase();
+ const at = email.lastIndexOf("@");
+ const domain = at >= 0 ? email.slice(at + 1) : "";
+
+ if (!ALLOWED_DOMAINS.has(domain)) {
+ throw new Error("Email domain is not allowed");
+ }
+
+ event.response.autoConfirmUser = true;
+ event.response.autoVerifyEmail = true;
+ return event;
+}
diff --git a/terraform/locals.tf b/terraform/locals.tf
new file mode 100644
index 0000000..07710e1
--- /dev/null
+++ b/terraform/locals.tf
@@ -0,0 +1,77 @@
+locals {
+ project = "seahaven-ap"
+ account_id = "710827005802"
+ hcp_project = "seahaven-dev"
+ hcp_workspace = "seahaven-ap-dev"
+ apply_role = "hcptf-seahaven-ap"
+ plan_role = "hcptf-seahaven-ap-plan"
+ deploy_role = "githubdeploy-seahaven-ap"
+
+ web_bucket_name = "seahaven-ap-web-${local.account_id}"
+ artifacts_bucket_name = "seahaven-ap-artifacts-${local.account_id}"
+ documents_bucket_name = "seahaven-ap-documents-${local.account_id}"
+ ssm_prefix = "/seahaven-ap"
+
+ manage_vpc = var.existing_vpc_id == ""
+ vpc_cidr = "10.63.0.0/16"
+ public_subnet_cidrs = ["10.63.0.0/24", "10.63.1.0/24"]
+ private_subnet_cidrs = ["10.63.10.0/24", "10.63.11.0/24"]
+
+ github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
+
+ # Org-baseline topic in this account. Alarm-only; no OK or insufficient-data action.
+ site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"
+
+ cache_policy_caching_optimized = "658327ea-f89d-4fab-a63d-7e88639e58f6"
+ cache_policy_caching_disabled = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
+ origin_request_all_viewer_except_host = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
+ response_headers_security_headers = "67f7725c-6f97-4210-82d7-5512b31e9d03"
+
+ s3_origin_id = "S3WebOrigin"
+ api_origin_id = "ApiOrigin"
+
+ spa_csp = join(" ", [
+ "default-src 'self';",
+ "script-src 'self';",
+ "style-src 'self' 'unsafe-inline';",
+ "img-src 'self' data:;",
+ "font-src 'self';",
+ "connect-src 'self';",
+ "object-src 'none';",
+ "base-uri 'self';",
+ "form-action 'self';",
+ "frame-ancestors 'none';",
+ "upgrade-insecure-requests;",
+ ])
+
+ spa_permissions_policy = join(", ", [
+ "accelerometer=()",
+ "camera=()",
+ "geolocation=()",
+ "gyroscope=()",
+ "magnetometer=()",
+ "microphone=()",
+ "payment=()",
+ "usb=()",
+ ])
+
+ spa_rewrite_code = join("\n", [
+ "function handler(event) {",
+ " var request = event.request;",
+ " var uri = request.uri;",
+ " if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
+ " request.uri = '/index.html';",
+ " }",
+ " return request;",
+ "}",
+ ])
+
+ spa_security_headers_code = join("\n", [
+ "function handler(event) {",
+ " var headers = event.response.headers;",
+ " headers['content-security-policy'] = { value: ${jsonencode(local.spa_csp)} };",
+ " headers['permissions-policy'] = { value: ${jsonencode(local.spa_permissions_policy)} };",
+ " return event.response;",
+ "}",
+ ])
+}
diff --git a/terraform/logs.tf b/terraform/logs.tf
new file mode 100644
index 0000000..dd7e917
--- /dev/null
+++ b/terraform/logs.tf
@@ -0,0 +1,4 @@
+resource "aws_cloudwatch_log_group" "api" {
+ name = "/ecs/${local.project}"
+ retention_in_days = 14
+}
diff --git a/terraform/outputs.tf b/terraform/outputs.tf
new file mode 100644
index 0000000..bd0e486
--- /dev/null
+++ b/terraform/outputs.tf
@@ -0,0 +1,69 @@
+output "web_bucket_name" {
+ description = "S3 origin bucket name. deploy-web.yaml syncs placeholder/ to the bucket root."
+ value = aws_s3_bucket.web.bucket
+}
+
+output "cloudfront_distribution_id" {
+ description = "CloudFront distribution ID. deploy-web.yaml invalidates /* after each sync."
+ value = aws_cloudfront_distribution.web.id
+}
+
+output "cloudfront_domain_name" {
+ description = "CloudFront distribution domain (*.cloudfront.net)."
+ value = aws_cloudfront_distribution.web.domain_name
+}
+
+output "github_deploy_role_arn" {
+ description = "OIDC role ARN for deploy-web.yaml and deploy-api.yaml (Environment variable DEPLOY_ROLE_ARN)."
+ value = aws_iam_role.github_deploy.arn
+}
+
+output "ecs_cluster_name" {
+ description = "ECS cluster name."
+ value = aws_ecs_cluster.api.name
+}
+
+output "ecs_service_name" {
+ description = "ECS service name."
+ value = aws_ecs_service.api.name
+}
+
+output "alb_dns_name" {
+ description = "API ALB DNS name. CloudFront /api/* origin."
+ value = aws_lb.api.dns_name
+}
+
+output "cognito_user_pool_id" {
+ description = "seahaven-ap Cognito user pool ID."
+ value = aws_cognito_user_pool.portal.id
+}
+
+output "cognito_user_pool_client_id" {
+ description = "Public app client ID (authorization code + PKCE)."
+ value = aws_cognito_user_pool_client.portal.id
+}
+
+output "cognito_prefix_domain" {
+ description = "Cognito hosted UI prefix domain."
+ value = "${aws_cognito_user_pool_domain.prefix.domain}.auth.${var.aws_region}.amazoncognito.com"
+}
+
+output "vpc_id" {
+ description = "VPC the ALB, Fargate tasks, and Aurora run in."
+ value = local.vpc_id
+}
+
+output "public_subnet_ids" {
+ description = "Public subnet IDs for the ALB and Fargate tasks."
+ value = local.public_subnet_ids
+}
+
+output "aurora_cluster_endpoint" {
+ description = "Aurora writer endpoint."
+ value = aws_rds_cluster.api.endpoint
+}
+
+output "documents_bucket_name" {
+ description = "Invoice documents bucket."
+ value = aws_s3_bucket.documents.bucket
+}
diff --git a/terraform/providers.tf b/terraform/providers.tf
new file mode 100644
index 0000000..4f9d903
--- /dev/null
+++ b/terraform/providers.tf
@@ -0,0 +1,11 @@
+provider "aws" {
+ region = var.aws_region
+
+ default_tags {
+ tags = {
+ Project = local.project
+ Environment = var.environment
+ ManagedBy = "terraform"
+ }
+ }
+}
diff --git a/terraform/s3.tf b/terraform/s3.tf
new file mode 100644
index 0000000..6a57cfe
--- /dev/null
+++ b/terraform/s3.tf
@@ -0,0 +1,96 @@
+resource "aws_s3_bucket" "web" {
+ bucket = local.web_bucket_name
+
+ tags = {
+ Purpose = "seahaven-ap-spa"
+ }
+
+ lifecycle {
+ prevent_destroy = true
+ }
+}
+
+resource "aws_s3_bucket_public_access_block" "web" {
+ bucket = aws_s3_bucket.web.id
+
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_ownership_controls" "web" {
+ bucket = aws_s3_bucket.web.id
+
+ rule {
+ object_ownership = "BucketOwnerEnforced"
+ }
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "web" {
+ bucket = aws_s3_bucket.web.id
+
+ rule {
+ apply_server_side_encryption_by_default {
+ sse_algorithm = "AES256"
+ }
+ }
+}
+
+resource "aws_s3_bucket_versioning" "web" {
+ bucket = aws_s3_bucket.web.id
+
+ versioning_configuration {
+ status = "Enabled"
+ }
+}
+
+data "aws_iam_policy_document" "web" {
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+
+ actions = ["s3:*"]
+ resources = [
+ aws_s3_bucket.web.arn,
+ "${aws_s3_bucket.web.arn}/*",
+ ]
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+
+ statement {
+ sid = "AllowCloudFrontOacRead"
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["cloudfront.amazonaws.com"]
+ }
+
+ actions = ["s3:GetObject"]
+ resources = ["${aws_s3_bucket.web.arn}/*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "AWS:SourceArn"
+ values = [aws_cloudfront_distribution.web.arn]
+ }
+ }
+}
+
+resource "aws_s3_bucket_policy" "web" {
+ bucket = aws_s3_bucket.web.id
+ policy = data.aws_iam_policy_document.web.json
+
+ depends_on = [aws_s3_bucket_public_access_block.web]
+}
diff --git a/terraform/secrets.tf b/terraform/secrets.tf
new file mode 100644
index 0000000..3ee9452
--- /dev/null
+++ b/terraform/secrets.tf
@@ -0,0 +1,36 @@
+resource "aws_secretsmanager_secret" "google_oidc" {
+ name = "seahaven-ap/google-oidc"
+ description = "Google OIDC client credentials for seahaven-ap Cognito. Value is written outside Terraform."
+}
+
+# Placeholder so the first apply has an AWSCURRENT version to read. Replace the
+# value in Secrets Manager; Terraform will not write this placeholder back.
+resource "aws_secretsmanager_secret_version" "google_oidc" {
+ secret_id = aws_secretsmanager_secret.google_oidc.id
+ secret_string = jsonencode({
+ client_id = "replace-me"
+ client_secret = "replace-me"
+ })
+
+ lifecycle {
+ ignore_changes = [secret_string]
+ }
+}
+
+resource "aws_secretsmanager_secret" "database" {
+ name = "seahaven-ap/database"
+ description = "Aurora master credentials for seahaven-ap"
+}
+
+resource "aws_secretsmanager_secret_version" "database" {
+ secret_id = aws_secretsmanager_secret.database.id
+ secret_string = jsonencode({
+ username = "seahaven"
+ password = random_password.db.result
+ })
+}
+
+resource "random_password" "db" {
+ length = 32
+ special = false
+}
diff --git a/terraform/ssm.tf b/terraform/ssm.tf
new file mode 100644
index 0000000..725df5c
--- /dev/null
+++ b/terraform/ssm.tf
@@ -0,0 +1,55 @@
+resource "aws_ssm_parameter" "deploy_bucket" {
+ name = "${local.ssm_prefix}/deploy/bucket"
+ type = "String"
+ value = aws_s3_bucket.web.id
+ description = "SPA origin bucket; deploy-web syncs placeholder/ to the bucket root"
+}
+
+resource "aws_ssm_parameter" "deploy_distribution_id" {
+ name = "${local.ssm_prefix}/deploy/distribution-id"
+ type = "String"
+ value = aws_cloudfront_distribution.web.id
+ description = "CloudFront distribution ID; deploy-web invalidates /* after sync"
+}
+
+resource "aws_ssm_parameter" "deploy_cluster" {
+ name = "${local.ssm_prefix}/deploy/cluster"
+ type = "String"
+ value = aws_ecs_cluster.api.name
+ description = "ECS cluster name for deploy-api.yaml"
+}
+
+resource "aws_ssm_parameter" "deploy_service" {
+ name = "${local.ssm_prefix}/deploy/service"
+ type = "String"
+ value = aws_ecs_service.api.name
+ description = "ECS service name for deploy-api.yaml"
+}
+
+resource "aws_ssm_parameter" "deploy_task_family" {
+ name = "${local.ssm_prefix}/deploy/task-family"
+ type = "String"
+ value = aws_ecs_task_definition.api.family
+ description = "ECS task definition family for deploy-api.yaml"
+}
+
+resource "aws_ssm_parameter" "deploy_ecr_repository" {
+ name = "${local.ssm_prefix}/deploy/ecr-repository"
+ type = "String"
+ value = aws_ecr_repository.api.repository_url
+ description = "ECR repository URL for deploy-api.yaml"
+}
+
+resource "aws_ssm_parameter" "deploy_container_name" {
+ name = "${local.ssm_prefix}/deploy/container-name"
+ type = "String"
+ value = local.api_container_name
+ description = "Container name in the ECS task definition"
+}
+
+resource "aws_ssm_parameter" "deploy_task_environment" {
+ name = "${local.ssm_prefix}/deploy/task-environment"
+ type = "SecureString"
+ value = jsonencode(local.api_environment_map)
+ description = "Terraform-owned API task env JSON. deploy-api.yaml applies it on each image deploy, then sets GIT_SHA."
+}
diff --git a/terraform/variables.tf b/terraform/variables.tf
new file mode 100644
index 0000000..b6f3138
--- /dev/null
+++ b/terraform/variables.tf
@@ -0,0 +1,55 @@
+variable "aws_region" {
+ description = "Region every resource in this configuration is created in."
+ type = string
+ default = "us-east-1"
+}
+
+variable "environment" {
+ description = "HCP workspace stage. seahaven-dev only; prod is AP-12."
+ type = string
+
+ validation {
+ condition = var.environment == "dev"
+ error_message = "environment must be \"dev\". Prod is AP-12."
+ }
+}
+
+variable "github_repo" {
+ description = "GitHub owner/name for the SPA and API deploy OIDC trust."
+ type = string
+ default = "Sea-Haven-Industries/seahaven-ap"
+}
+
+variable "github_deploy_branch" {
+ description = "Git branch pinned in job_workflow_ref for the SPA and API deploy role."
+ type = string
+ default = "main"
+}
+
+variable "existing_vpc_id" {
+ description = "When set, place the ALB, Fargate tasks, and Aurora in this VPC instead of creating one."
+ type = string
+ default = ""
+}
+
+variable "existing_public_subnet_ids" {
+ description = "Public subnet IDs in existing_vpc_id. Required with existing_vpc_id."
+ type = list(string)
+ default = []
+
+ validation {
+ condition = var.existing_vpc_id == "" || length(var.existing_public_subnet_ids) >= 2
+ error_message = "existing_public_subnet_ids must list at least two subnets when existing_vpc_id is set."
+ }
+}
+
+variable "existing_private_subnet_ids" {
+ description = "Private subnet IDs in existing_vpc_id for Aurora. Required with existing_vpc_id."
+ type = list(string)
+ default = []
+
+ validation {
+ condition = var.existing_vpc_id == "" || length(var.existing_private_subnet_ids) >= 2
+ error_message = "existing_private_subnet_ids must list at least two subnets when existing_vpc_id is set."
+ }
+}
diff --git a/terraform/versions.tf b/terraform/versions.tf
new file mode 100644
index 0000000..8754570
--- /dev/null
+++ b/terraform/versions.tf
@@ -0,0 +1,26 @@
+terraform {
+ required_version = ">= 1.14.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.65"
+ }
+ archive = {
+ source = "hashicorp/archive"
+ version = "~> 2.8"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.9"
+ }
+ }
+
+ cloud {
+ organization = "seahaven"
+
+ workspaces {
+ name = "seahaven-ap-dev"
+ }
+ }
+}
diff --git a/terraform/vpc.tf b/terraform/vpc.tf
new file mode 100644
index 0000000..a7bb48e
--- /dev/null
+++ b/terraform/vpc.tf
@@ -0,0 +1,101 @@
+data "aws_availability_zones" "available" {
+ count = local.manage_vpc ? 1 : 0
+ state = "available"
+}
+
+data "aws_vpc" "existing" {
+ count = var.existing_vpc_id == "" ? 0 : 1
+ id = var.existing_vpc_id
+}
+
+data "aws_subnet" "existing_public" {
+ for_each = toset(var.existing_public_subnet_ids)
+ id = each.value
+}
+
+data "aws_subnet" "existing_private" {
+ for_each = toset(var.existing_private_subnet_ids)
+ id = each.value
+}
+
+data "aws_ec2_managed_prefix_list" "cloudfront_origin" {
+ name = "com.amazonaws.global.cloudfront.origin-facing"
+}
+
+resource "aws_vpc" "this" {
+ count = local.manage_vpc ? 1 : 0
+
+ cidr_block = local.vpc_cidr
+ enable_dns_support = true
+ enable_dns_hostnames = true
+
+ tags = {
+ Name = "${local.project}-vpc"
+ }
+}
+
+resource "aws_internet_gateway" "this" {
+ count = local.manage_vpc ? 1 : 0
+
+ vpc_id = aws_vpc.this[0].id
+
+ tags = {
+ Name = "${local.project}-igw"
+ }
+}
+
+resource "aws_subnet" "public" {
+ count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0
+
+ vpc_id = aws_vpc.this[0].id
+ cidr_block = local.public_subnet_cidrs[count.index]
+ availability_zone = data.aws_availability_zones.available[0].names[count.index]
+ map_public_ip_on_launch = true
+
+ tags = {
+ Name = "${local.project}-public-${count.index}"
+ }
+}
+
+resource "aws_subnet" "private" {
+ count = local.manage_vpc ? length(local.private_subnet_cidrs) : 0
+
+ vpc_id = aws_vpc.this[0].id
+ cidr_block = local.private_subnet_cidrs[count.index]
+ availability_zone = data.aws_availability_zones.available[0].names[count.index]
+
+ tags = {
+ Name = "${local.project}-private-${count.index}"
+ }
+}
+
+resource "aws_route_table" "public" {
+ count = local.manage_vpc ? 1 : 0
+
+ vpc_id = aws_vpc.this[0].id
+
+ tags = {
+ Name = "${local.project}-public"
+ }
+}
+
+resource "aws_route" "public_default" {
+ count = local.manage_vpc ? 1 : 0
+
+ route_table_id = aws_route_table.public[0].id
+ destination_cidr_block = "0.0.0.0/0"
+ gateway_id = aws_internet_gateway.this[0].id
+}
+
+resource "aws_route_table_association" "public" {
+ count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0
+
+ subnet_id = aws_subnet.public[count.index].id
+ route_table_id = aws_route_table.public[0].id
+}
+
+locals {
+ vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id)
+ public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids
+ private_subnet_ids = local.manage_vpc ? aws_subnet.private[*].id : var.existing_private_subnet_ids
+}