Files
pr-preview/SPEC.md
T

479 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.572)
- port (int, default 3000 — the port the app listens on inside the EC2 instance)
- envVars (JSON key-value)
- 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 `t2.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:ImportKeyPair",
"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 curl git unzip build-essential
# Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
# Docker Compose v2 (plugin)
apt-get install -y docker-compose-plugin
# NVM + Node LTS
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default lts/*
```
- 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. **Install apt packages** (if any): `sudo apt-get install -y <aptPackages>`. Only runs on first provision.
5. **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`
6. **Detect Node version:** Check for `/opt/app/.nvmrc`. If present: `nvm install && nvm use`. Otherwise: `nvm use default`.
7. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`.
8. **Setup commands** (if any): run each in order. Only runs on first provision.
9. Set Preview status to `BUILDING`.
10. **Build commands:** run each in order.
11. **Post-build commands** (if any): run each in order.
12. **Start:**
- Docker Compose: `cd /opt/app && 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`.
13. Set Preview status to `RUNNING`. Store `instanceIp`, `port`, `commitSha`, `lastActivityAt = now`.
14. **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 && 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-detect Node version** (`.nvmrc` 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 (t2.micro, t2.medium, t3.medium, t3.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.
- **Docker Compose toggle** — if on: 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).