# API Reference

Base URL: `https://api.packr.blueforge.studio` (production) or
`http://localhost:4873` (local).

**This is the guided tour, not the exhaustive list.** The complete endpoint list
is `GET /openapi.json`, generated from the registry's route table and checked
against it by a test, so it cannot drift from what the server actually serves.
This document explains the endpoints worth explaining and the workflows they
add up to.

That split exists because the previous arrangement failed twice over: a
hand-maintained spec claimed 16 endpoints against roughly 110 real ones and
reported version 0.5.0 while production ran 0.29.x, and this file documented a
dist-tag path the registry never served. Treat anything below that reads like a
complete inventory as a curated selection instead.

---

## Authentication

### JWT Bearer Token

Most write operations require a JWT token obtained via `POST /-/v1/login`:

```
Authorization: Bearer <token>
```

### npmrc Configuration

For CI, add to `~/.npmrc`:

```
//api.packr.blueforge.studio/:_authToken=<token>
@myorg:registry=https://api.packr.blueforge.studio
```

Or use environment variable `PACKR_TOKEN`.

---

## Endpoints

### Health Check

```
GET /health
```

Returns `{"status":"ok"}`. No authentication required.

---

### Ping (Build Identity)

```
GET /-/ping
```

Returns the registry's build identity and an absolute URL to the changelog. Useful for service discovery and "what version is running" dashboards. No authentication required.

**Response 200:**

```json
{
  "status": "ok",
  "version": "v0.8.0",
  "commit": "07ad5ba8",
  "build_date": "2026-06-27T14:23:11Z",
  "go_version": "go1.25.0",
  "region": "ams",
  "changelog_url": "https://packr.blueforge.studio/-/changelog"
}
```

`version`, `commit`, and `build_date` are injected at build time via `go build -ldflags "-X github.com/packr-registry/packr/internal/version.X=Y"`. They default to `dev` / `unknown` for local `go run` builds. `region` is read from `FLY_REGION` (empty on local).

---

### Changelog

```
GET /-/changelog
```

Returns the contents of `CHANGELOG.md` as `text/markdown`. Cached for 5 minutes. No authentication required.

The path is configurable via `CHANGELOG_FILE` env var; defaults to `/CHANGELOG.md` (bundled into the Docker image at build time).

**Response 503** if the file does not exist at any of the resolved paths (`$CHANGELOG_FILE`, `/CHANGELOG.md`, `/app/CHANGELOG.md`, `./CHANGELOG.md`).

---

### OpenAPI Spec

```
GET /openapi.json
```

Returns the OpenAPI 3.0 specification. No authentication required.

---

### Token Introspection (Whoami)

```
GET /-/whoami
Authorization: Bearer <token>
```

Returns the claims from a valid token. Use this to debug CI auth issues — verify the token has the expected scopes, permissions, and has not expired.

Accepts **both** kinds of credential, and reports which one was recognised via the
`auth` field:

| `auth` | Meaning |
|---|---|
| `jwt` | Signature verified against the current `JWT_SECRET`. Works everywhere, including the admin API. |
| `token` | Resolved by hash lookup in the `tokens` table — the path publish and install use. Works for publishing; **will not** work on `/api/v1/admin/*`. |

This is the supported way to check whether a CI token still works without
publishing something. A `401` that names the signing key means the token was issued
before the last `JWT_SECRET` rotation: it will still publish but cannot use the
admin API, and should be re-issued with `packr-cli login`.

**Response** (200 OK):
```json
{
  "user_id": "default:blueforge-ci",
  "org_id": "default",
  "scopes": ["@blueforge-studio"],
  "pkg_limit": 100,
  "permissions": ["read", "publish"],
  "issued_at": "2026-05-01T10:00:00Z",
  "expires_at": "2026-06-01T10:00:00Z"
}
```

- `401` — Missing token
- `401` — Invalid or expired token

---

### Login / Get Token

```
POST /-/v1/login
Content-Type: application/json

{
  "name": "username",
  "password": "Password@123"
}
```

**Response** (200 OK):
```json
{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}
```

**Errors:**
- `401` — Invalid credentials
- `429` — Rate limit exceeded (10 req/min/IP)

**Notes:**
- If `ALLOW_REGISTRATION=false` (default), user must already exist. Create users via CLI: `packr-cli user create <name> --password <pass>`
- If `ALLOW_REGISTRATION=true`, new users are auto-created on first login

---

### Publish Package

```
PUT /[:scope/]:pkg
Authorization: Bearer <token>
Content-Type: application/octet-stream (npm CLI format)

<metadata JSON>\n\n<tarball gzipped>
```

Or plain JSON (curl):

```
PUT /[:scope/]:pkg
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "dependencies": {...}
}
```

**Response** (200 OK):
```json
{"ok": "true"}
```

**Errors:**
- `400` — Invalid package name, version, or tarball
- `401` — Missing or invalid token
- `403` — Not authorized to publish to this package
- `413` — Tarball too large (default: 5 MB, configurable via `MAX_TARBALL_SIZE`)
- `429` — Rate limit exceeded (30 req/min/IP)

**npm CLI Example:**
```bash
npm publish --registry https://api.packr.blueforge.studio
```

---

### Get Package Metadata

```
GET /[:scope/]:pkg
```

**Response** (200 OK):
```json
{
  "name": "@scope/pkg",
  "dist-tags": {"latest": "1.0.0"},
  "versions": {
    "1.0.0": {
      "name": "@scope/pkg",
      "version": "1.0.0",
      "dist": {"tarball": "https://api.packr.blueforge.studio/@scope/pkg/-/package/1.0.0.tgz"}
    }
  },
  "time": {"modified": "2026-04-09T12:00:00Z"}
}
```

**Errors:**
- `404` — Package not found

---

### Get Specific Version

```
GET /[:scope/]:pkg/:version
```

**Response** (200 OK): Version metadata object (same format as version in package metadata).

**Errors:**
- `404` — Package or version not found

---

### Download Tarball

The registry accepts multiple tarball URL formats to interoperate with pnpm,
Yarn, npm, and any client that resolves dependencies through the npm registry
protocol.

| Format | URL pattern | Example |
|--------|-------------|---------|
| Packr scoped | `/{scope}/{name}/-/package/{filename}.tgz` | `/@blueforge-studio/foo/-/package/foo-1.0.0.tgz` |
| Packr unscoped | `/{name}/-/package/{filename}.tgz` | `/foo/-/package/foo-1.0.0.tgz` |
| npm registry scoped | `/@scope/name/-/name-version.tgz` | `/@blueforge-studio/foo/-/foo-1.0.0.tgz` |
| npm top-level | `/-/name-version.tgz` | `/-/foo-1.0.0.tgz` (pnpm lockfile) |

For the npm top-level format, the registry looks up the package by name across
all scopes to resolve the canonical scope. When `TARBALL_DIRECT_DOWNLOAD=true`,
the registry returns a 307 redirect to a presigned S3/B2 URL instead of proxying
the bytes (disabled by default — Docker build contexts and firewalled clients
cannot always reach S3 endpoints directly).

```
GET /[:scope/]:pkg/-/package/:version.tgz
```

**Response** (200 OK): `.tgz` tarball file stream.

**Errors:**
- `404` — Package or version not found (or proxy upstream also 404)
- `502` — Storage backend failure (`STORAGE_ERROR`)
- `504` — Upstream proxy timed out (`GATEWAY_TIMEOUT`)

---

### Unpublish Package

```
DELETE /-/v1/unpublish
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "version": "1.0.0"
}
```

**Constraints:**
- Only the package owner can unpublish
- Version must have been published within the last 72 hours
- If unpublishing the last version, the entire package is deleted

**Response** (200 OK):
```json
{
  "ok": true,
  "name": "@scope/pkg",
  "version": "1.0.0"
}
```

**Errors:**
- `400` — Missing name or version, or outside 72-hour window
- `401` — Missing or invalid token
- `403` — Not the package owner
- `404` — Package or version not found

---

### Deprecate Package

```
PUT /-/v1/deprecate
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "message": "Use @scope/pkg-v2 instead"
}
```

**Response** (200 OK):
```json
{"ok": true}
```

**Errors:**
- `400` — Missing required fields
- `401` — Missing or invalid token
- `403` — Not the package owner
- `404` — Package not found

---

### Dist-Tags

These are the paths `npm dist-tag` actually calls. A scoped package is one
percent-encoded segment: `@acme%2Fwidget`.

```
GET    /-/package/:pkg/dist-tags
PUT    /-/package/:pkg/dist-tags/:tag     body: "1.2.3"  (a JSON string)
DELETE /-/package/:pkg/dist-tags/:tag
```

**Response** (200 OK) — always the package's full tag map:
```json
{"latest": "1.0.0", "next": "2.0.0-beta.1"}
```

Writes require package **ownership**, not merely a valid token: moving a tag
decides what every `npm install` of the package resolves to.

Three rules the registry enforces:

- A tag may not point at an unpublished version. Stored, it would surface as a
  404 on some later install, far from the mistake that caused it.
- A tag name that parses as a version is refused, because `pkg@1.0.0` cannot
  distinguish a tag from a version.
- `latest` cannot be removed, only repointed. Removing it leaves a plain
  `npm install` with nothing to resolve.

> Earlier versions of this document described
> `GET /:scope/:pkg/-/package/dist-tags`. That path was never served, and
> `npm dist-tag` answered 404 against this registry until it was implemented
> on the paths above.

---

### Search Packages

```
GET /-/search/:scope?q=:query
```

**Parameters:**
- `q` — Search query (matches package name)
- `sort` — Sort by `name` or `updated` (default: `name`)

**Response** (200 OK):
```json
{
  "packages": [
    {
      "name": "@scope/pkg",
      "version": "1.0.0",
      "description": "A package",
      "updatedAt": "2026-04-09T12:00:00Z"
    }
  ],
  "total": 1
}
```

---

### Public Package Browse
`GET /api/v1/public/packages?ecosystem=&scope=&limit=&offset=`
No auth. Returns `{packages, total, limit, offset}` — only packages whose
visibility is `public`.

### Cross-Ecosystem Search

```
GET /api/v1/search?q=<query>&ecosystem=<eco>&limit=<n>&offset=<n>
```

No auth (optional credential — surfaces private packages the caller may read). Searches every ecosystem
in the unified `packages` table by name, scope, and description.

- `q` (required) — substring query; `%`/`_` are matched literally.
- `ecosystem` (optional) — one of `npm|go|cargo|jvm|maven|pypi|nuget`; omitted = all.
- `limit`/`offset` — default `20`/`0`; `limit` capped at 100.

Anonymous calls see only public packages; a credential additionally surfaces
private packages the caller may read (never 403s).

**Response** (200 OK):
```json
{
  "objects": [
    {
      "package": {
        "ecosystem": "npm",
        "scope": "@acme",
        "name": "foo",
        "version": "1.2.3",
        "description": "Does the thing",
        "visibility": "public"
      },
      "score": { "exact": true, "prefix": false, "foundIn": ["name"] }
    }
  ],
  "total": 7,
  "limit": 20,
  "offset": 0
}
```

### Set Package Visibility (admin)
`POST /api/v1/admin/packages/visibility`
Body: `{"ecosystem":"npm","scope":"@acme","name":"my-pkg","visibility":"private"}`
Auth: admin session (cookie) or JWT bearer — the caller must own the package
(or send `X-Internal-Secret`). Valid visibility values: `public`, `private`.
404 if the package does not exist.

---

### Password Reset Request

```
POST /-/v1/password-reset
Content-Type: application/json

{
  "name": "username"
}
```

**Response** (200 OK):
```json
{"ok": true}
```

**Errors:**
- `429` — Rate limit exceeded (10 req/min/IP)

**Notes:** Sends a reset email if email is configured. Otherwise returns immediately.

---

### Password Reset Confirm

```
POST /-/v1/password-reset/confirm
Content-Type: application/json

{
  "token": "reset-token-from-email",
  "password": "NewPassword@123"
}
```

**Response** (200 OK):
```json
{"ok": true}
```

**Errors:**
- `400` — Invalid or expired token
- `400` — Password does not meet requirements (min 10 chars, upper+lower+digit+special)

---

## OAuth Device Flow

The OAuth device flow (RFC 8628) enables headless and CLI login without a browser on the authenticating machine. See [`docs/06-oauth.md`](06-oauth.md) for architecture details.

### List Available OAuth Providers

```
GET /-/v1/device/authorize
```

Returns the list of configured OAuth providers. No request body required.

**Response** (200 OK):
```json
{
  "providers": ["github", "google", "gitlab"]
}
```

---

### Start Device Flow

```
POST /-/v1/device/authorize
Content-Type: application/json

{
  "provider": "github"
}
```

**Response** (200 OK):
```json
{
  "device_code": "d1e2v3i4c5e6c7o8d9e0",
  "user_code": "ABCD-1234",
  "verification_uri": "https://api.packr.blueforge.studio/verify",
  "verification_uri_complete": "https://api.packr.blueforge.studio/verify?code=ABCD-1234",
  "expires_in": 900,
  "interval": 5
}
```

**Errors:**
- `400` — Missing or unsupported provider
- `429` — Rate limit exceeded (5 req/min/IP)

The user must open `verification_uri_complete` (or visit `verification_uri` and enter `user_code`) within `expires_in` seconds (15 minutes).

---

### Poll for Access Token

```
POST /-/v1/device/token
Content-Type: application/json

{
  "device_code": "d1e2v3i4c5e6c7o8d9e0"
}
```

Poll this endpoint at the `interval` (default 5 seconds) returned by the authorize call.

**Response** (200 OK — user completed login):
```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 2592000
}
```

**Polling States** (400 errors — keep polling unless `expired_token`):

| Error code | Meaning |
|------------|---------|
| `authorization_pending` | User hasn't completed login yet. Keep polling. |
| `slow_down` | Polling too fast. Increase interval by 5s. |
| `expired_token` | Code expired (15 min). Start a new flow. |
| `access_denied` | User denied the request. Stop polling. |

---

## AI Agent Endpoints

These endpoints honour package visibility exactly as the protocol routes do.
No credential is required, and an anonymous request is answered with the public
packages only: a private package answers `404`, and `/agent/search` omits it.
Send a token with read authorization (`Authorization: Bearer <token>`) to see
private packages — `packr-cli agent-*` uses your stored login, and the SDK and
MCP server send `PACKR_TOKEN` when it is set.

### Package Info

```
GET /agent/:pkg
```

Returns AI-optimized package info with quality score and download count.

**Response** (200 OK):
```json
{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "quality": 85,
  "downloads": 1234,
  "description": "A package",
  "dependencies": ["dep-a", "dep-b"]
}
```

---

### Capability Search

```
GET /agent/search?q=:capability
```

Search packages by capability keyword.

**Response** (200 OK):
```json
{
  "packages": [...],
  "query": "auth",
  "count": 5
}
```

---

### Compare Packages

```
GET /agent/compare?packages=:pkg1,:pkg2
```

Side-by-side comparison with recommendation.

**Response** (200 OK):
```json
{
  "packages": [
    {"name": "@scope/pkg-a", "quality": 85, "downloads": 500},
    {"name": "@scope/pkg-b", "quality": 72, "downloads": 1200}
  ],
  "recommended": "@scope/pkg-a",
  "count": 2
}
```

---

### Dependency Graph

```
GET /agent/:pkg/deps
```

Returns the dependency graph for a package.

**Response** (200 OK):
```json
{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "dependencies": [
    {"name": "@scope/dep-a", "version": "^1.0.0"},
    {"name": "@scope/dep-b", "version": "^2.0.0"}
  ],
  "dependents": [
    {"name": "@scope/other-pkg", "version": "^1.0.0"}
  ]
}
```

---

## Admin API

Admin endpoints require either:
- `X-Internal-Secret: <INTERNAL_API_SECRET>` header (for Next.js dashboard → Go backend)
- Session cookie (for dashboard browser sessions)

### Create Admin Session

```
POST /api/v1/admin/sessions
X-Internal-Secret: <secret>
Content-Type: application/json

{
  "name": "username",
  "password": "Password@123"
}
```

**Response** (200 OK):
```json
{"ok": true}
```

Sets `packr_session` cookie.

---

### Create Token

```
POST /api/v1/admin/tokens
Cookie: packr_session=<session>
Content-Type: application/json

{
  "name": "ci-publish-token",
  "role": "ci-publish",
  "scopes": ["@blueforge-studio"],
  "expires_in_days": 30
}
```

**Role values:** `ci-readonly`, `ci-publish`, `maintainer`, `admin`

**Response** (201 Created):
```json
{
  "id": 42,
  "name": "ci-publish-token",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "role": "ci-publish",
  "permissions": ["read", "publish"],
  "scopes": ["@blueforge-studio"],
  "expires_at": "2026-05-10T00:00:00Z",
  "created_at": "2026-04-10T00:00:00Z"
}
```

The raw `token` value is only returned at creation time. Store it securely immediately.

**Errors:**
- `400` — Invalid role or missing name
- `401` — Not authenticated

---

### List Tokens

```
GET /api/v1/admin/tokens
Cookie: packr_session=<session>
```

**Response** (200 OK):
```json
{
  "tokens": [
    {
      "id": 1,
      "name": "github-actions",
      "role": "ci-publish",
      "permissions": ["read", "publish"],
      "scopes": ["@blueforge-studio"],
      "created_at": "2026-04-09T12:00:00Z",
      "last_used_at": "2026-04-09T14:00:00Z",
      "expires_at": "2026-05-09T12:00:00Z"
    }
  ]
}
```

---

### Revoke Token

```
DELETE /api/v1/admin/users/me/tokens/:id
Cookie: packr_session=<session>
```

Deletes a token you own. Requires a session or a JWT whose signature verifies
against the **current** `JWT_SECRET` — see [Revoke Token (operator secret)](#revoke-token-operator-secret)
when that is not available.

**Response** (200 OK):
```json
{"ok": true}
```

---

### Revoke Token (operator secret)

```
POST /api/v1/admin/tokens/revoke
X-Super-Admin-Token: <token>      (or X-Internal-Secret: <secret>)
```

Gated by a shared secret rather than by a session, because the case it exists for is
a **leaked** credential: whoever is revoking may not be able to authenticate as its
owner, and the leaked token must obviously not be an acceptable credential for
revoking itself.

Accepts exactly one of:

| Field | Effect |
|---|---|
| `jti` | Adds that token id to the revocation denylist. |
| `token` | The leaked value itself; its `jti` is read from the claims (signature not required, so a token signed with a rotated key can still be revoked). |
| `id` | Deletes that row from the `tokens` table. |

Use `id` for a credential issued before `jti` claims existed. It has no `jti` to
deny, and it authenticates by sha256 hash lookup rather than by signature, so
deleting the row is what actually stops it. Before this existed the only remedy was
rotating `JWT_SECRET`, which invalidates every other token at the same time.

**Request:**
```json
{"id": 19, "reason": "exposed in a log"}
```

**Response** (200 OK):
```json
{"revoked": "19", "reason": "exposed in a log"}
```

- `400` — none of `jti`, `token`, or `id` supplied, or the row does not exist
- `401` — missing or incorrect shared secret

```bash
packr-cli token revoke 19 --super-admin-token <token> --reason "exposed in a log"
```

---

### List Revoked Tokens

```
GET /api/v1/admin/tokens/revoked
X-Super-Admin-Token: <token>
```

Returns the `jti` denylist. Note that tokens revoked by row `id` are **deleted**
rather than denylisted, so they do not appear here — they are simply gone from the
`tokens` table.

---

### List Webhooks

```
GET /api/v1/admin/webhooks
Cookie: packr_session=<session>
```

**Response** (200 OK):
```json
{
  "webhooks": [
    {
      "id": 1,
      "url": "https://example.com/webhook",
      "events": ["package.published", "token.created"],
      "created_at": "2026-04-09T12:00:00Z"
    }
  ]
}
```

---

### Create Webhook

```
POST /api/v1/admin/webhooks
Cookie: packr_session=<session>
Content-Type: application/json

{
  "url": "https://example.com/webhook",
  "secret": "webhook-secret",
  "events": ["package.published"]
}
```

**Response** (201 Created):
```json
{
  "id": 1,
  "url": "https://example.com/webhook",
  "secret": "webhook-secret",
  "events": ["package.published"]
}
```

---

### Delete Webhook

```
DELETE /api/v1/admin/webhooks/:id
Cookie: packr_session=<session>
```

**Response** (200 OK):
```json
{"ok": true}
```

---

### Webhook Deliveries

Webhook delivery attempts are now logged and retried automatically with exponential backoff.

Retry schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours (max 5 attempts).

Failed deliveries are retried automatically. After 5 failures, the delivery is marked as permanently failed.

---

### Audit Log

```
GET /api/v1/admin/audit
Cookie: packr_session=<session>
```

**Query parameters:** `limit` (default 50, max 200), `offset` (default 0)

**Response** (200 OK):
```json
{
  "events": [
    {
      "id": 1,
      "action": "package.published",
      "actor_id": 42,
      "actor_name": "kmandrup",
      "resource_type": "package",
      "resource_id": "1",
      "resource_name": "@blueforge-studio/test-pkg",
      "details": "version 1.0.0",
      "created_at": "2026-04-10T12:00:00Z"
    }
  ],
  "limit": 50,
  "offset": 0
}
```

**Event types:** `package.published`, `package.unpublished`, `package.deprecated`, `token.created`, `token.revoked`, `user.created`, `profile.updated`, `user.login`

---

### Organizations

```
GET /api/v1/admin/orgs
Cookie: packr_session=<session>
```

List all organizations.

---

```
POST /api/v1/admin/orgs
Cookie: packr_session=<session>
Content-Type: application/json

{
  "slug": "blueforge-studio",
  "display_name": "BlueForge Studio"
}
```

Create organization. The creator automatically becomes an admin member.

**Response** (201 Created):
```json
{"ok": true}
```

---

```
GET /api/v1/admin/orgs/:slug/members
Cookie: packr_session=<session>
```

List members of an organization.

---

```
POST /api/v1/admin/orgs/:slug/members
Cookie: packr_session=<session>
Content-Type: application/json

{
  "user_id": 42,
  "role": "maintainer"
}
```

Add a member to an organization. **Valid roles:** `admin`, `maintainer`, `reader`

---

```
DELETE /api/v1/admin/orgs/:slug/members/:userId
Cookie: packr_session=<session>
```

Remove a member from an organization.

**Response** (200 OK):
```json
{"ok": true}
```

---

```
GET /api/v1/admin/users/me/orgs
Cookie: packr_session=<session>
```

List the current user's organizations.

---

### Org Packages (Public)

```
GET /api/v1/orgs/:slug/packages
```

List all packages belonging to an organization. No authentication required.

**Query parameters:** `limit` (default 20, max 100), `offset` (default 0)

**Response** (200 OK):
```json
{
  "packages": [
    {
      "name": "@blueforge-studio/forge-ui",
      "version": "1.2.0",
      "description": "BlueForge UI component library",
      "updatedAt": "2026-04-10T12:00:00Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}
```

**Errors:**
- `404` — Organization not found

---

### Package Transfer

```
POST /api/v1/admin/packages/transfer
Cookie: packr_session=<session>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "to_user": "new-owner-username"
}
```

Transfer ownership of a package to another user. Only the current package owner or an admin may transfer.

**Response** (200 OK):
```json
{"ok": true}
```

**Errors:**
- `400` — Missing name or to_user
- `401` — Not authenticated
- `403` — Not the package owner or admin
- `404` — Package or target user not found

---

### Prune Versions Before

```
POST /api/v1/admin/packages/:scope/:name/prune-before
Cookie: packr_session=<session>
Content-Type: application/json

{
  "before": "1.0.0"
}
```

Delete all versions of a package with semver strictly less than `before`. Useful for
discarding old CI snapshots after a stable release. Only the package owner or an admin
may prune. Versions that fail semver parsing are skipped (not deleted).

`X-Internal-Secret` header can be used by server-side automation to bypass the
ownership check.

**Response** (200 OK):
```json
{
  "deleted": 12,
  "before": "1.0.0"
}
```

**Errors:**
- `400` — Missing `before`, or invalid semver
- `401` — Not authenticated
- `403` — Not the package owner or admin
- `404` — Package not found

---

### Prune Keep Latest

```
POST /api/v1/admin/packages/:scope/:name/prune-keep-latest
Cookie: packr_session=<session>
Content-Type: application/json

{
  "keep": 5
}
```

Delete all versions of a package except the `keep` most recent semver releases.
Useful for capping package version count and storage. Sorted by semver, not by
publish date — pre-release versions are sorted alongside stable releases.

`X-Internal-Secret` header can be used by server-side automation to bypass the
ownership check.

**Response** (200 OK):
```json
{
  "deleted": 7,
  "kept": 5
}
```

**Errors:**
- `400` — Missing or invalid `keep` (must be ≥ 0)
- `401` — Not authenticated
- `403` — Not the package owner or admin
- `404` — Package not found

---

### Usage (Raw)

```
GET /api/v1/admin/usage
Cookie: packr_session=<session>
```

Returns per-package download counts for the authenticated user's packages.

**Query parameters:** `limit` (default 50, max 200), `offset` (default 0)

**Response** (200 OK):
```json
{
  "usage": [
    {
      "package": "@scope/pkg",
      "version": "1.0.0",
      "downloads": 42,
      "period": "2026-04"
    }
  ],
  "limit": 50,
  "offset": 0
}
```

---

### Usage Summary

```
GET /api/v1/admin/usage/summary
Cookie: packr_session=<session>
```

Returns aggregate usage totals for the authenticated user, including plan limits.

**Response** (200 OK):
```json
{
  "total_downloads": 1234,
  "total_packages": 5,
  "plan": "solo",
  "package_limit": 20,
  "bandwidth_bytes": 52428800,
  "period": "2026-04"
}
```

---

### Subscription

```
GET /api/v1/admin/users/me/subscription
Cookie: packr_session=<session>
```

Returns the current user's subscription details from Stripe.

**Response** (200 OK):
```json
{
  "plan": "solo",
  "status": "active",
  "current_period_end": "2027-04-10T00:00:00Z",
  "cancel_at_period_end": false,
  "portal_url": "https://billing.stripe.com/session/..."
}
```

**Plan values:** `free`, `solo`, `team`, `enterprise`

**Status values:** `active`, `trialing`, `past_due`, `canceled`

**Notes:**
- `portal_url` is a short-lived Stripe Customer Portal URL for self-service plan changes, payment method updates, and invoice history.

**Errors:**
- `401` — Not authenticated

---

## Profile Endpoints

### Get Own Profile

```
GET /api/v1/admin/users/me/profile
Cookie: packr_session=<session>
```

**Response** (200 OK):
```json
{
  "username": "kristian",
  "display_name": "Kristian Mandrup",
  "bio": "Open source developer",
  "website": "https://example.com",
  "avatar_url": "https://avatars.githubusercontent.com/u/123456",
  "created_at": "2026-01-01T00:00:00Z"
}
```

---

### Update Own Profile

```
PUT /api/v1/admin/users/me/profile
Cookie: packr_session=<session>
Content-Type: application/json

{
  "display_name": "Kristian Mandrup",
  "bio": "Open source developer",
  "website": "https://example.com"
}
```

**Response** (200 OK):
```json
{"ok": true}
```

**Notes:**
- `avatar_url` is set automatically from OAuth provider and cannot be updated directly.

---

### Get Public Profile

```
GET /api/v1/users/:username/profile
```

No authentication required. Returns non-sensitive profile fields for all users.

**Response** (200 OK):
```json
{
  "username": "kristian",
  "display_name": "Kristian Mandrup",
  "bio": "Open source developer",
  "website": "https://example.com",
  "avatar_url": "https://avatars.githubusercontent.com/u/123456"
}
```

**Errors:**
- `404` — User not found

---

## Error Response Format

All errors return JSON:

```json
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message",
    "status": 400
  }
}
```

### Error Codes

| Code | HTTP Status | Description |
|------|-------------|-------------|
| `AUTH_REQUIRED` | 401 | Missing token |
| `AUTH_INVALID` | 401 | Token invalid or expired |
| `FORBIDDEN` | 403 | Not authorized for this action |
| `RATE_LIMITED` | 429 | Rate limit exceeded |
| `LIMIT_EXCEEDED` | 403/413 | Plan limit or payload size exceeded |
| `NOT_FOUND` | 404 | Resource not found |
| `VALIDATION_ERROR` | 400 | Invalid request body or parameters |
| `METHOD_NOT_ALLOWED` | 405 | HTTP method not allowed for this endpoint |
| `VERSION_EXISTS` | 409 | Version already published |
| `PROXY_ERROR` | 502 | Upstream proxy registry error |
| `STORAGE_ERROR` | 502 | Blob storage (S3/B2) error |
| `GATEWAY_TIMEOUT` | 504 | Upstream registry timed out |
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
| `INTERNAL_ERROR` | 500 | Internal server error |

---

## Rate Limits

| Endpoint | Limit | Window |
|----------|-------|--------|
| `POST /-/v1/login` | 10 req | 1 min/IP |
| `POST /-/v1/password-reset` | 10 req | 1 min/IP |
| `POST /-/v1/device/authorize` | 5 req | 1 min/IP |
| `POST /-/v1/device/token` | 60 req | 1 min/IP |
| `PUT /` (publish) | 30 req | 1 min/IP |
| Admin API | 60 req | 1 min/IP |
| Search/Agent API | 30 req | 1 min/IP |

---

**See also:** [`docs/SECURITY.md`](SECURITY.md) — Security controls, [`docs/deployment.md`](deployment.md) — Deployment guide
