# Packr Registry — Deployment Guide

> **Partly stale — read `deployment-architecture.md` first.**
>
> The dashboard is **not** on Vercel. It moved to forge-control's `app-3` behind
> Caddy, so the Vercel steps and the environment table in Phase 1 describe a
> deployment that no longer exists. `blueforge.manifest.json` still says
> `"vercel": "packr-site"` for the same reason.
>
> The Fly.io sections for the registry are accurate. Phase 2's secret list is
> the authoritative one — note that `fly.toml`'s own quick-start comment used to
> omit `INTERNAL_API_SECRET`, which is how a registry that refuses every login
> becomes deployable from a copy-paste.

## Architecture

Packr has two deployable components:

```
┌─────────────────────┐     ┌─────────────────────┐
│   Dashboard Site    │     │   Registry Server    │
│   (Next.js 16)      │────>│   (Go binary)        │
│                     │     │                     │
│   Vercel / CDN      │     │   Fly.io / VPS      │
│   packr.blueforge.studio         │     │   api.packr.blueforge.studio     │
└─────────────────────┘     └─────────────────────┘
         │                           │
         ▼                           ▼
┌─────────────────────┐     ┌─────────────────────┐
│     Supabase        │     │     Supabase        │
│   (Postgres DB)     │     │   (S3 Storage)      │
└─────────────────────┘     └─────────────────────┘
```

---

## Phase 1: Vercel + Supabase (Current)

Deploy the dashboard site to Vercel. The Go registry runs locally or on any VPS for now.

### Dashboard Site → Vercel

#### 1. Connect to Vercel

```bash
cd apps/site
npx vercel link
```

Or connect via Vercel Dashboard → New Project → Import from GitHub.

**Settings:**
- Root Directory: `apps/site`
- Framework: Next.js
- Build Command: `npm run build`
- Output Directory: `.next`

#### 2. Environment Variables

Set these in Vercel Dashboard → Settings → Environment Variables:

| Variable | Value | Required |
|----------|-------|----------|
| `PACKR_REGISTRY_URL` | `http://your-registry:4873` | Yes |
| `NEXT_PUBLIC_SITE_URL` | `https://packr.blueforge.studio` | Yes |
| `NEXT_PUBLIC_REGISTRY_URL` | `http://your-registry:4873` | Yes |
| `SESSION_SECRET` | `openssl rand -hex 32` | Yes |
| `INTERNAL_API_SECRET` | `openssl rand -hex 32` (must match registry) | Yes |
| `GITHUB_OAUTH_CLIENT_ID` | From GitHub OAuth App | For GitHub login |
| `GITHUB_OAUTH_CLIENT_SECRET` | From GitHub OAuth App | For GitHub login |
| `STRIPE_SECRET_KEY` | From Stripe Dashboard | For billing |
| `STRIPE_WEBHOOK_SECRET` | From Stripe webhook config | For billing |
| `STRIPE_SOLO_PRICE_ID` | From `stripe-setup.sh` | For billing |
| `STRIPE_TEAM_PRICE_ID` | From `stripe-setup.sh` | For billing |
| `DATABASE_URL` | Supabase Postgres connection string | For Drizzle direct queries |
| `VAULT7_API_URL` | AI server URL | Optional |
| `VAULT7_API_KEY` | AI API key | Optional |

#### 3. Deploy

```bash
npx vercel --prod
# Or push to main — auto-deploys if GitHub integration is connected
```

### Registry Server → Local / VPS (Temporary)

Until Fly.io is set up, run the Go server on any machine:

```bash
# With Supabase
STORAGE_DRIVER=supabase \
SUPABASE_PROJECT_ID=your-id \
SUPABASE_DB_PASSWORD=your-pass \
BLOB_DRIVER=supabase \
S3_ACCESS_KEY=your-key \
S3_SECRET_KEY=your-secret \
JWT_SECRET=$(openssl rand -hex 32) \
SESSION_SECRET=$(openssl rand -hex 32) \
./bin/packr
```

Or via Docker:
```bash
docker run -p 4873:4873 \
  -e STORAGE_DRIVER=supabase \
  -e SUPABASE_PROJECT_ID=your-id \
  -e SUPABASE_DB_PASSWORD=your-pass \
  -e BLOB_DRIVER=supabase \
  -e S3_ACCESS_KEY=your-key \
  -e S3_SECRET_KEY=your-secret \
  -e JWT_SECRET=your-secret \
  -e SESSION_SECRET=your-secret \
  packr-registry
```

---

## Phase 2: Fly.io for Registry Server

When you're ready for a production registry endpoint:

### 1. Create fly.toml

```toml
# fly.toml
app = "packr-registry"
primary_region = "iad"

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 4873
  force_https = true

  [[http_service.checks]]
    path = "/health"
    interval = 10000
    timeout = 2000

[env]
  PORT = "4873"
  STORAGE_DRIVER = "supabase"
  BLOB_DRIVER = "supabase"
  LOG_LEVEL = "info"
```

### 2. Set Secrets

```bash
fly secrets set \
  SUPABASE_PROJECT_ID=your-id \
  SUPABASE_DB_PASSWORD=your-pass \
  S3_ACCESS_KEY=your-key \
  S3_SECRET_KEY=your-secret \
  JWT_SECRET=$(openssl rand -hex 32) \
  SESSION_SECRET=$(openssl rand -hex 32) \
  INTERNAL_API_SECRET=$(openssl rand -hex 32) \
  EMAIL_PROVIDER=resend \
  EMAIL_API_KEY=re_your_resend_api_key \
  EMAIL_FROM=noreply@yourdomain.com
```

---

## Email Configuration

Password reset emails are sent via a configurable email provider.

| Variable | Default | Description |
|----------|---------|-------------|
| `EMAIL_PROVIDER` | `log` | Email provider: `resend` or `log` |
| `EMAIL_API_KEY` | _(empty)_ | API key for the chosen provider (required for `resend`) |
| `EMAIL_FROM` | `noreply@packr.blueforge.studio` | Sender address shown in emails |

### Providers

**`log` (default)** — Logs email content to stdout instead of sending. Safe for development and staging.

**`resend`** — Sends transactional email via [Resend](https://resend.com). Requires an API key and a verified sender domain.

```bash
# Development — emails logged to stdout (default)
EMAIL_PROVIDER=log

# Production — send via Resend
EMAIL_PROVIDER=resend
EMAIL_API_KEY=re_your_resend_api_key
EMAIL_FROM=noreply@yourdomain.com
```

The `DASHBOARD_ORIGIN` variable is used to construct the reset link in the email (e.g., `https://packr.blueforge.studio/login/reset?token=...`). Ensure it is set correctly in production.

### 3. Deploy

```bash
fly launch        # First time
fly deploy        # Subsequent deploys
fly scale count 2 # Add a second instance for HA
```

### 4. Custom Domain

```bash
fly certs add api.packr.blueforge.studio
# Add CNAME: api.packr.blueforge.studio → packr-registry.fly.dev
```

### Cost Estimate

| Resource | Fly.io Cost |
|----------|------------|
| 1x shared-cpu-1x (256MB) | $1.94/mo |
| 2x shared-cpu-1x (HA) | $3.88/mo |
| Bandwidth (10GB/mo) | Free |
| Custom domain + SSL | Free |

Total: **~$4/mo** for a production registry with HA.

---

## Phase 3: Kubernetes (When Needed)

Move to K8s when you need:
- >2 replicas with auto-scaling
- Zero-downtime canary deployments
- Enterprise customers requiring "runs in our cluster"
- Traffic exceeding Fly.io limits

### Recommended Providers

| Provider | Min Cost | Best For |
|----------|----------|----------|
| DigitalOcean K8s | $24/mo | First K8s, simple |
| Hetzner + k3s | $15/mo | Cheapest, EU-based |
| GKE Autopilot | Pay-per-pod | Scale-to-zero |
| AWS EKS | $72/mo + nodes | Enterprise/AWS |

### Helm Chart (Future)

When the time comes, a Helm chart would include:
- Deployment (Go server, 2+ replicas)
- Service + Ingress (TLS termination)
- ConfigMap (non-secret env vars)
- Secret (JWT_SECRET, DB passwords)
- HPA (auto-scale on CPU/request count)
- PodDisruptionBudget (HA guarantees)

```bash
# Future usage:
helm install packr ./charts/packr \
  --set supabase.projectId=your-id \
  --set supabase.dbPassword=your-pass \
  --set image.tag=v1.0.0
```

---

## DNS Setup

| Record | Type | Value |
|--------|------|-------|
| `packr.blueforge.studio` | CNAME | `cname.vercel-dns.com` (Vercel) |
| `api.packr.blueforge.studio` | CNAME | `packr-registry.fly.dev` (Fly.io) |

---

## Environment Variable Reference

### Registry Server (Go)

| Variable | Required | Description |
|----------|----------|-------------|
| `PORT` | No (4873) | HTTP port |
| `STORAGE_DRIVER` | No (sqlite) | `sqlite`, `postgres`, `supabase` |
| `SUPABASE_PROJECT_ID` | If supabase | Supabase project ID |
| `SUPABASE_DB_PASSWORD` | If supabase | Database password |
| `BLOB_DRIVER` | No (file) | `file`, `supabase`, `backblaze`, `s3` |
| `S3_ACCESS_KEY` | If S3 | S3 access key |
| `S3_SECRET_KEY` | If S3 | S3 secret key |
| `JWT_SECRET` | **Yes** | JWT signing key |
| `SESSION_SECRET` | **Yes** | Session cookie key |
| `ALLOW_REGISTRATION` | No (false) | Allow public self-registration via `/-/v1/login` |
| `INTERNAL_API_SECRET` | **Yes (dashboard)** | Shared secret for admin session creation |
| `DASHBOARD_ORIGIN` | No | Allowed CORS origin for admin API |
| `MAX_TARBALL_SIZE` | No (5242880) | Max tarball upload in bytes (default 5 MB) |
| `DEFAULT_PACKAGE_VISIBILITY` | No (`public`) | Visibility given to a package on **first publish**: `public` or `private`. See the warning below before changing. |
| `RATE_LIMIT_LOGIN` | No (10) | Login rate limit/min |
| `RATE_LIMIT_PUBLISH` | No (30) | Publish rate limit/min |
| `LOG_LEVEL` | No (info) | `debug`, `info`, `warn`, `error` |

### Dashboard Site (Next.js)

| Variable | Required | Description |
|----------|----------|-------------|
| `PACKR_REGISTRY_URL` | **Yes** | Registry server URL |
| `NEXT_PUBLIC_SITE_URL` | **Yes** | This site's public URL |
| `SESSION_SECRET` | **Yes** | Session cookie key |
| `INTERNAL_API_SECRET` | **Yes** | Must match registry's `INTERNAL_API_SECRET` |
| `GITHUB_OAUTH_CLIENT_ID` | For OAuth | GitHub App client ID |
| `GITHUB_OAUTH_CLIENT_SECRET` | For OAuth | GitHub App secret |
| `STRIPE_SECRET_KEY` | For billing | Stripe secret key |
| `STRIPE_WEBHOOK_SECRET` | For billing | Stripe webhook secret |
| `STRIPE_SOLO_PRICE_ID` | For billing | Solo plan price ID |
| `STRIPE_TEAM_PRICE_ID` | For billing | Team plan price ID |
| `DATABASE_URL` | Optional | Direct Postgres (Drizzle) |

---

## OAuth Configuration

Packr supports OAuth login via GitHub, Google, GitLab, and any OIDC-compliant provider. Set the env vars for the providers you want to enable. See [`docs/06-oauth.md`](06-oauth.md) for full details.

### Auth Mode

| Variable | Default | Description |
|----------|---------|-------------|
| `PACKR_AUTH_MODE` | `hybrid` | `standalone` (password only), `forge-auth` (SSO only), `hybrid` (both) |

**All OAuth providers share one redirect base env var**:

```bash
OAUTH_REDIRECT_BASE=https://packr.example.com  # public dashboard URL
```

Each provider's callback URL is built as `{OAUTH_REDIRECT_BASE}/api/auth/callback/{provider}` — configure this on the provider's OAuth app settings.

### GitHub OAuth

```bash
docker run -p 4873:4873 \
  -e OAUTH_GITHUB_CLIENT_ID=Ov23liABCDEFGH123456 \
  -e OAUTH_GITHUB_CLIENT_SECRET=abc123def456... \
  -e OAUTH_REDIRECT_BASE=https://packr.example.com \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  packr-registry
```
Callback URL to configure in GitHub: `https://packr.example.com/api/auth/callback/github`

### Google OAuth

```bash
docker run -p 4873:4873 \
  -e OAUTH_GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com \
  -e OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-abc123... \
  -e OAUTH_REDIRECT_BASE=https://packr.example.com \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  packr-registry
```

### GitLab OAuth

```bash
docker run -p 4873:4873 \
  -e OAUTH_GITLAB_CLIENT_ID=abc123def456... \
  -e OAUTH_GITLAB_CLIENT_SECRET=gloas-abc123... \
  -e OAUTH_GITLAB_BASE_URL=https://gitlab.com \
  -e OAUTH_REDIRECT_BASE=https://packr.example.com \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  packr-registry
```

### Generic OIDC (Keycloak, Okta, Azure AD, Authentik)

```bash
docker run -p 4873:4873 \
  -e OAUTH_OIDC_CLIENT_ID=packr \
  -e OAUTH_OIDC_CLIENT_SECRET=super-secret \
  -e OAUTH_OIDC_ISSUER_URL=https://auth.example.com/realms/myrealm \
  -e OAUTH_REDIRECT_BASE=https://packr.example.com \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  packr-registry
```

### Forge-auth SSO

```bash
docker run -p 4873:4873 \
  -e PACKR_AUTH_MODE=forge-auth \
  -e FORGE_AUTH_URL=https://auth.blueforge.studio \
  -e FORGE_AUTH_CLIENT_ID=packr-registry \
  -e FORGE_AUTH_CLIENT_SECRET=forge-client-secret \
  -e OAUTH_REDIRECT_BASE=https://packr.example.com \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  packr-registry
```

### Multiple Providers + Password Login

```bash
docker run -p 4873:4873 \
  -e PACKR_AUTH_MODE=hybrid \
  -e OAUTH_GITHUB_CLIENT_ID=... \
  -e OAUTH_GITHUB_CLIENT_SECRET=... \
  -e OAUTH_GOOGLE_CLIENT_ID=... \
  -e OAUTH_GOOGLE_CLIENT_SECRET=... \
  -e OAUTH_REDIRECT_BASE=https://packr.example.com \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  packr-registry
```

### Credential Store (CLI)

| Variable | Default | Description |
|----------|---------|-------------|
| `PACKR_CREDENTIAL_STORE` | `file` | Where CLI stores tokens: `file`, `keychain`, `secret-service` |

This variable is used by the CLI, not the registry server.

---

## Security Configuration

### Generating Secrets

All secrets should be cryptographically random. Use `openssl rand -hex 32` for each:

```bash
# Registry (Go) — set on Fly.io
fly secrets set \
  JWT_SECRET=$(openssl rand -hex 32) \
  SESSION_SECRET=$(openssl rand -hex 32) \
  INTERNAL_API_SECRET=$(openssl rand -hex 32)

# Dashboard (Next.js) — set on Vercel
# INTERNAL_API_SECRET must be the SAME value as the registry's
vercel env add INTERNAL_API_SECRET production
```

### Key Security Variables

| Variable | Where | Notes |
|----------|-------|-------|
| `INTERNAL_API_SECRET` | Registry + Site | **Must match on both sides.** Used to authenticate dashboard → registry admin calls via `X-Internal-Secret` header. |
| `ALLOW_REGISTRATION` | Registry | Defaults to `false`. Set to `true` only if you want open sign-up. |
| `DASHBOARD_ORIGIN` | Registry | CORS origin allowed to call admin endpoints. Defaults to `https://packr.blueforge.studio`. Set to your dashboard URL. |
| `MAX_TARBALL_SIZE` | Registry | Defaults to 5 MB (5242880 bytes). Increase only if you publish large packages. |
| `DEFAULT_PACKAGE_VISIBILITY` | Registry | `public` (default) or `private`. Applies on first publish only. Read the warning under [Package visibility](#package-visibility) first — enabling it without working install credentials breaks consumers. |

### Password Requirements

All user passwords must meet these requirements:
- Minimum 10 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character

### Post-Deployment Checklist

After deploying with new secrets, complete these steps:

- [ ] Verify `INTERNAL_API_SECRET` is set on **both** Vercel (dashboard) and Fly.io (registry) with the **same value**
- [ ] Revoke any existing tokens that were issued before the security hardening (JWT algorithm and hash changes invalidate old tokens)
- [ ] Confirm `ALLOW_REGISTRATION=false` is set (or intentionally omitted — it defaults to false) unless open sign-up is desired
- [ ] Set `DASHBOARD_ORIGIN` to your actual dashboard URL if it differs from the default
- [ ] Test dashboard login end-to-end after deployment
- [ ] Rotate `JWT_SECRET` and `SESSION_SECRET` if they were previously auto-generated (random at startup means they change on every restart)


## Package visibility

Package reads are **anonymous by default**. This is deliberate: visibility was
designed as *opt-in privacy* so that adding the feature broke no existing
install (see `docs/superpowers/plans/2026-08-22-package-visibility.md`). The
`packages.visibility` column defaults to `public` and the migration backfilled
every existing row the same way.

The consequence is worth stating plainly, because the default is easy to leave
in place without realising its reach: unless you have opted individual packages
in, every package in your registry — metadata *and* tarballs — is readable
without a token:

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
  https://your-registry/@scope%2fpackage        # 200, no credentials
```

For a registry deployed as a private replacement for Artifactory or GitHub
Packages, that is often not what is wanted once the migration settles — the
default optimises for not breaking installs, not for confidentiality. Audit
what you are actually exposing with:

```sql
SELECT visibility, count(*) FROM packages GROUP BY visibility;
```

### Making packages private

Do this in order. The trap is that because reads have always been anonymous,
**consumers may never have needed a working token** — so their credentials can
be long expired without anyone noticing, and making packages private is the
moment that surfaces.

1. **Verify install credentials actually authenticate.** `GET /-/whoami`
   accepts stored tokens as well as JWTs and reports which path accepted it:

   ```bash
   curl -s -H "Authorization: Bearer $PACKR_TOKEN" https://your-registry/-/whoami
   # {"auth":"token","name":"ci-publish","permissions":["read","publish"], ...}
   ```

   `"auth":"token"` means it resolves against the tokens table — the path
   installs and publishes use. A `401` naming the signing key means the token
   predates the last `JWT_SECRET` rotation; re-issue it.

2. **Stop the exposure growing.** Set `DEFAULT_PACKAGE_VISIBILITY=private` so
   newly published packages start private. Existing packages are unaffected.

3. **Migrate existing packages in batches**, watching for install failures
   between batches:

   ```bash
   packr-cli visibility npm/@scope/package private
   ```

Existing packages are never changed implicitly, and a republish never re-opens
a package you made private — visibility is applied on first publish only.
