# CLAUDE.md

Guidance for AI agents (and humans) working in this repository. Read this before making changes.

> **This file is mirrored 1:1 to `AGENTS.md`.** If you edit one, make the identical edit to the other. They must stay byte-for-byte identical.

---

## What this project is

**PR Previews (PP)** is a self-hosted service that connects to a **Gitea** instance via webhook. When a PR is opened or updated, PP:

1. Provisions an **AWS EC2** instance in the user's own AWS account,
2. Clones the PR branch, installs deps, builds, and runs the app over SSH,
3. Posts a **live preview URL** (`http://<ec2-ip>:<port>`) back onto the PR as an in-place-edited comment.

It is built for teams and agent-driven workflows where reviewers want to see a running PR without pulling code locally. **Gitea only** — no GitHub/GitLab. One preview per PR; the EC2 instance is reused across pushes.

The canonical product/behavior spec is [`SPEC.md`](./SPEC.md). When behavior is ambiguous, **SPEC.md wins** — this file describes *how the code is built*, SPEC.md describes *what it must do*.

---

## Repository layout

This is a **pnpm workspace monorepo** (`pnpm-workspace.yaml` → `backend`, `frontend`).

```
./backend/            # Node.js API + workers (rjweb-server + Prisma). Compiled with esbuild.
./frontend/           # React 19 + Vite + Tailwind SPA.
./prisma/             # Prisma schema + migrations — LIVES AT REPO ROOT, not in backend/.
  schema.prisma
  migrations/
./Dockerfile          # Multi-stage build for the whole app (frontend + backend + prisma).
./docker-compose.yml  # Runs PP itself: pp-backend + pp-db (Postgres). NOT the preview EC2s.
./SPEC.md             # Product/behavior specification (source of truth for behavior).
./example.env         # Env var template.
./TODO.md             # Known follow-ups / rough edges (untracked scratch list).
```

### Backend source map (`backend/src/`)
```
index.ts                     # Server bootstrap: builds rjweb Server, registers ALL routes, starts workers.
lib/
  env.ts                     # Zod-validated process.env. Loads backend/.env. Throws on invalid env at boot.
  db.ts                      # Prisma client singleton (`prisma`).
  encryption.ts              # AES-256-GCM encrypt()/decrypt() using ENCRYPTION_KEY.
  logger.ts                  # pino logger + createLogger("COMPONENT") child factory.
  errors.ts                  # ERROR_MESSAGES map (code + message constants).
  response.ts                # makeResponse()/endResponse() — the standard JSON envelope.
  adminSettings.ts           # getAdminSettings() — reads/creates the single AdminSettings row (id=1).
  middlewares/
    cors.ts                  # CORS (allows PP_BASE_URL origin; wide-open in development).
    main.ts                  # Handles OPTIONS preflight.
    auth.ts                  # authResolution (adds ctr.getAuth()) + authEnforcement middleware.
routes/
  auth.ts                    # login/logout/me/setup-status/first-user.
  webhook.ts                 # POST /webhook/{userId} — HMAC verify, enqueue jobs. The Gitea entrypoint.
  api/user.ts                # User settings: gitea creds, aws creds, webhook secret.
  api/repos.ts               # Repo discovery + enable/disable (registers/deletes Gitea webhooks).
  api/previews.ts            # List/get/stop previews + WebSocket log stream.
  api/admin.ts               # Admin: user CRUD, global settings, all-previews dashboard.
services/
  ec2.ts                     # AWS EC2/STS: key pairs, security groups, launch, terminate, AMI map, bootstrap script.
  ssh.ts                     # ssh2 wrapper: connectSsh(), exec with timeout, abort support.
  gitea.ts                   # Gitea REST client (axios): repos, webhooks, comments, permissions, PR comment body builder.
  deploy.ts                  # THE deploy engine: firstDeploy/redeploy/stopPreview, log streaming, abort signals.
  orphanCleanup.ts           # On boot: terminate EC2s tagged pp:managed that have no active Preview.
workers/
  jobWorker.ts               # Polls Job table, serializes per-preview, new DEPLOY cancels running DEPLOY.
  cronWorker.ts              # node-cron: inactivity check (30m) + daily cleanup (03:00).
types/node-cron.d.ts         # Ambient types.
```

### Frontend source map (`frontend/src/`)
```
main.tsx / App.tsx           # Router (react-router-dom v7). ProtectedRoute + SetupCheck gating.
services/api.ts              # Typed fetch wrapper (credentials: include) + openLogsWs() WebSocket helper.
hooks/useAuth.ts             # Auth context/provider (calls /api/auth/me).
hooks/useTheme.ts            # Dark/light theme, persisted to localStorage.
pages/                       # Login, Dashboard, PreviewDetail, Repos, Settings, Admin, SetupWizard, Privacy.
components/                  # Layout, LogViewer, StatusBadge, ConfirmDialog.
```

---

## Tech stack & versions

| Layer | Choice | Notes |
|---|---|---|
| Runtime | **Node 24** (alpine in Docker) | `packageManager` pins **pnpm 11.5.2** (hash-verified via corepack). |
| Package manager | **pnpm** workspaces | Do **not** use `npm`/`yarn`. A stray `package-lock.json` exists but pnpm is authoritative. |
| Backend HTTP | **rjweb-server ^9.8.6** + `@rjweb/runtime-node` | v9 API — see gotchas below. |
| Backend build | **esbuild** → CJS to `dist/` | Not `tsc` for runtime; `tsc` is only used by the frontend build. |
| ORM / DB | **Prisma ^6** + **PostgreSQL 18** | Schema at `./prisma/schema.prisma` (repo root). |
| Auth hashing | **bcryptjs** | NOT native `bcrypt` (Node 24 compat). |
| AWS | `@aws-sdk/client-ec2`, `@aws-sdk/client-sts` v3 | |
| SSH | **ssh2** | Connects to EC2 as user `ubuntu`. |
| HTTP client | **axios** | Gitea REST calls. |
| Validation | **zod** | Env schema; ad-hoc input checks in handlers. |
| Logging | **pino** (+ pino-pretty in dev) | |
| Scheduling | **node-cron** | |
| Frontend | **React 19**, **Vite 6**, **Tailwind 3**, **react-router-dom 7**, **motion**, **react-toastify** | |

---

## Commands

Run backend/frontend commands from **their own package dir** (`cd backend` / `cd frontend`). This is Windows/PowerShell — chain with `;` (not `&&`) if needed, or just run the dedicated tool.

### Local development
```bash
# 1. Start Postgres (compose, just the db)
docker compose up -d pp-db

# 2. Apply migrations (schema path is RELATIVE and points OUTSIDE backend/)
cd backend; npx prisma migrate dev --schema=../prisma/schema.prisma
#   or: pnpm --filter pp-backend migrate

# 3. Backend (esbuild → dist → node), serves API on :5000
cd backend; pnpm install; pnpm dev

# 4. Frontend dev server on :3000, proxies /api and /webhook → :5000
cd frontend; pnpm install; pnpm dev
```

### Build / production
```bash
cd backend; pnpm build      # esbuild src → dist
cd backend; pnpm start      # node dist/index.js  (cwd must be dist)
cd backend; pnpm prod       # build + start
cd frontend; pnpm build     # tsc typecheck + vite build → frontend/dist

# Whole stack (build image, run backend + db, auto-runs `prisma migrate deploy` on start)
docker compose up -d
```

### Prisma
```bash
cd backend; pnpm generate         # prisma generate --schema=../prisma/schema.prisma
cd backend; pnpm migrate          # migrate dev
cd backend; pnpm migrate:deploy   # migrate deploy (used by the Docker CMD)
```

> ⚠️ **Every Prisma command needs `--schema=../prisma/schema.prisma`** because the schema is at the repo root, not in `backend/`. The package.json scripts already include it — prefer them.

There are **no automated tests** (V1 decision). Verify changes by running the stack. Do not add a test runner unless asked.

---

## Environment variables

Validated at boot by `backend/src/lib/env.ts` (zod). Backend loads `backend/.env`. Missing/invalid vars **throw and prevent startup** — this is intentional.

| Var | Required | Purpose |
|---|---|---|
| `DATABASE_URL` | yes | Postgres connection string. |
| `SESSION_SECRET` | yes (min 16) | Session cookie value signing. |
| `PP_BASE_URL` | yes | Public URL of this PP instance. Used in webhook URLs, PR comment links, CORS allow-list. No trailing slash. |
| `ENCRYPTION_KEY` | yes | **Exactly 64 hex chars** (32-byte AES-256). Encrypts Gitea PAT, AWS creds, SSH keys at rest. Changing it invalidates all stored secrets. |
| `PORT` | no (default 5000) | Backend port. |
| `LOG_LEVEL` | no (default info) | trace/debug/info/warn/error. |
| `NODE_ENV` | no (default development) | development/production/test. |

Generate secrets: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.

---

## How the system works (data + control flow)

### Request → deploy pipeline
1. **Gitea** fires a webhook → `POST /webhook/{userId}` (`routes/webhook.ts`).
2. Handler: rate-limits per user (in-memory), **HMAC-SHA256 verifies** the raw body against the user's `WebhookToken.token`, returns `200 OK` **immediately**, then processes async via `setImmediate`.
3. It creates/updates a `Preview` row and enqueues a `Job` (`DEPLOY`/`STOP`) in Postgres. Webhooks never do slow work inline.
4. **`jobWorker.ts`** polls the `Job` table (~1s), enforces **one active job per `previewId`**, and — key rule — a **new `DEPLOY` aborts a currently-running `DEPLOY`** for the same preview (via `signalAbort` → SSH session `.abort()`).
5. **`deploy.ts`** does the real work over SSH: `firstDeploy` (provision EC2, clone, setup, build, run) or `redeploy` (reuse instance, pull, rebuild, restart). Status transitions: `PROVISIONING → BUILDING → RUNNING` (or `FAILED`).
6. Throughout, log output is appended to `Preview.logs` (capped at `AdminSettings.logSizeLimitBytes`, oldest lines truncated) and **broadcast over WebSocket** to `PreviewDetail` via `subscribeToLogs`.
7. PR comment is edited in place through `gitea.ts` using `Preview.giteaCommentId`.

### Background loops (`cronWorker.ts`)
- **Every 30 min**: any `RUNNING` preview idle past `RepoConfig.inactivityHours` gets an `INACTIVITY_STOP` job (deduped).
- **Daily 03:00**: purge `STOPPED`/`FAILED` previews older than `AdminSettings.previewRetentionDays` (+ their jobs).

### On boot (`index.ts` → `.start()` callback)
`prisma.$connect()` → seed `AdminSettings` → `startJobWorker()` (resets stuck `RUNNING` jobs → `PENDING`) → `startCronWorkers()` → `runOrphanCleanup()` (terminate `pp:managed` EC2s with no active Preview).

### EC2 lifecycle (`ec2.ts` + `deploy.ts`)
- Per preview: ephemeral **RSA key pair** (`CreateKeyPair`, name `pp-preview-<id>`) → **security group** `pp-preview-<id>` (opens 22 + app port) → **launch** Ubuntu 22.04 (per-region AMI map) with a `UserData` bootstrap script → poll for `running` + public IP.
- Bootstrap installs only PP's base SSH/deploy dependencies. Repo-selected preinstall options add Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages over SSH.
- Every instance is tagged `pp:managed`, `pp:userId`, `pp:repo`, `pp:prNumber`, `pp:previewId`.
- On stop: terminate instance → delete key pair → delete SG (after ~30s delay for ENI detach) → null out `sshPrivateKey`/`sshKeyName`/`instanceId`.

### PP commands (in PR comments)
Parsed in `webhook.ts` (`handleIssueCommentEvent`). A comment is a command if its first line starts with `/pp `. Only the PR author or a repo owner/admin may run them; PP ignores its own comments. Commands: `/pp rebuild`, `/pp stop`, `/pp start`, `/pp logs`, `/pp ignore`.

---

## Conventions (match these)

### Backend HTTP handlers
- Handlers take a single `ctr: any` (rjweb context) and are registered in **`index.ts`** — every route is declared there, grouped by area. There is no file-based routing.
- **Return responses via `makeResponse({ ctr, content: { code, message?, data? } })`** (`lib/response.ts`). The envelope is:
  - success: `{ status: "OK", message?, data? }`
  - failure (code ≥ 400): `{ status: "FAILED", message }`
  - 5xx messages are replaced with a generic string automatically.
- Auth inside a handler: `const auth = ctr.getAuth?.();` then check `auth?.success` and use `auth.user`. Several route files define a local `requireAuth(ctr)` helper — reuse that pattern.
- Read JSON body with `await ctr.body()`; raw body (webhook HMAC) with `await ctr.$body().text()`.
- Route/path params: `ctr.params.get("name")`. Query/headers/cookies: `ctr.headers.get(...)`, `ctr.cookies.get(...)`.

### rjweb-server v9 gotchas (important)
- **`path.http(METHOD, fullPath, ...)` takes the FULL path**, not one relative to the `.path("/")` prefix. All routes are registered under `.path("/")` with absolute paths like `/api/repos/{owner}/{repo}/config`.
- **URL params use `{braces}`**, e.g. `/webhook/{userId}`, `/api/previews/{id}` — not `:colon`.
- **Static UI is registered LAST**, after all API routes, and `notFound` hand-serves `index.html` for non-API/non-asset paths (SPA fallback).
- Middleware order in the `Server` constructor matters: cors → main (OPTIONS) → authResolution → authEnforcement.

### Secrets & security
- **Anything sensitive is encrypted at rest**: Gitea PAT, AWS access key + secret, SSH private keys go through `encrypt()` before DB writes and `decrypt()` on read. Never store them in plaintext.
- **Never log or echo secrets.** `deploy.ts` deliberately masks the PAT out of git output and passes it via `git -c http.extraHeader` rather than in the clone URL. Preserve this.
- API responses **never return raw secrets** — they return `"****"`, booleans like `giteaPatSet`, or omit the field (see `auth.ts` `meHandler`, and `repoConfig` responses strip `giteaWebhookId`).
- Webhook signatures are compared with a constant-time equal. Keep it constant-time.

### Database
- Single Prisma client from `lib/db.ts` — import `{ prisma }`, don't `new PrismaClient()`.
- `AdminSettings` is a **singleton row `id = 1`** — always go through `getAdminSettings()` (creates it if missing).
- Enums live in the schema: `PreviewStatus`, `JobType`, `JobStatus`. Reuse them; don't invent string statuses.

### Logging
- `import { createLogger } from "../lib/logger"` and `const log = createLogger("COMPONENT")`. pino style: `log.info({ structuredFields }, "message")`.

### Frontend
- All API calls go through the `api` object in `services/api.ts` (adds `credentials: "include"`). Add new endpoints there rather than calling `fetch` directly in components.
- Live logs use `openLogsWs(previewId, onMessage)` (auto-picks ws/wss).
- Toasts via `react-toastify`; confirmations via `components/ConfirmDialog`. Theme via `useTheme` (localStorage).
- Setup gating: `ProtectedRoute` (requires auth) wraps `SetupCheck` (redirects to `/setup` until `user.setupComplete`).

### Style
- TypeScript, 2-space indent, double quotes, semicolons — match the surrounding file. Handlers are terminal (`return makeResponse(...)`), services throw and let the deploy engine/worker catch.

---

## Known sharp edges / decisions already made

Don't "fix" these without a reason — they were deliberate (see git history / project memory):

- **pnpm, not npm.** Ignore `package-lock.json`.
- **bcryptjs, not bcrypt** (Node 24).
- **`CreateKeyPair`, not `ImportKeyPair`** — avoids OpenSSH wire-format encoding issues; AWS returns the PEM we store encrypted.
- **SSH as `ubuntu`, not `root`.** Selected preinstall tools are installed from the deploy flow; Node commands are wrapped with `NVM_PREFIX` only when Node is selected. Recent commits specifically moved off root.
- **Prisma schema is at repo root** (`./prisma/schema.prisma`), so every command needs `--schema=../prisma/schema.prisma`.
- **rjweb full paths** in `path.http()` (see above).
- **Docker uses corepack + frozen lockfile** with the pinned pnpm to keep supply-chain policy deterministic; workspace-level install so `pnpm-workspace.yaml` settings apply.
- **Webhook returns 200 before processing** — all deploy work is async through the Job queue; keep it that way.

---

## Current work

Active branch: **`v2/full-spec-implementation`** (recent commits are `fix(v2): ...` hardening the EC2 bootstrap/SSH/deploy path). `main` is the stable branch and the usual PR base. `TODO.md` tracks UI/UX polish items still open (theming, repo search/filtering, config-as-its-own-page, favicon/SEO, tag-input bugs).

Gitea webhooks are registered for `pull_request`, **`pull_request_sync`** (pushes to a PR branch — a *separate* Gitea event; without it previews never redeploy on push), and `issue_comment`, and are named `PR Previews`. On boot, `services/webhookReconcile.ts` backfills these onto existing webhooks. Any PATCH to a Gitea webhook MUST re-send the full `events` array — Gitea resets an omitted/empty `events` to push-only. Also: Gitea's PR-sync action string is **`synchronized`** (past tense), not GitHub's `synchronize` — `webhook.ts` accepts both.

Before committing: only commit when asked; branch off `main` if you're on it; end commit messages with the required `Co-Authored-By` trailer.

---

## Where to look first

- **Changing deploy/build/run behavior on the EC2** → `services/deploy.ts` (+ `services/ssh.ts`, `services/ec2.ts`).
- **Webhook / PP command handling** → `routes/webhook.ts`.
- **Job scheduling / cancellation** → `workers/jobWorker.ts`.
- **Gitea API calls / PR comment format** → `services/gitea.ts`.
- **Adding an API endpoint** → write the handler in `routes/…`, then register it in `index.ts`.
- **Data model change** → `prisma/schema.prisma`, then `pnpm --filter pp-backend migrate`.
- **What the product is supposed to do** → `SPEC.md`.
