Documentation

Operations

Running the registry: health, metrics, limits, maintenance.

Source: api.packr.blueforge.studio/-/docs/operations

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

  • Registry deployed at https://api.packr.blueforge.studio (Fly.io)
  • GitHub OAuth App configured (client ID: Ov23ligJE4NpN7NysXzL)
  • OAuth credentials set on Fly.io
  • OAUTH_REDIRECT_BASE=https://packr.blueforge.studio set on Fly.io
  • GitHub OAuth App callback URL = https://packr.blueforge.studio/api/auth/callback/github
  • packr-cli binary built at bin/packr-cli
  • 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:

cd packages/site
vercel deploy --prod
vercel alias set <new-deployment-url> packr.blueforge.studio

Required Vercel environment variables (Production):

  • REGISTRY_URL / PACKR_REGISTRY_URLhttps://api.packr.blueforge.studio
  • INTERNAL_API_SECRETmust 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_URLhttps://packr.blueforge.studio
  • NEXT_PUBLIC_REGISTRY_URLhttps://api.packr.blueforge.studio

Step 2: First OAuth Login

# 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:

./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

# 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.

# 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):

# 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:

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:

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:

./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:

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.

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

./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:

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

Current Fly.io Secrets

SecretStatus
JWT_SECRETSet
SESSION_SECRETSet
INTERNAL_API_SECRETSet
OAUTH_GITHUB_CLIENT_IDSet (Ov23ligJE4NpN7NysXzL)
OAUTH_GITHUB_CLIENT_SECRETSet
ALLOW_REGISTRATIONSet
DASHBOARD_ORIGINSet
S3_* / BLOB_DRIVERSet (Backblaze B2)
PROXY_*Set

Token Role Reference

RolePermissionsUse for
ci-readonlyreadCI install jobs, local dev installs
ci-publishread, publishCI publish workflows
maintainerread, publish, unpublishDeveloper accounts (default for OAuth login)
adminallRegistry 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.

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"}'
  • limiterlogin, 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:

{"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.