# PR Previews (PP)
> PP is a self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP automatically provisions an AWS EC2 instance, builds and runs the project, and comments a live preview URL back on the PR. Designed for teams and agents iterating on code without needing local environments.

## Purpose
- Provide a shareable live preview of any PR before it's merged.
- Remove the need for reviewers to pull and run code locally.
- Works well in agent-driven workflows where humans review from mobile or external environments.

---

## Tech Stack
- **Backend:** `rj-web` from `../shsf` (same setup pattern), logging + auth middleware + Prisma from `../shsf` adapted to this project's needs.
- **Frontend:** React + Vite + Tailwind CSS (dark/light mode toggle, responsive).
- **Database:** PostgreSQL via Prisma (schema at `./prisma/schema.prisma`).
- **CI/CD:** Reuse `../shsf` CI/CD pipeline; build and push Docker image to the same registry under `/pp-previews/core`.

## Project Structure
```
./backend/             # Node backend (rj-web)
./frontend/            # React + Vite frontend
./backend/prisma/schema.prisma # Prisma schema
./docker-compose.yml   # Compose + Dockerfile for PP itself
```

---

## Implementation Notes
You're building this from scratch. Refer to `../shsf` for patterns on rj-web setup, logging, auth middleware, and Prisma client initialization.

**Testing:** No automated tests for V1. Build to fully working state locally first. The first real test will be against a live Gitea instance with a real repo. Build as if it will be tested end-to-end immediately.

---

## Data Model (Prisma)

### User
PP account.
- id, username, passwordHash, isAdmin, isFounder, createdAt
- giteaUsername, giteaPAT (encrypted), giteaInstanceUrl (e.g. `https://gitea.example.com`, no trailing slash)
- awsAccessKeyId (encrypted), awsSecretAccessKey (encrypted), awsRegion

### RepoConfig
Per-repo settings, one per user per repo. A repo can only be configured by one user — first to save wins; others receive an error.
- id, userId, repoOwner, repoName
- isEnabled (bool)
- giteaWebhookId (string, stored after auto-registration so PP can delete/update it later)
- denyList (string[], Gitea usernames to skip — e.g. dependabot)
- instanceType (string, default from AdminSettings.defaultInstanceType)
- inactivityHours (float, default 12, range 0.5–72)
- port (int, default 3000 — the port the app listens on inside the EC2 instance)
- envVars (JSON key-value)
- preinstallTools (string[] — selected runtime/tooling bootstrap options: `docker`, `node`, `python`, `go`, `lua`, `build-essential`)
- nodeVersion (string, nullable — used when `node` is selected; default `lts/*`; `.nvmrc` in the repo wins during deploy)
- useDockerCompose (bool)
- composeFilePath (string, nullable — path within repo, default `docker-compose.yml`)
- aptPackages (string[])
- setupCommands (string[])
- buildCommands (string[])
- postBuildCommands (string[])
- runCommand (string, single — non-compose mode only)

### Preview
One record per PR. Persists across pushes; the EC2 instance is reused on each new push (only build + run steps re-run).
- id, repoConfigId, prNumber, prTitle
- commitSha (updated on each push)
- instanceId (AWS EC2 instance ID)
- instanceIp (public IP)
- port (copied from RepoConfig at deploy time)
- status (enum: `PROVISIONING` | `BUILDING` | `RUNNING` | `FAILED` | `STOPPED` | `IGNORED`)
- logs (text, appended each redeploy, capped at AdminSettings.logSizeLimitBytes — oldest lines truncated with a `--- logs truncated ---` marker)
- pid (int, nullable — non-compose mode only, the background process PID on the EC2 instance)
- sshPrivateKey (encrypted — ephemeral, generated per launch, deleted on instance termination)
- sshKeyName (string — AWS key pair name, for deletion on terminate)
- giteaCommentId (int, nullable — the Gitea comment ID of PP's status comment, for in-place edits)
- lastActivityAt (DateTime — updated on each push webhook AND each PR comment webhook)
- createdAt, updatedAt, stoppedAt

### Job
Durable async job queue for deployments and stops.
- id, previewId (nullable), type (enum: `DEPLOY` | `STOP` | `INACTIVITY_STOP`), status (enum: `PENDING` | `RUNNING` | `DONE` | `FAILED`)
- payload (JSON), error (text, nullable)
- createdAt, startedAt, finishedAt

### WebhookToken
Per-user HMAC secret for verifying Gitea webhook payloads.
- id, userId, token (random secret), createdAt

### NoConfigComment
Tracks which PRs have already received a "no previews configured" comment to avoid repeat spam.
- id, userId, repoOwner, repoName, prNumber, createdAt

### AdminSettings
Single row global config (seeded on first run).
- id
- defaultInstanceType (string, default `t3.medium`)
- maxConcurrentInstancesPerUser (int, default 5)
- logSizeLimitBytes (int, default 1048576 — 1MB)
- previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged)
- webhookRateLimitPerMinute (int, default 10 — per user)
- contactEmail (string — shown in privacy policy footer)

---

## Backend

### Auth
- Session-based auth (reuse shsf middleware).
- Registration endpoint is disabled — only admins can create users via the admin panel.
- First registered user automatically becomes admin and is flagged as `isFounder = true`. The founder cannot be demoted or deleted.

### Webhook Endpoint
`POST /webhook/:userId`

**Rate limiting:** Each user is limited to `AdminSettings.webhookRateLimitPerMinute` webhook events per minute. Excess requests receive `429 Too Many Requests` and are dropped (not queued).

**Signature verification:** Gitea sends `X-Gitea-Signature-256: sha256=<hmac>`. PP verifies using the user's `WebhookToken.token` before doing anything else. Invalid signature → `401`.

**Accepted Gitea events** (set via `X-Gitea-Event` header):

- `pull_request` with action `opened` → trigger new deploy
- `pull_request` with action `synchronize` → trigger redeploy on existing Preview
- `pull_request` with action `reopened` → same as `opened`
- `pull_request` with action `closed` → stop and terminate the preview
- `issue_comment` with action `created` → parse for `/pp` commands (see PP Commands section)

**Webhook handler returns `200 OK` immediately** after enqueueing a job. All processing is async.

**Activity tracking:** Update `Preview.lastActivityAt = now` on both `synchronize` and `issue_comment` events.

### PP Commands
PP listens for comments on PRs (via `issue_comment` webhook). A comment is a PP command if it starts with `/pp ` as the first line. Commands are only acted on if posted by: the PR author, or a Gitea user with `owner` or `admin` role on the repo (checked via `GET /api/v1/repos/{owner}/{repo}/teams` or collaborator endpoint).

| Command | Behaviour |
|---|---|
| `/pp rebuild` | Re-runs build + start steps on the existing EC2 instance (same as a new push but without a git pull). |
| `/pp stop` | Stops and terminates the preview instance. Sets status to `STOPPED`. |
| `/pp start` | If `STOPPED`: re-provisions a new EC2 and deploys. If `IGNORED`: un-ignores AND immediately triggers a fresh deploy. |
| `/pp logs` | PP posts a new comment with the last 50 lines of `Preview.logs` in a fenced code block. |
| `/pp ignore` | Sets `Preview.status = IGNORED`. Future pushes and commands (except `/pp start`) are silently ignored. If no Preview exists yet, creates one in `IGNORED` state so future pushes are skipped. |

PP does **not** react to its own comments (check that the commenter's username != `User.giteaUsername` for the configured user).

### AWS EC2 Management

**Required IAM permissions** (document in the setup wizard UI with a copy-pasteable JSON policy):
```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ec2:RunInstances",
      "ec2:TerminateInstances",
      "ec2:DescribeInstances",
      "ec2:CreateSecurityGroup",
      "ec2:DeleteSecurityGroup",
      "ec2:AuthorizeSecurityGroupIngress",
      "ec2:DescribeSecurityGroups",
      "ec2:CreateKeyPair",
      "ec2:DeleteKeyPair",
      "ec2:CreateTags",
      "sts:GetCallerIdentity"
    ],
    "Resource": "*"
  }]
}
```

**EC2 instance tagging** — every instance PP launches must be tagged:
```
pp:managed = true
pp:userId  = <User.id>
pp:repo    = <repoOwner>/<repoName>
pp:prNumber = <prNumber>
pp:previewId = <Preview.id>
```

**EC2 setup per preview:**
1. Generate an ephemeral RSA key pair. Store the private key encrypted in `Preview.sshPrivateKey`. Import the public key to AWS as `pp-preview-<previewId>` and store the name in `Preview.sshKeyName`.
2. Create a security group named `pp-preview-<previewId>` in the default VPC. Allow inbound: TCP 22 (SSH) and TCP `<port>` from `0.0.0.0/0`.
3. Launch instance:
   - AMI: Ubuntu 22.04 LTS (hardcode a per-region AMI map, or resolve via SSM `resolve:ssm:/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id`)
   - Instance type from `RepoConfig.instanceType`
   - Key pair: `pp-preview-<previewId>`
   - Security group: `pp-preview-<previewId>`
   - User data bootstrap script (runs once on first boot):
     ```bash
     #!/bin/bash
     apt-get update -y
     apt-get install -y ca-certificates curl git unzip
     ```
   - Repo-selected preinstall tools are installed over SSH after clone: Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages.
   - All instance tags as above.
4. Poll `DescribeInstances` until status = `running` and the instance has a public IP.
5. Poll TCP port 22 until it accepts connections (max 5 minutes, then fail).

**Orphan cleanup on startup:** On PP startup, call `DescribeInstances` with filter `tag:pp:managed=true` and compare against all non-`STOPPED` Preview records. Any instance with no matching Preview, or whose matching Preview is `STOPPED`, is terminated and its security group deleted.

**On instance termination:**
1. Delete the AWS key pair (`DeleteKeyPair` using `Preview.sshKeyName`).
2. Delete the security group `pp-preview-<previewId>`.
3. Terminate the instance.
4. Null out `Preview.sshPrivateKey` and `Preview.sshKeyName` in the DB.

### Deployment Flow

Each PR has exactly one Preview record. The EC2 instance is provisioned once and reused on subsequent pushes.

#### First push (PR opened / no existing Preview, or previous Preview is STOPPED)
Set Preview status to `PROVISIONING`.

1. Check `AdminSettings.maxConcurrentInstancesPerUser` — count PROVISIONING + BUILDING + RUNNING previews for this user. If at limit: post a Gitea comment on the PR explaining the limit is reached, mark job FAILED, stop.
2. **Provision EC2** — as described above.
3. SSH in. All subsequent commands run over SSH.
4. **Clone repo:**
   - Normal PR: `git clone https://<giteaUsername>:<PAT>@<giteaInstanceUrl>/<owner>/<repo>.git /opt/app`
   - Fork PR: clone from the fork's URL (`payload.pull_request.head.repo.clone_url`, injecting PAT auth).
   - `cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr`
5. **Install selected preinstall tools and apt packages:** Docker + Compose, Node (using `.nvmrc` if present, otherwise `RepoConfig.nodeVersion`), Python, Go, Lua, build tools, and custom `RepoConfig.aptPackages`.
6. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`.
7. **Setup commands** (if any): run each in order. Only runs on first provision.
8. Set Preview status to `BUILDING`.
9. **Build commands:** run each in order.
10. **Post-build commands** (if any): run each in order.
11. **Start:**
    - Docker Compose: `cd /opt/app && sudo docker compose -f <composeFilePath> up -d --build --force-recreate`
    - Non-compose: run `runCommand` in background via `nohup ... > /opt/app/pp.log 2>&1 &`, capture PID → save to `Preview.pid`.
12. Set Preview status to `RUNNING`. Store `instanceIp`, `port`, `commitSha`, `lastActivityAt = now`.
13. **Comment on PR** — post initial status comment (see PR Commenting section). Store the comment ID in `Preview.giteaCommentId`.

#### Subsequent push (synchronize / existing RUNNING or FAILED Preview)
1. SSH into existing instance using `Preview.instanceIp` and decrypted `Preview.sshPrivateKey`.
2. **Stop current process:**
   - Non-compose: `kill <Preview.pid>` (SIGTERM, wait 5s, SIGKILL if still running).
   - Docker Compose: `cd /opt/app && sudo docker compose -f <composeFilePath> down`.
3. **Pull latest:**
   ```bash
   cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD
   ```
4. **Re-run selected preinstall checks** (`.nvmrc` or runtime selections may have changed).
5. **Overwrite `.env`** — re-write from current `RepoConfig.envVars`.
6. Set Preview status to `BUILDING`. Append `\n--- Redeploy: <commitSha> ---\n` to `Preview.logs`.
7. **Build commands** — re-run each.
8. **Post-build commands** — re-run if defined.
9. **Start** — same as first provision start step. Update `Preview.pid`, `commitSha`, `lastActivityAt = now`. Set status to `RUNNING`.
10. **Update PR comment** — edit the existing comment in-place using `Preview.giteaCommentId` (see PR Commenting section).

**If any step fails:** Set status to `FAILED`. Append the error output to `Preview.logs`. Edit the PR comment to show failure with the last few log lines. Stop processing.

All log output is streamed to `Preview.logs` via WebSocket to the frontend (rj-web WebSocket support). Enforce log size cap: if `Preview.logs` exceeds `AdminSettings.logSizeLimitBytes`, drop oldest lines and prepend `--- logs truncated ---`.

### Inactivity Detection
- `Preview.lastActivityAt` is updated on each `synchronize` webhook AND each `issue_comment` webhook for the PR.
- A background cron job runs every 30 minutes. Any Preview where `lastActivityAt + inactivityHours < now` and `status = RUNNING` gets a `STOP` job enqueued.
- On stop: terminate the EC2 instance, post a final PR comment, set status to `STOPPED`.

### Job Queue
Webhook handler returns `200 OK` immediately and enqueues a Job. A background worker processes jobs:
- **Per-PR serialisation:** at most one active Job per `previewId` at a time.
- **New deploy cancels running deploy:** if a `DEPLOY` job is already `RUNNING` for a PR and a new `DEPLOY` arrives, the running job's SSH session is aborted and the new job takes over immediately.
- **On PP startup:** any Jobs left in `RUNNING` state (from a crash) are reset to `PENDING` and requeued.
- Job status (PENDING / RUNNING / DONE / FAILED) is visible on the preview detail page in the UI.

### Repo Discovery & Webhook Auto-Registration
- When the user saves their Gitea credentials, PP fetches all repos via `GET <giteaInstanceUrl>/api/v1/repos/search?limit=50&token=<PAT>` (paginate as needed).
- These appear in the Repo Configuration page for the user to enable.
- When the user **enables** a repo: PP calls `POST <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks` to register a webhook with:
  - `type: gitea`
  - `config.url`: `https://<PP_BASE_URL>/webhook/<userId>`
  - `config.secret`: `WebhookToken.token`
  - `config.content_type: json`
  - `events: ["pull_request", "issue_comment"]`
  - Store the returned hook ID in `RepoConfig.giteaWebhookId`.
- When the user **disables or deletes** a repo config: PP warns the user that the Gitea webhook will be deleted, and on confirmation calls `DELETE <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks/<giteaWebhookId>`.
- **Repos without a RepoConfig** that trigger a webhook: PP checks `NoConfigComment` — if no entry exists for `userId + repoOwner + repoName + prNumber`, PP posts one comment ("No previews configured for this repo...") and creates a `NoConfigComment` record.
- **Repo ownership conflict:** If a user tries to enable a repo that already has a `RepoConfig` owned by another user, return an error: "This repo is already configured by another user."

### Webhook Secret Rotation
When the user regenerates their webhook secret:
1. Generate a new `WebhookToken.token`.
2. For every `RepoConfig` belonging to this user that has a `giteaWebhookId`, call `PATCH <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks/<giteaWebhookId>` to update the secret.
3. Old secret is invalid immediately.

### AWS Credential Validation
When the user saves AWS credentials, PP calls `sts:GetCallerIdentity`. Show inline success ("Connected as arn:aws:iam::...") or error ("Invalid credentials").

### Gitea URL Validation
When the user saves their Gitea instance URL, PP calls `GET <giteaInstanceUrl>/api/v1/version`. Show inline success (Gitea version) or error.

### DB Cleanup (background cron, daily)
Delete Preview records where `status IN (STOPPED, FAILED)` and `stoppedAt < now - AdminSettings.previewRetentionDays days`. Also delete associated Job records.

---

## Admin Panel (`/admin`)

Accessible only to `isAdmin` users. Has its own UI section, separate from the regular user dashboard.

### User Management
- Create users (username + password).
- Edit username or password of any user.
- Delete users (cannot delete `isFounder` user).
- Promote users to admin (cannot demote `isFounder` user).
- Registration endpoint is always disabled — admin creates all accounts.

### Global Settings
- **Default EC2 instance type** — shown as the default in all repo configs.
- **Max concurrent EC2 instances per user** — when a user hits this, new deploys are rejected with a PR comment.
- **Webhook rate limit** — max events per user per minute (default 10).
- **Log size cap** — max bytes per Preview.logs before truncation (default 1MB).
- **Preview retention** — days before STOPPED/FAILED records are purged (default 30).
- **Contact email** — shown in the privacy policy footer.

### Admin Preview Dashboard
- Shows all previews across all users (separate from the user's own dashboard).
- Per preview: user, repo, PR number, status, instance IP, created at.
- Admin can stop any preview.

---

## User Panel

### Account Settings (`/settings`)
- **Username** — editable.
- **Password** — change password.
- **Gitea Instance URL** — e.g. `https://gitea.example.com`. Validated on save (`GET /api/v1/version`).
- **Gitea Username** — the account that PP will comment as.
- **Gitea PAT** — write-only display (show `••••••••` after save). Must have scopes: `repository` (read), `issue` (write), `admin:repo_hook` (for webhook management). Show a tooltip explaining required scopes with a link to Gitea's token settings page.
- **AWS Access Key ID** — write-only display after save. Validated on save via `sts:GetCallerIdentity`.
- **AWS Secret Access Key** — write-only display after save.
- **AWS Region** — dropdown of all AWS regions.
- **Webhook URL** — read-only. `https://<PP_BASE_URL>/webhook/<userId>`. This is the "Target URL" in Gitea's webhook settings. Copy button included.
- **Webhook Secret** — read-only (separate from the URL). This is the "Secret" field in Gitea's webhook settings. Regenerate button (rotates secret and auto-updates all registered hooks in Gitea with a confirmation prompt).

Note displayed in settings: _"PP will post PR comments as your Gitea account (@username). Make sure your PAT has 'issue' write permission."_

### Repo Configuration (`/repos`)
- List of repos fetched from Gitea (refresh button). Each repo shows a toggle to enable/disable previews.
- Enabling a repo auto-registers the webhook in Gitea. Disabling warns and then deletes it.
- A repo already configured by another PP user shows a lock icon and "Claimed by another user" — the toggle is disabled.
- Expanded per-repo config:
  - **Deny list** — tag input of Gitea usernames to skip.
  - **EC2 instance type** — searchable select (t3.medium, t3.large, t3a.medium, t3a.large, t4g.medium, t4g.large, m5.large, c5.large) + custom text input. Show estimated hourly cost next to each option.
  - **Inactivity kill timer** — slider 0.5h to 72h with labelled stops.
  - **App port** — number input (default 3000).
  - **Environment variables** — key/value editor, values masked. Add/remove rows.
  - **Preinstall options** — checkboxes for Docker + Compose, Node.js (with version), Python, Go, Lua, build tools, plus custom apt packages.
  - **Docker Compose toggle** — if on: require Docker, show compose file path input (default `docker-compose.yml`), hide manual command fields.
  - **Commands** (shown for both modes unless noted):
    - Apt packages — tag input (compose + non-compose)
    - Setup commands — ordered list, add/remove/reorder (compose + non-compose, run once on first provision)
    - Build commands — ordered list (non-compose only; compose uses `docker compose up --build`)
    - Post-build commands — ordered list, optional (non-compose only)
    - Run command — single text input (non-compose only)

### Preview Dashboard (`/` or `/previews`)
- Cards for all of this user's previews across repos.
- Card shows: repo name, PR number + title, status badge, preview URL (clickable when RUNNING), commit SHA, last updated.
- Click into a preview to see:
  - Full log viewer (WebSocket — live stream while active, monospace, auto-scroll, ANSI codes stripped).
  - Current job status and queue position if PENDING.
  - All previous redeploy separators visible inline in the log.
  - Status history (PROVISIONING → BUILDING → RUNNING etc. with timestamps).

---

## PR Commenting

PP comments using the configured user's Gitea PAT (comments appear as their account). There is no bot account.

**One comment per deploy cycle, edited in-place.** PP stores the comment ID in `Preview.giteaCommentId` and calls `PATCH /api/v1/repos/{owner}/{repo}/issues/comments/{id}` to update it. A new comment is posted fresh at the start of each new push/redeploy cycle (replacing `giteaCommentId` with the new comment's ID).

**Comment format — Markdown:**

```markdown
## 🚀 PR Preview — `<repoOwner>/<repoName>` #<prNumber>

**Status:** 🟡 Provisioning EC2 instance...
**Commit:** `<sha>`
**Updated:** <timestamp>

---
_Powered by [PR Previews](<PP_BASE_URL>)_
```

Status line updates as the deploy progresses:
- `🟡 Provisioning EC2 instance...`
- `🟡 Building... (EC2 ready at <ip>)`
- `🟢 Live at http://<ip>:<port>`
- `🔴 Failed — last log lines:`  _(followed by a fenced code block with the last 10 lines of logs)_
- `⚫ Stopped (inactivity timeout / PR closed / manual stop)`

On a new redeploy, PP posts a **new** comment (fresh cycle) and updates `giteaCommentId`.

---

## Setup Wizard (first login)

Shown to a user on first login if any of the required fields (Gitea URL, PAT, AWS credentials) are unset. Skippable at any point. Steps:

1. **Welcome** — brief explanation of what PP does.
2. **Gitea Connection** — Gitea instance URL + PAT. Validate on "Next". Show required PAT scopes with a link to Gitea settings.
3. **AWS Setup** — Access Key ID, Secret, Region. Show the required IAM policy JSON (copy button). Validate credentials on "Next".
4. **Your Webhook** — show the webhook URL and secret. Explain where to find these in Gitea's UI (per-repo or org-level webhook settings). Note: PP auto-registers per-repo webhooks when you enable a repo — this step is just for reference.
5. **Enable your first repo** — show the repo list, let the user enable one and fill in its config. On save, PP registers the webhook automatically.
6. **Done** — link to the preview dashboard.

---

## UI Design

- Dark/light mode toggle, persisted to localStorage.
- Animated route transitions and status badge updates.
- Status badges: ⚫ grey (stopped/ignored), 🟡 yellow (provisioning/building), 🟢 green (running), 🔴 red (failed).
- Log viewer: monospace font, auto-scroll to bottom, ANSI codes stripped, redeploy separator lines visually distinct (dimmed/italic).
- Responsive layout — usable on mobile for monitoring previews on the go.
- Toast notifications for save success/failure.
- Confirmation dialogs for: deleting a user, stopping a preview, regenerating webhook secret, disabling a repo (which deletes the Gitea webhook).
- Inline validation feedback on settings fields (Gitea URL, AWS credentials, PAT) that tests the connection on save.
- Contextual tooltips on instance type selector (cost estimates), PAT field (required scopes), inactivity timer (what happens when it fires).

---

## Docker / Deployment

`./docker-compose.yml` runs PP itself (not preview instances — those are EC2):
- `pp-backend` — Node backend.
- `pp-frontend` — Vite build served statically (or via backend).
- `pp-db` — PostgreSQL.
- Volume for Prisma migrations.

**Required environment variables:**
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `SESSION_SECRET` | Session signing secret |
| `PP_BASE_URL` | Public URL of this PP instance (used in webhook URLs and PR comment links) |
| `ENCRYPTION_KEY` | AES-256 key for encrypting PAT, AWS secrets, SSH private keys at rest |

---

## Security Notes
- Gitea PAT, AWS credentials, and SSH private keys are encrypted at rest with AES-256 using `ENCRYPTION_KEY`.
- Webhook HMAC-SHA256 signature is verified before any payload is processed. Invalid signatures return `401`.
- Webhook rate limiting (default 10 req/min/user) prevents floods.
- EC2 security groups are scoped per preview — created on provision, deleted on termination.
- SSH keys are ephemeral per launch. Private key is stored encrypted in the DB and nulled out on instance termination.
- Users can only see and manage their own previews, repos, and credentials.
- Admins can see all previews in a dedicated admin UI section.
- The repo config ownership model (first-to-save) prevents two users from deploying conflicting previews for the same repo.

---

## Privacy Policy
A generic self-hosted app privacy policy is included in the footer. It covers:
- What data PP stores (user credentials, Gitea PAT encrypted at rest, AWS credentials encrypted at rest, preview logs, PR metadata).
- That credentials are used only to operate the preview service and are never shared with third parties.
- That EC2 instances are launched in the user's own AWS account — PP does not have access to user data on those instances beyond what it deploys.
- Log retention policy (`previewRetentionDays`).
- How to contact the instance administrator (configurable contact email in admin settings).
- No analytics, no tracking, no external data sharing.

---

## Out of Scope for V1
- GitHub/GitLab support (Gitea only).
- Custom domains for previews (EC2 public IP + port only).
- Preview instance autoscaling.
- Billing or usage tracking.
- Automated tests (manual QA first, tests added in V2).
- Multi-port previews (one port per repo config only).
