diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index 2df9f18..17bb3b5 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -15,14 +15,14 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.0 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6.4.0 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: frontend/package-lock.json @@ -39,7 +39,7 @@ jobs: run: npm run build - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6.2.0 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 84eaac0..c670a61 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + + ## Git Workflow **Always commit to the `dev` branch.** Never commit directly to `production` or `main` without explicit human approval. @@ -42,7 +44,7 @@ Cloud-native portfolio platform on AWS. React SPA frontend, fully serverless bac |---|---|---| | Hosting | S3 + CloudFront (OAC) | **live** | | CI/CD | GitHub Actions | **live** | -| API | API Gateway HTTP API + Lambda (Node.js 20) | **live** | +| API | API Gateway HTTP API + Lambda (Node.js 22) | **live** | | IaC | Terraform (`infra/`) | **live** | | Secrets / config | SSM Parameter Store | **live** | | Database | DynamoDB | **live** | @@ -60,12 +62,16 @@ Frontend connects via `VITE_API_BASE_URL`. When unset locally, all API calls ret ### CI/CD — `.github/workflows/deploy-frontend.yml` -Triggers on push to `production` (or `workflow_dispatch`). Steps: -1. `actions/checkout@v4` with `fetch-depth: 0` — full history for `git log --no-merges` -2. `npm ci` + `npm run build` (injects `VITE_*` secrets) -3. `aws s3 sync dist/ s3://$S3_BUCKET/` — assets with long cache, `index.html` no-cache -4. `aws cloudfront create-invalidation` — purges CDN cache -5. `aws ssm put-parameter` — writes last real commit (non-merge) SHA/message/date to `/portfolio/*` +Triggers on push to `production` **only for `frontend/**` path changes** (or `workflow_dispatch`). Steps: +1. `actions/checkout@v4.2.2` with `fetch-depth: 0` — full history for `git log --no-merges` +2. `actions/setup-node@v4.4.0` with `node-version: '22'` +3. `npm ci` + `npm run build` (injects `VITE_*` secrets) +4. `aws-actions/configure-aws-credentials@v4.1.0` +5. `aws s3 sync dist/ s3://$S3_BUCKET/` — assets with long cache, `index.html` no-cache +6. `aws cloudfront create-invalidation` — purges CDN cache +7. `aws ssm put-parameter` — writes last real commit (non-merge) SHA/message/date to `/portfolio/*` + +**Important:** CI/CD only deploys the frontend. Terraform/infra changes require manual `terraform apply` in `infra/`. GitHub secrets required: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET`, `CLOUDFRONT_DISTRIBUTION_ID`, `VITE_API_BASE_URL`, `VITE_GITHUB_TOKEN`, `VITE_ADMIN_PASSWORD`. @@ -80,7 +86,7 @@ infra/ lambda.tf # Lambda exec role + all Lambda functions + IAM policies api_gateway.tf # HTTP API, $default stage, "api" named stage (both throttled), # all routes + Lambda permissions, POST /admin/auth route - cloudfront_api.tf # CloudFront Function (not attached), origin request policy + cloudfront_api.tf # origin request policy # (whitelist: CloudFront-Viewer-Country + Authorization) dynamodb.tf # all DynamoDB tables (including rate_limit and admin_sessions) ses.tf # SES domain identity + Lambda SES send IAM policy @@ -94,6 +100,16 @@ Lambda hotfix without full CI/CD: terraform apply -target=aws_lambda_function. ``` +Lambda resource names in `lambda.tf`: `status`, `contact`, `contacts_get`, `contacts_patch`, `settings`, `resume`, `video`, `content`, `admin_auth`, `visitors`. + +Retrieve live resource IDs at any time: +```bash +terraform output +# cloudfront_distribution_id = "E3GN9C58SUEB3Q" +# s3_bucket_name = "cloud-portfolio-frontend-356892335394" +# api_gateway_url = "https://58l9thztmj.execute-api.us-east-1.amazonaws.com/" +``` + SSM parameters: - `/portfolio/version` — full git SHA of merge commit (written by CI) - `/portfolio/last-deploy` — ISO 8601 timestamp (written by CI) @@ -114,7 +130,11 @@ Setup: - ACM wildcard cert (`*.alessandro-bezerra.me` + SAN `alessandro-bezerra.me`) in `us-east-1`, DNS validated via Route 53 - CloudFront aliases currently `["portfolio.${domain_name}"]` only - Route 53 A alias records: `portfolio.` → CloudFront; `www.` and `@` records exist in Route 53 but are NOT in CloudFront aliases yet (blocked by Squarespace's old CloudFront distribution owning those CNAMEs) -- **When Squarespace releases the aliases** (verify with `aws cloudfront list-conflicting-aliases --alias www.alessandro-bezerra.me --distribution-id `): change `aliases` in `main.tf` to `["portfolio.${var.domain_name}", "www.${var.domain_name}", var.domain_name]` and run `terraform apply` +- **When Squarespace releases the aliases** — verify first: + ```bash + aws cloudfront list-conflicting-aliases --alias www.alessandro-bezerra.me --distribution-id E3GN9C58SUEB3Q + ``` + When `Quantity` returns `0`, change `aliases` in `main.tf` to `["portfolio.${var.domain_name}", "www.${var.domain_name}", var.domain_name]` and run `terraform apply` ### CloudFront dual-origin architecture @@ -126,7 +146,7 @@ CloudFront distribution has two origins: **Authorization forwarding is critical** — without it, Bearer tokens sent by the admin frontend never reach Lambda. The whitelist policy (`cloudfront_api.tf`) explicitly includes `Authorization`. -**API GW `api` named stage**: all Lambda routes are accessible at `/.execute-api.us-east-1.amazonaws.com/api/*`. This means a request to `portfolio.../api/visitors` hits CloudFront → APIGW origin → `api` stage → `/visitors` route — no URI rewriting needed. The CloudFront Function in `cloudfront_api.tf` exists in state but is NOT attached to any behavior. +**API GW `api` named stage**: all Lambda routes are accessible at `/.execute-api.us-east-1.amazonaws.com/api/*`. This means a request to `portfolio.../api/visitors` hits CloudFront → APIGW origin → `api` stage → `/visitors` route — no URI rewriting needed. The `$default` stage also exists (referenced in `outputs.tf` for the invoke URL) but receives no CloudFront traffic. **API Gateway throttling**: both `$default` and `api` stages have `throttling_burst_limit = 50`, `throttling_rate_limit = 20`. diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 675213c..6a1de45 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -13,7 +13,7 @@ function LangSlot({ lang }: { lang: string }) { return ( - - - {theme === 'dark' ? : } - - + {theme === 'dark' ? : } {/* Mobile menu button */} diff --git a/infra/cloudfront_api.tf b/infra/cloudfront_api.tf index a21022f..a06560d 100644 --- a/infra/cloudfront_api.tf +++ b/infra/cloudfront_api.tf @@ -1,18 +1,3 @@ -# ── CloudFront Function: strip /api prefix before forwarding to API Gateway ─── - -resource "aws_cloudfront_function" "api_rewrite" { - name = "${var.project_name}-api-rewrite-${var.environment}" - runtime = "cloudfront-js-2.0" - publish = true - code = <<-EOT - function handler(event) { - var request = event.request; - request.uri = request.uri.replace(/^\/api/, '') || '/'; - return request; - } - EOT -} - # ── Origin request policy: forward CloudFront-Viewer-Country to Lambda ──────── resource "aws_cloudfront_origin_request_policy" "api" { diff --git a/infra/lambda.tf b/infra/lambda.tf index ac90b80..0ca98f5 100644 --- a/infra/lambda.tf +++ b/infra/lambda.tf @@ -85,7 +85,7 @@ resource "aws_lambda_function" "status" { filename = data.archive_file.status_lambda.output_path source_code_hash = data.archive_file.status_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags @@ -105,7 +105,7 @@ resource "aws_lambda_function" "contact" { filename = data.archive_file.contact_lambda.output_path source_code_hash = data.archive_file.contact_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 15 tags = local.tags @@ -178,7 +178,7 @@ resource "aws_lambda_function" "settings" { filename = data.archive_file.settings_lambda.output_path source_code_hash = data.archive_file.settings_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags @@ -230,7 +230,7 @@ resource "aws_lambda_function" "resume" { filename = data.archive_file.resume_lambda.output_path source_code_hash = data.archive_file.resume_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 15 tags = local.tags @@ -249,7 +249,7 @@ resource "aws_lambda_function" "contacts_patch" { filename = data.archive_file.contacts_patch_lambda.output_path source_code_hash = data.archive_file.contacts_patch_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags @@ -267,7 +267,7 @@ resource "aws_lambda_function" "contacts_get" { filename = data.archive_file.contacts_lambda.output_path source_code_hash = data.archive_file.contacts_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags @@ -320,7 +320,7 @@ resource "aws_lambda_function" "video" { filename = data.archive_file.video_lambda.output_path source_code_hash = data.archive_file.video_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 15 tags = local.tags @@ -339,7 +339,7 @@ resource "aws_lambda_function" "visitors" { filename = data.archive_file.visitors_lambda.output_path source_code_hash = data.archive_file.visitors_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags @@ -383,7 +383,7 @@ resource "aws_lambda_function" "content" { filename = data.archive_file.content_lambda.output_path source_code_hash = data.archive_file.content_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags @@ -424,7 +424,7 @@ resource "aws_lambda_function" "admin_auth" { filename = data.archive_file.admin_auth_lambda.output_path source_code_hash = data.archive_file.admin_auth_lambda.output_base64sha256 handler = "index.handler" - runtime = "nodejs20.x" + runtime = "nodejs22.x" role = aws_iam_role.lambda_exec.arn timeout = 10 tags = local.tags