# Operations Guide — OAuth Login & Token Setup

**Last updated:** 2026-04-11

This guide covers the steps to complete OAuth login setup and configure scoped tokens across your projects. **All steps below were verified end-to-end on 2026-04-11.**

---

## Prerequisites

- [x] Registry deployed at `https://api.packr.blueforge.studio` (Fly.io)
- [x] GitHub OAuth App configured (client ID: `Ov23ligJE4NpN7NysXzL`)
- [x] OAuth credentials set on Fly.io
- [x] `OAUTH_REDIRECT_BASE=https://packr.blueforge.studio` set on Fly.io
- [x] GitHub OAuth App callback URL = `https://packr.blueforge.studio/api/auth/callback/github`
- [x] `packr-cli` binary built at `bin/packr-cli`
- [x] Dashboard deployed at `https://packr.blueforge.studio` (Vercel)

---

## Step 1: Deploy Dashboard (Vercel) — ✅ DONE

The device flow verification page (`/cli/auth`) and the GitHub OAuth callback route (`/api/auth/callback/github`) are live.

To redeploy after site changes:
```bash
cd packages/site
vercel deploy --prod
vercel alias set <new-deployment-url> packr.blueforge.studio
```

**Required Vercel environment variables** (Production):
- `REGISTRY_URL` / `PACKR_REGISTRY_URL` — `https://api.packr.blueforge.studio`
- `INTERNAL_API_SECRET` — **must match** the same value on Fly.io
- `SESSION_SECRET` — must match Fly.io
- `GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET` — dashboard web login
- `NEXT_PUBLIC_SITE_URL` — `https://packr.blueforge.studio`
- `NEXT_PUBLIC_REGISTRY_URL` — `https://api.packr.blueforge.studio`

---

## Step 2: First OAuth Login

```bash
# From the packr-registry repo root
./bin/packr-cli login --provider github --registry https://api.packr.blueforge.studio
```

**What happens:**
1. CLI prints a verification URL and a code like `ABCD-EFGH`
2. Browser opens to `https://packr.blueforge.studio/cli/auth`
3. Enter the code, click Authorize
4. GitHub OAuth consent screen appears — authorize
5. Browser shows "CLI Authorized. You can close this tab."
6. CLI prints: `Logged in as <username> (maintainer) via github`

**What gets created:**
- `~/.packr/credentials.json` — token + metadata
- `~/.npmrc` — updated with `@blueforge-studio:registry=...` and auth token

**Verify:**
```bash
./bin/packr-cli whoami --registry https://api.packr.blueforge.studio
# Expected: "Logged in as <username> (maintainer) via github — expires 2026-05-10"
```

---

## Step 3: Test Package Install

```bash
# In any project directory
pnpm install @blueforge-studio/some-package
# Should resolve from Packr registry using the token in ~/.npmrc
```

---

## Step 4: Create Scoped CI Tokens — ✅ DONE

**Important:** `packr-cli token create` from your laptop uses the **admin API**, not the local DB. It only works if you're logged in (`packr-cli login`) and pass `--registry` or set `PACKR_REGISTRY`.

```bash
# Set once in your shell
export PACKR_REGISTRY=https://api.packr.blueforge.studio

# Read-only token (for install / most CI jobs)
./bin/packr-cli token create ci-read --role ci-readonly

# Publish token (scoped to @blueforge-studio, for release workflows)
./bin/packr-cli token create ci-publish --role ci-publish --scope @blueforge-studio
```

Each command prints the JWT to stdout (safe to pipe into `gh secret set`) and a confirmation to stderr.

### Setting GitHub Org Secrets — pitfalls to avoid

**Use stdin with `printf`**, not `--body` (avoids line-wrap corruption):

```bash
# Unset GITHUB_TOKEN if it's in your shell — otherwise gh ignores keychain auth
unset GITHUB_TOKEN

# Make sure gh has admin:org scope
gh auth refresh -h github.com -s admin:org

# Set the tokens
printf '%s' '<ci-read-token>' | gh secret set PACKR_TOKEN --org blueforge-studio
printf '%s' '<ci-publish-token>' | gh secret set PACKR_TOKEN_PUBLISH --org blueforge-studio
```

**Critical gotcha: repo-level secrets shadow org-level secrets.** If a repo already has a `PACKR_TOKEN` secret (leftover from a previous setup), the org-level one is ignored. Check with:

```bash
cd /path/to/repo && gh secret list        # shows REPO-level secrets
gh secret list --org blueforge-studio      # shows ORG-level secrets

# If there's a duplicate, delete the repo-level one:
gh secret delete PACKR_TOKEN
```

### `PACKR_TOKEN` vs `PACKR_TOKEN_PUBLISH`

The original design was: use `PACKR_TOKEN` (read-only) for `pnpm install`, `PACKR_TOKEN_PUBLISH` (publish + scoped) for releases.

**In practice this doesn't work with a single root `.npmrc`**, because:
- Root `.npmrc` references `${PACKR_TOKEN}` for the registry
- `pnpm install` AND `pnpm publish` both read this same `.npmrc`
- The workflow sets `NODE_AUTH_TOKEN=PACKR_TOKEN_PUBLISH` but `pnpm publish` prefers the `_authToken` line from `.npmrc` (which resolves to `PACKR_TOKEN`)

**Simpler setup that actually works** (what's in production):

Use **one** token for both reads and publishes — the `ci-publish` token with role `ci-publish`, scoped to `@blueforge-studio`. It has `["read", "publish"]` permissions, so installs work too. Set it as both `PACKR_TOKEN` and `PACKR_TOKEN_PUBLISH` org secrets:

```bash
printf '%s' '<ci-publish-token>' | gh secret set PACKR_TOKEN --org blueforge-studio
printf '%s' '<ci-publish-token>' | gh secret set PACKR_TOKEN_PUBLISH --org blueforge-studio
```

The scope restriction (`@blueforge-studio` only) is the authorization boundary. Since the token can't publish to any other scope, the read/write separation is less critical.

For true defense-in-depth, you'd need to split the `.npmrc` — one for install (with read token) and a temp-generated one for publish (with publish token). That's a future improvement.

---

## Step 5: Initialize Projects

Run in each repo that consumes Packr packages:

```bash
./bin/packr-cli init --scope @blueforge-studio --registry https://api.packr.blueforge.studio
```

This creates/updates:
- `.packr.json` — registry + scope config
- `.npmrc` — scope registry line + `${PACKR_TOKEN}` env var reference (safe to commit)

**Key repos to initialize:**
- `blueforge-org` (already has .npmrc — init will standardize it)
- `mailstack-os`
- Any other repo consuming `@blueforge-studio/*` packages

---

## Step 6: Update Dashboard Environment (Vercel)

The dashboard site needs the Packr OAuth App credentials for its existing web login flow.

In Vercel Dashboard → packr site → Settings → Environment Variables, set:

```
GITHUB_OAUTH_CLIENT_ID=Ov23ligJE4NpN7NysXzL
GITHUB_OAUTH_CLIENT_SECRET=<from GitHub → Settings → Developer settings → OAuth Apps → Packr → Client secrets>
```

> The client secret was previously written out in full here, so it is in this
> repo's git history and in every clone. Treat the value that was committed as
> burned: generate a new secret in the OAuth App and update Vercel. The client
> ID above is not a secret and needs no rotation.

Or via CLI:
```bash
cd packages/site
vercel env add GITHUB_OAUTH_CLIENT_ID
vercel env add GITHUB_OAUTH_CLIENT_SECRET
```

---

## Troubleshooting

### "authorization required" on device/authorize

The device flow endpoints may be hitting auth middleware. Verify the registry is running the latest code with the middleware exemption for `/-/v1/device/*` routes.

```bash
curl -s https://api.packr.blueforge.studio/health
# Should show uptime from latest deploy
```

### OAuth callback fails / redirect_uri mismatch

Check:
1. GitHub OAuth App **Authorization callback URL** is `https://packr.blueforge.studio/api/auth/callback/github` (NOT `/cli/auth/callback`)
2. Dashboard site has `/api/auth/callback/github/route.ts` deployed
3. `OAUTH_REDIRECT_BASE=https://packr.blueforge.studio` set on Fly.io
4. `INTERNAL_API_SECRET` matches between Fly.io registry and Vercel dashboard

### Workflow publishes to wrong token

If CI publishes are failing with 403 "insufficient permission" or "token not authorized for scope":
1. Check for repo-level `PACKR_TOKEN` shadowing the org-level one (see Step 4)
2. Verify the token in the secret actually has the permissions you expect — decode the JWT payload (middle base64 section) with `echo "eyJ..." | base64 -d`
3. Check Fly.io logs: `fly logs --no-tail | grep publish:`

### Token expired

```bash
./bin/packr-cli login --provider github --registry https://api.packr.blueforge.studio
# Re-authenticates and refreshes the token
```

### Keychain storage (optional)

To store tokens in macOS Keychain instead of plain file:

```bash
./bin/packr-cli config set credential-store keychain
./bin/packr-cli login --provider github --registry https://api.packr.blueforge.studio
```

---

## Current Fly.io Secrets

| Secret | Status |
|--------|--------|
| `JWT_SECRET` | Set |
| `SESSION_SECRET` | Set |
| `INTERNAL_API_SECRET` | Set |
| `OAUTH_GITHUB_CLIENT_ID` | Set (Ov23ligJE4NpN7NysXzL) |
| `OAUTH_GITHUB_CLIENT_SECRET` | Set |
| `ALLOW_REGISTRATION` | Set |
| `DASHBOARD_ORIGIN` | Set |
| `S3_*` / `BLOB_DRIVER` | Set (Backblaze B2) |
| `PROXY_*` | Set |

---

## Token Role Reference

| Role | Permissions | Use for |
|------|------------|---------|
| `ci-readonly` | `read` | CI install jobs, local dev installs |
| `ci-publish` | `read`, `publish` | CI publish workflows |
| `maintainer` | `read`, `publish`, `unpublish` | Developer accounts (default for OAuth login) |
| `admin` | all | Registry administration |

### Lifting a rate limit (super-admin escape hatch)

The login limiter is 10/min per IP. A `packr-cli login` device flow that polls
can spend that, and the operator is then locked out of the one action that
would let them fix anything — including whatever made them retry. Observed
2026-09-02.

```bash
curl -X POST https://api.packr.blueforge.studio/api/v1/admin/maintenance/rate-limit/reset \
  -H "X-Super-Admin-Token: $SUPER_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"limiter":"login","key":"203.0.113.7"}'
```

- `limiter` — `login`, `publish`, `admin`, `search`, or `all`. An unknown name
  is a 400, not a silent no-op: "cleared nothing" and "worked" must not look
  the same to someone acting in an incident.
- `key` — the client IP to clear. Omit it to clear every bucket on that
  limiter.

Gated by `X-Super-Admin-Token` or `X-Internal-Secret`, like the other
`maintenance/*` routes. Deliberately **not** behind a session: an operator
locked out by the login limiter has no session to present, and gating it behind
one would make it unreachable exactly when it is needed.

**Budgeted at 3 per rolling 24h** (`RATE_LIMIT_RESETS_PER_DAY`). A reset switch
with no budget is not a safety valve, it is the removal of the limit — anyone
holding the token could clear the login limiter on every request and brute-force
behind it. The window slides, so a budget spent at 23:00 refills through the
following day rather than at midnight.

Every reset is written to the audit log as `ratelimit.reset` with the limiter,
key and what was cleared. The budget keeps those entries rare enough to read.

The response reports what is left:

```json
{"cleared":{"login":1},"resetsRemaining":2,"budgetWindowHours":24}
```

> If sessions are being invalidated rather than rate-limited, the cause is
> different: `SESSION_SECRET` unset makes the server generate a random one at
> boot, so every restart silently logs everyone out while the CLI still reports
> a valid local session. Set `SESSION_SECRET` and `JWT_SECRET` persistently.
