# 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://:`) 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-`) → **security group** `pp-preview-` (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`.