diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 49a12ad8..d4922b68 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -9,6 +9,7 @@ CI/CD and PR automation. Two groups: **build/deploy** pipelines and the **PR rev | `frontend-ci.yml` | PR to main/develop, merge_group | In `apps/frontend`: `npm ci` → typecheck → lint → build → test. **Required check `frontend-ci`.** | | `lambda-tests.yml` | push/PR main/develop, merge_group | Discover lambdas (excl. `tools/`); matrix per lambda spins Postgres 16, seeds `db_setup.sql`, starts dev-server, health-checks, `npm test`. **Required check `lambda-tests`.** | | `lambda-deploy.yml` | push to main, paths `apps/backend/lambdas/**` or `shared/types/**` | Detect changed lambdas (all if none); build `npm ci --legacy-peer-deps` + `npm run package` → `lambda.zip`; `aws lambda update-function-code --function-name branch-` (us-east-2). | +| `frontend-deploy.yml` | push to main, paths `apps/frontend/**` | Build static export (`npm run build` → `out/`) with `NEXT_PUBLIC_API_BASE_URL`; `aws s3 sync` to the frontend bucket + CloudFront invalidation. `production` env, OIDC apply role. | | `lambda-readme.yml` | after `terraform-plan` completes, or manual | `node tools/lambda-cli.js generate-readme` (all), commit regenerated READMEs. | | `regenerate-db-types.yaml` | `db_setup.sql` changes, after `lambda-readme`, or manual | Spin Postgres, apply schema, `kysely-codegen`, strip kysely import → local `ColumnType`, write `shared/types/db-types.d.ts`, `tsc --noEmit`, commit to PR branch (or comment "in sync"). | | `terraform-plan.yml` | PR main/develop, merge_group | Detect changed TF dirs; `fmt` + terraform-docs (auto-commit); per-dir `init`/`validate`/`plan`, post plan PR comment. **Required check `terraform-plan-summary`.** | diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml new file mode 100644 index 00000000..85529d88 --- /dev/null +++ b/.github/workflows/frontend-deploy.yml @@ -0,0 +1,63 @@ +name: Frontend Deploy + +on: + push: + branches: [main] + paths: + - 'apps/frontend/**' + workflow_dispatch: + +# Requires the OIDC apply role (branch-ci-apply) from infrastructure/aws/oidc.tf +# to exist. Deploys the static export to S3 + invalidates CloudFront. +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + permissions: + id-token: write # assume the OIDC apply role + contents: read + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Configure AWS credentials (write apply role via OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::489881683177:role/branch-ci-apply + aws-region: us-east-2 + + - name: Resolve backend API base URL + run: | + API_ID=$(aws apigateway get-rest-apis --query "items[?name=='branch-api'].id | [0]" --output text) + echo "NEXT_PUBLIC_API_BASE_URL=https://$API_ID.execute-api.us-east-2.amazonaws.com/prod" >> "$GITHUB_ENV" + + - name: Install dependencies + run: npm ci + + - name: Build (static export) + run: npm run build + + - name: Resolve S3 bucket + CloudFront distribution + run: | + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + echo "BUCKET=branch-frontend-$ACCOUNT_ID" >> "$GITHUB_ENV" + DIST_ID=$(aws cloudfront list-distributions \ + --query "DistributionList.Items[?Comment=='branch frontend (static SPA)'].Id | [0]" \ + --output text) + echo "DIST_ID=$DIST_ID" >> "$GITHUB_ENV" + + - name: Sync to S3 + run: aws s3 sync out "s3://$BUCKET" --delete + + - name: Invalidate CloudFront + run: aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*" + + - name: Summary + run: echo "Deployed apps/frontend to s3://$BUCKET and invalidated $DIST_ID" diff --git a/AGENTS.md b/AGENTS.md index 52d1b1c2..32d59af9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi - **Frontend:** Next.js 15.5 (App Router, Turbopack), React 19, Chakra UI v3 + Tailwind v4, JWT-in-localStorage auth. - **Backend:** AWS Lambda (Node 20), TypeScript, Kysely + PostgreSQL, AWS Cognito auth. Each service is self-contained, deployed as `branch-{service}`. -- **Infra:** Terraform 1.13.0, state in S3 (`c4c-neu-terraform-state-files`) + DynamoDB lock, secrets via Infisical. Frontend on Amplify; backend on Lambda + API Gateway; RDS Postgres 17. +- **Infra:** Terraform 1.13.0, state in S3 (`c4c-neu-terraform-state-files`) + DynamoDB lock, secrets via Infisical. Frontend is a static export on S3 + CloudFront (SPA); backend on Lambda + API Gateway; RDS Postgres 17. - **Monorepo:** Nx 16 (nx-cloud cache). Per-app `package.json` (not a single workspace install) — backend lambdas and frontend each `npm ci` independently. ## Shared packages (critical) diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index 2377c0f5..a361424b 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -2,6 +2,8 @@ Next.js 15.5 app (App Router, Turbopack), React 19. Talks to the backend lambda microservices. UI: Chakra UI v3 (unstyled primitives) + Tailwind v4. Auth: JWT in localStorage via a React context. +**Deployed as a static SPA.** `next.config.ts` sets `output: 'export'` — `npm run build` emits `out/`, synced to S3 + served by CloudFront (see `infrastructure/aws/frontend_hosting.tf`, `.github/workflows/frontend-deploy.yml`). No server: everything is client-rendered. Dynamic routes (`projects/[id]`) need `generateStaticParams` in a **Server Component** — the page is a thin server wrapper delegating to a `'use client'` component; deep links resolve via the CloudFront SPA fallback. `NEXT_PUBLIC_API_BASE_URL` (set at build) points the client at API Gateway. + > `README.md` here is `create-next-app` boilerplate — ignore it. ## Commands @@ -38,7 +40,7 @@ test/ # jest + RTL mirror of src/ (custom render in test/u No React Query / SWR / Redux. Pattern: `useState` + `useEffect` + `apiFetch`, local component state. -`src/lib/api.ts` — `apiFetch(path, { token?, ... })`. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Injects `Authorization: Bearer `. Throws on non-2xx. `next.config.ts` rewrites mirror this routing for the dev server. New backend calls go through `apiFetch` — don't hand-roll `fetch`. +`src/lib/api.ts` — `apiFetch(path, { token?, ... })`. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Injects `Authorization: Bearer `. Throws on non-2xx. In production `NEXT_PUBLIC_API_BASE_URL` (API Gateway) is set at build so every call goes there with its full prefixed path; the localhost port map is the dev fallback. (Static export has no server, so there are no `next.config` rewrites.) New backend calls go through `apiFetch` — don't hand-roll `fetch`. ## Auth diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 2879aa1c..dd7aa664 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -1,38 +1,13 @@ import type { NextConfig } from 'next'; +// Static export (SPA) — the app is fully client-rendered (JWT in localStorage, +// all data via apiFetch). Output goes to `out/`, hosted on S3 + CloudFront. +// No server: `next.config` rewrites don't run in export, so prod routing to the +// backend is entirely via NEXT_PUBLIC_API_BASE_URL (see src/lib/api.ts). const nextConfig: NextConfig = { - async rewrites() { - return [ - { - source: '/auth/:path*', - destination: 'http://localhost:3006/auth/:path*', - }, - { - source: '/expenditures/:path*', - destination: 'http://localhost:3004/expenditures/:path*', - }, - { - source: '/expenditures', - destination: 'http://localhost:3004/expenditures', - }, - { - source: '/projects/:id/members', - destination: 'http://localhost:3002/:id/members', - }, - { - source: '/projects/:id/expenditures', - destination: 'http://localhost:3002/:id/expenditures', - }, - { - source: '/projects/:id/donors', - destination: 'http://localhost:3002/:id/donors', - }, - { - source: '/projects', - destination: 'http://localhost:3002/projects', - }, - ]; - }, + output: 'export', + trailingSlash: true, // emit /route/index.html — clean S3 key mapping + images: { unoptimized: true }, // no server image optimizer in export }; export default nextConfig; diff --git a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx new file mode 100644 index 00000000..6d37af05 --- /dev/null +++ b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx @@ -0,0 +1,189 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { FaEdit } from 'react-icons/fa'; +import { RxCaretRight } from 'react-icons/rx'; +import NavBar from '../../components/Navbar'; +import ExpensesTable from '../../components/ExpensesTable'; +import StaffCard from '../../components/StaffCard'; +import type { Expenditure } from '../../components/ExpensesTable'; +import { apiFetch } from '@/lib/api'; + +type Project = { + project_id: number; + name: string; + description: string; + total_budget: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + created_at: string | null; +}; + +type Member = { + user_id: number; + name: string; + email: string; + role: string; +}; + +const PREVIEW_EXPENSES = 8; +const PREVIEW_STAFF = 4; + +export default function ProjectPage() { + const { id } = useParams<{ id: string }>(); + + const [project, setProject] = useState(null); + const [expenditures, setExpenditures] = useState([]); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const token = + typeof window !== 'undefined' + ? (localStorage.getItem('branch_access_token') ?? '') + : ''; + + useEffect(() => { + if (!id) return; + + async function fetchAll() { + setLoading(true); + setError(null); + try { + const [projectData, expenditureData, memberData] = await Promise.all([ + apiFetch(`/projects/${id}`, { token }), + apiFetch(`/projects/${id}/expenditures`, { token }), + apiFetch<{ ok: boolean; body: { users: Member[] } }>( + `/projects/${id}/members`, + { token }, + ), + ]); + setProject(projectData); + setExpenditures(Array.isArray(expenditureData) ? expenditureData : []); + setMembers(memberData?.body?.users ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load project'); + } finally { + setLoading(false); + } + } + + fetchAll(); + }, [id, token]); + + // financial info + const totalBudget = project?.total_budget ? parseFloat(project.total_budget) : 0; + const totalSpent = expenditures.reduce((sum, e) => sum + parseFloat(e.amount), 0); + const totalRemaining = totalBudget - totalSpent; + + // Loading state + if (loading) { + return ( +
+ +
+

Loading project...

+
+
+ ); + } + + // error / not found state + if (error || !project) { + return ( +
+ +
+

+ {error ?? 'Project not found.'} +

+
+
+ ); + } + + // main page + return ( +
+ + +
+ + {/* Project title row */} +
+

{project.name}

+ +
+ +

{project.description}

+ + {/* Stat cards */} +
+ {[ + { label: 'Funding Received', value: totalBudget }, + { label: 'Total Spent', value: totalSpent }, + { label: 'Total Remaining', value: totalRemaining }, + ].map(({ label, value }) => ( +
+

{label}

+

+ ${value.toLocaleString()} +

+
+ ))} +
+ +
+ {/* Expenses */} +
+
+

Expenses

+ +
+ {expenditures.length === 0 ? ( +

No expenses recorded.

+ ) : ( +
+ +
+ )} +
+ + {/* Staff */} +
+
+

Staff

+ +
+ {members.length === 0 ? ( +

No staff assigned.

+ ) : ( +
+ {members.slice(0, PREVIEW_STAFF).map((member) => ( + + ))} +
+ )} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/app/projects/[id]/page.tsx b/apps/frontend/src/app/projects/[id]/page.tsx index 6d37af05..fb0605c5 100644 --- a/apps/frontend/src/app/projects/[id]/page.tsx +++ b/apps/frontend/src/app/projects/[id]/page.tsx @@ -1,189 +1,17 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { FaEdit } from 'react-icons/fa'; -import { RxCaretRight } from 'react-icons/rx'; -import NavBar from '../../components/Navbar'; -import ExpensesTable from '../../components/ExpensesTable'; -import StaffCard from '../../components/StaffCard'; -import type { Expenditure } from '../../components/ExpensesTable'; -import { apiFetch } from '@/lib/api'; - -type Project = { - project_id: number; - name: string; - description: string; - total_budget: string | null; - start_date: string | null; - end_date: string | null; - currency: string | null; - created_at: string | null; -}; - -type Member = { - user_id: number; - name: string; - email: string; - role: string; -}; - -const PREVIEW_EXPENSES = 8; -const PREVIEW_STAFF = 4; - -export default function ProjectPage() { - const { id } = useParams<{ id: string }>(); - - const [project, setProject] = useState(null); - const [expenditures, setExpenditures] = useState([]); - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const token = - typeof window !== 'undefined' - ? (localStorage.getItem('branch_access_token') ?? '') - : ''; - - useEffect(() => { - if (!id) return; - - async function fetchAll() { - setLoading(true); - setError(null); - try { - const [projectData, expenditureData, memberData] = await Promise.all([ - apiFetch(`/projects/${id}`, { token }), - apiFetch(`/projects/${id}/expenditures`, { token }), - apiFetch<{ ok: boolean; body: { users: Member[] } }>( - `/projects/${id}/members`, - { token }, - ), - ]); - setProject(projectData); - setExpenditures(Array.isArray(expenditureData) ? expenditureData : []); - setMembers(memberData?.body?.users ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project'); - } finally { - setLoading(false); - } - } - - fetchAll(); - }, [id, token]); - - // financial info - const totalBudget = project?.total_budget ? parseFloat(project.total_budget) : 0; - const totalSpent = expenditures.reduce((sum, e) => sum + parseFloat(e.amount), 0); - const totalRemaining = totalBudget - totalSpent; - - // Loading state - if (loading) { - return ( -
- -
-

Loading project...

-
-
- ); - } - - // error / not found state - if (error || !project) { - return ( -
- -
-

- {error ?? 'Project not found.'} -

-
-
- ); - } - - // main page - return ( -
- - -
- - {/* Project title row */} -
-

{project.name}

- -
- -

{project.description}

- - {/* Stat cards */} -
- {[ - { label: 'Funding Received', value: totalBudget }, - { label: 'Total Spent', value: totalSpent }, - { label: 'Total Remaining', value: totalRemaining }, - ].map(({ label, value }) => ( -
-

{label}

-

- ${value.toLocaleString()} -

-
- ))} -
- -
- {/* Expenses */} -
-
-

Expenses

- -
- {expenditures.length === 0 ? ( -

No expenses recorded.

- ) : ( -
- -
- )} -
- - {/* Staff */} -
-
-

Staff

- -
- {members.length === 0 ? ( -

No staff assigned.

- ) : ( -
- {members.slice(0, PREVIEW_STAFF).map((member) => ( - - ))} -
- )} -
- -
-
-
- ); -} \ No newline at end of file +// Server wrapper for the dynamic project route. `output: 'export'` requires a +// dynamic segment to declare its params here (a Server Component export), so +// the actual UI lives in ProjectDetailClient. We pre-render no ids — every +// /projects/:id is client-rendered (ProjectDetailClient reads the id via +// useParams and fetches it). Hard loads/deep links resolve through the +// CloudFront SPA fallback (404 -> /index.html). +import ProjectDetailClient from './ProjectDetailClient'; + +// Export needs >=1 param to emit the route. We only emit a throwaway shell; +// real ids are client-rendered and resolve via the CloudFront SPA fallback. +export function generateStaticParams() { + return [{ id: 'placeholder' }]; +} + +export default function Page() { + return ; +} diff --git a/infrastructure/AGENTS.md b/infrastructure/AGENTS.md index 26584829..35b59b8a 100644 --- a/infrastructure/AGENTS.md +++ b/infrastructure/AGENTS.md @@ -13,7 +13,8 @@ Application infra. Providers: AWS 6.14.1, Infisical. - `cognito.tf` — user pool (email sign-in, auto-verify, 8-char password policy, advanced security, deletion protection) + public client (1h access/ID tokens, 30d refresh, no secret). **Manual step:** copy output pool/client IDs into Infisical `/aws/cognito/`. - `api_gateway.tf` — REST API, one resource per lambda, method routing, `AWS_PROXY` integration, `prod` stage. - `s3.tf` — public-read reports bucket + versioned/encrypted lambda-deployments bucket. -- `amplify.tf` — frontend (Next.js SSR), monorepo root `apps/frontend`, auto-deploys `main` (GitHub token from Infisical). +- `frontend_hosting.tf` — static frontend: private S3 bucket + CloudFront (OAC) with an SPA fallback (403/404 → `/index.html`) and an index-rewrite CloudFront Function. The Next.js app is exported (`output: 'export'`) and synced to S3 by the `frontend-deploy` workflow. +- `oidc.tf` — GitHub OIDC provider + `branch-ci-plan` (read-only) / `branch-ci-apply` (write, `production` env only) roles for CI. - `secrets.tf`, `variables.tf` — Infisical data sources. ### `github/` (state key `github/terraform.tfstate`) diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index 1b6e1d69..473c054f 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -23,38 +23,34 @@ No modules. | Name | Type | |------|------| -| [aws_amplify_app.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/amplify_app) | resource | -| [aws_amplify_branch.main](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/amplify_branch) | resource | | [aws_api_gateway_deployment.branch_deployment](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_deployment) | resource | | [aws_api_gateway_integration.lambda_integrations](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_integration) | resource | | [aws_api_gateway_method.lambda_methods](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_method) | resource | | [aws_api_gateway_resource.lambda_resources](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_resource) | resource | | [aws_api_gateway_rest_api.branch_api](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_rest_api) | resource | | [aws_api_gateway_stage.branch_stage](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_stage) | resource | -| [aws_cloudwatch_event_api_destination.github_dispatch](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudwatch_event_api_destination) | resource | -| [aws_cloudwatch_event_connection.github_dispatch](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudwatch_event_connection) | resource | -| [aws_cloudwatch_event_rule.amplify_frontend_deploy](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudwatch_event_rule) | resource | -| [aws_cloudwatch_event_target.github_dispatch](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudfront_distribution.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudfront_distribution) | resource | +| [aws_cloudfront_function.rewrite_index](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudfront_function) | resource | +| [aws_cloudfront_origin_access_control.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cloudfront_origin_access_control) | resource | | [aws_cognito_user_pool.branch_user_pool](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cognito_user_pool) | resource | | [aws_cognito_user_pool_client.branch_client](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/cognito_user_pool_client) | resource | | [aws_db_instance.branch_rds](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/db_instance) | resource | | [aws_iam_openid_connect_provider.github](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_openid_connect_provider) | resource | -| [aws_iam_role.amplify_ssr](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role) | resource | | [aws_iam_role.ci_apply](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role) | resource | | [aws_iam_role.ci_plan](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role) | resource | -| [aws_iam_role.eventbridge_api_dest](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role) | resource | | [aws_iam_role.lambda_role](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role) | resource | | [aws_iam_role_policy.ci_plan_state_lock](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.eventbridge_api_dest](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.amplify_ssr](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ci_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ci_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_function.functions](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/lambda_function) | resource | | [aws_lambda_permission.api_gateway_permissions](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/lambda_permission) | resource | +| [aws_s3_bucket.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.reports_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_policy.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | | [aws_s3_bucket_policy.reports_bucket_policy](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | +| [aws_s3_bucket_public_access_block.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_public_access_block.reports_bucket_public_access](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_server_side_encryption_configuration.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | | [aws_s3_bucket_versioning.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_versioning) | resource | @@ -63,6 +59,7 @@ No modules. | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/data-sources/caller_identity) | data source | | [aws_iam_policy_document.ci_apply_assume](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.ci_plan_assume](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.frontend_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/data-sources/iam_policy_document) | data source | | [infisical_secrets.github_folder](https://registry.terraform.io/providers/infisical/infisical/latest/docs/data-sources/secrets) | data source | | [infisical_secrets.rds_folder](https://registry.terraform.io/providers/infisical/infisical/latest/docs/data-sources/secrets) | data source | @@ -70,7 +67,6 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [api\_base\_url](#input\_api\_base\_url) | Base URL for the backend API, injected as NEXT\_PUBLIC\_API\_BASE\_URL | `string` | `""` | no | | [infisical\_client\_id](#input\_infisical\_client\_id) | n/a | `string` | n/a | yes | | [infisical\_client\_secret](#input\_infisical\_client\_secret) | n/a | `string` | n/a | yes | | [infisical\_workspace\_id](#input\_infisical\_workspace\_id) | n/a | `string` | `"d1ee8b80-118c-4daf-ae84-31da43261b76"` | no | @@ -85,5 +81,8 @@ No modules. | [cognito\_region](#output\_cognito\_region) | AWS Region for Cognito | | [cognito\_user\_pool\_arn](#output\_cognito\_user\_pool\_arn) | Cognito User Pool ARN | | [cognito\_user\_pool\_endpoint](#output\_cognito\_user\_pool\_endpoint) | Cognito User Pool Endpoint | +| [frontend\_bucket](#output\_frontend\_bucket) | S3 bucket the frontend build is synced to | +| [frontend\_cloudfront\_distribution\_id](#output\_frontend\_cloudfront\_distribution\_id) | CloudFront distribution id (for cache invalidation in CI) | +| [frontend\_cloudfront\_domain](#output\_frontend\_cloudfront\_domain) | Public URL of the frontend | | [reports\_bucket\_name](#output\_reports\_bucket\_name) | Name of the S3 bucket for generated reports | diff --git a/infrastructure/aws/amplify.tf b/infrastructure/aws/amplify.tf deleted file mode 100644 index 86975d11..00000000 --- a/infrastructure/aws/amplify.tf +++ /dev/null @@ -1,74 +0,0 @@ -# SSR (WEB_COMPUTE) apps require a service role so Amplify can provision the -# compute backend and write its CloudWatch logs. Without it, SSR deploys fail. -resource "aws_iam_role" "amplify_ssr" { - name = "branch-amplify-ssr-role" - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - # sts:TagSession is required alongside AssumeRole — Amplify attaches - # session tags when assuming the service role; without it the assume is - # denied ("Unable to assume specified IAM Role"). - Action = ["sts:AssumeRole", "sts:TagSession"] - Effect = "Allow" - # Amplify assumes the role via BOTH the regional and global service - # principals; the regional one (amplify..amazonaws.com) is the - # documented fix for "Unable to assume specified IAM Role" on SSR apps. - Principal = { Service = ["amplify.us-east-2.amazonaws.com", "amplify.amazonaws.com"] } - }] - }) -} - -resource "aws_iam_role_policy_attachment" "amplify_ssr" { - role = aws_iam_role.amplify_ssr.name - policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess-Amplify" -} - -resource "aws_amplify_app" "frontend" { - name = "branch-frontend" - repository = "https://github.com/Code-4-Community/branch" - access_token = data.infisical_secrets.github_folder.secrets["branch-gh-admin"].value - platform = "WEB_COMPUTE" - iam_service_role_arn = aws_iam_role.amplify_ssr.arn - - # Monorepo build spec: the `applications`/`appRoot` form makes Amplify build - # from apps/frontend AND skip builds when no files under that root changed — - # so backend/infra pushes to main don't trigger a frontend deploy. - build_spec = <<-EOT - version: 1 - applications: - - appRoot: apps/frontend - frontend: - phases: - preBuild: - commands: - - npm ci - build: - commands: - - npm run build - artifacts: - baseDirectory: .next - files: - - '**/*' - cache: - paths: - - .next/cache/**/* - EOT - - environment_variables = { - # Default the API base URL to the deployed API Gateway stage so the built - # frontend talks to the real backend instead of localhost. var.api_base_url - # still overrides when set (was previously "" -> apiFetch hit localhost). - NEXT_PUBLIC_API_BASE_URL = var.api_base_url != "" ? var.api_base_url : aws_api_gateway_stage.branch_stage.invoke_url - } - - enable_branch_auto_deletion = true -} - -resource "aws_amplify_branch" "main" { - app_id = aws_amplify_app.frontend.id - branch_name = "main" - framework = "Next.js - SSR" - stage = "PRODUCTION" - - enable_auto_build = true -} diff --git a/infrastructure/aws/amplify_notifications.tf b/infrastructure/aws/amplify_notifications.tf deleted file mode 100644 index c5e6f35a..00000000 --- a/infrastructure/aws/amplify_notifications.tf +++ /dev/null @@ -1,110 +0,0 @@ -# Amplify runs the frontend build outside GitHub Actions, so there is no native -# post-deploy hook. Amplify does emit build state changes to EventBridge, so we -# match those events and fan them into a GitHub `repository_dispatch`, which the -# "Frontend Deploy Notify" workflow reacts to (posts a PR comment + Slack msg). -# -# This replaces a long-polling GitHub Actions job — no idle runner, instant. - -locals { - github_repo_dispatch_url = "https://api.github.com/repos/Code-4-Community/branch/dispatches" -} - -# Connection holds the GitHub token EventBridge sends as the Authorization -# header. Reuses the same admin PAT Amplify already uses (see amplify.tf). -resource "aws_cloudwatch_event_connection" "github_dispatch" { - name = "branch-github-dispatch" - authorization_type = "API_KEY" - - auth_parameters { - api_key { - key = "Authorization" - value = "Bearer ${data.infisical_secrets.github_folder.secrets["branch-gh-admin"].value}" - } - } -} - -resource "aws_cloudwatch_event_api_destination" "github_dispatch" { - name = "branch-github-dispatch" - invocation_endpoint = local.github_repo_dispatch_url - http_method = "POST" - invocation_rate_limit_per_second = 10 - connection_arn = aws_cloudwatch_event_connection.github_dispatch.arn -} - -# Match only terminal build states on the frontend app's main branch. -resource "aws_cloudwatch_event_rule" "amplify_frontend_deploy" { - name = "branch-amplify-frontend-deploy" - description = "Amplify main-branch deploy status changes for the frontend app" - - event_pattern = jsonencode({ - source = ["aws.amplify"] - "detail-type" = ["Amplify Deployment Status Change"] - detail = { - appId = [aws_amplify_app.frontend.id] - branchName = ["main"] - jobStatus = ["SUCCEED", "FAILED", "CANCELLED"] - } - }) -} - -# EventBridge assumes this role to invoke the API destination. -resource "aws_iam_role" "eventbridge_api_dest" { - name = "branch-eventbridge-api-dest-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { Service = "events.amazonaws.com" } - }] - }) -} - -resource "aws_iam_role_policy" "eventbridge_api_dest" { - name = "invoke-github-dispatch" - role = aws_iam_role.eventbridge_api_dest.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Action = "events:InvokeApiDestination" - Resource = aws_cloudwatch_event_api_destination.github_dispatch.arn - }] - }) -} - -resource "aws_cloudwatch_event_target" "github_dispatch" { - rule = aws_cloudwatch_event_rule.amplify_frontend_deploy.name - arn = aws_cloudwatch_event_api_destination.github_dispatch.arn - role_arn = aws_iam_role.eventbridge_api_dest.arn - - http_target { - header_parameters = { - "Content-Type" = "application/json" - "Accept" = "application/vnd.github+json" - "X-GitHub-Api-Version" = "2022-11-28" - } - } - - # Reshape the Amplify event into a repository_dispatch body. EventBridge - # auto-quotes string variables, so placeholders stay unquoted. - input_transformer { - input_paths = { - jobId = "$.detail.jobId" - status = "$.detail.jobStatus" - appId = "$.detail.appId" - } - input_template = <<-EOT - { - "event_type": "amplify-deploy", - "client_payload": { - "jobId": , - "status": , - "appId": - } - } - EOT - } -} diff --git a/infrastructure/aws/frontend_hosting.tf b/infrastructure/aws/frontend_hosting.tf new file mode 100644 index 00000000..c67b3002 --- /dev/null +++ b/infrastructure/aws/frontend_hosting.tf @@ -0,0 +1,136 @@ +# Static frontend hosting: private S3 bucket + CloudFront (OAC). +# The Next.js app is exported as a static SPA (see apps/frontend, output:export) +# and synced to S3 by the frontend-deploy workflow; CloudFront serves it over +# HTTPS with an SPA fallback for client-routed paths (e.g. /projects/:id). + +resource "aws_s3_bucket" "frontend" { + bucket = "branch-frontend-${data.aws_caller_identity.current.account_id}" +} + +resource "aws_s3_bucket_public_access_block" "frontend" { + bucket = aws_s3_bucket.frontend.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# CloudFront reads S3 via Origin Access Control (bucket stays private). +resource "aws_cloudfront_origin_access_control" "frontend" { + name = "branch-frontend-oac" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +# Rewrites pretty paths to the exported index.html files. trailingSlash=true in +# next.config emits /route/index.html, so /route/ -> /route/index.html and +# extensionless /route -> /route/index.html. Unknown paths (e.g. /projects/7) +# then 404 in S3 and hit the SPA fallback below. +resource "aws_cloudfront_function" "rewrite_index" { + name = "branch-frontend-rewrite-index" + runtime = "cloudfront-js-2.0" + comment = "Append index.html to directory/extensionless requests" + publish = true + code = <<-EOT + function handler(event) { + var request = event.request; + var uri = request.uri; + if (uri.endsWith('/')) { + request.uri += 'index.html'; + } else if (!uri.includes('.')) { + request.uri += '/index.html'; + } + return request; + } + EOT +} + +resource "aws_cloudfront_distribution" "frontend" { + enabled = true + default_root_object = "index.html" + comment = "branch frontend (static SPA)" + price_class = "PriceClass_100" + + origin { + domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name + origin_id = "s3-frontend" + origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id + } + + default_cache_behavior { + target_origin_id = "s3-frontend" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + # AWS managed "CachingOptimized" policy. + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.rewrite_index.arn + } + } + + # SPA fallback: client-routed paths that don't exist as objects (deep links + # like /projects/7) return index.html so the Next client router can render. + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + error_caching_min_ttl = 10 + } + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + error_caching_min_ttl = 10 + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + } +} + +# Allow only this CloudFront distribution (via OAC) to read the bucket. +data "aws_iam_policy_document" "frontend_bucket" { + statement { + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.frontend.arn}/*"] + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.frontend.arn] + } + } +} + +resource "aws_s3_bucket_policy" "frontend" { + bucket = aws_s3_bucket.frontend.id + policy = data.aws_iam_policy_document.frontend_bucket.json +} + +output "frontend_bucket" { + description = "S3 bucket the frontend build is synced to" + value = aws_s3_bucket.frontend.bucket +} + +output "frontend_cloudfront_domain" { + description = "Public URL of the frontend" + value = aws_cloudfront_distribution.frontend.domain_name +} + +output "frontend_cloudfront_distribution_id" { + description = "CloudFront distribution id (for cache invalidation in CI)" + value = aws_cloudfront_distribution.frontend.id +} diff --git a/infrastructure/aws/variables.tf b/infrastructure/aws/variables.tf index e72a6269..07d2fd53 100644 --- a/infrastructure/aws/variables.tf +++ b/infrastructure/aws/variables.tf @@ -1,10 +1,4 @@ variable "infisical_workspace_id" { type = string default = "d1ee8b80-118c-4daf-ae84-31da43261b76" -} - -variable "api_base_url" { - type = string - description = "Base URL for the backend API, injected as NEXT_PUBLIC_API_BASE_URL" - default = "" } \ No newline at end of file