brackt/plans/completed/public-api-v1.md
Chris Parsons 35fe84a1dd
Update claude.md to push more tests. (#84)
* Update claude.md to push more tests.

* Archiving old plans.

* fix: run DB migrations programmatically on server startup

Replace unreliable drizzle-kit CLI migration with drizzle-orm's built-in
migrator running in the server process before accepting connections. This
ensures migrations are always applied on deploy and fails fast if they error.

- Add runMigrations() to server.ts using drizzle-orm/postgres-js/migrator
- Skip migrations in development (handled manually via db:migrate)
- Add DATABASE_URL guard with a clear error message
- Remove start:production script (now identical to start)
- Update Dockerfile CMD to use npm run start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 22:31:04 -08:00

608 lines
18 KiB
Markdown
Raw Permalink 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.

# Public API v1 — Implementation Plan
## Overview
Add a public REST API to Brackt.com, starting with a single `GET /api/v1/leagues/:leagueId/draft-board` endpoint. The API is read-only, requires API keys (via Clerk's built-in API keys feature), rate-limited at 60 req/min per user, versioned, and documented with OpenAPI + Scalar.
---
## Decisions Made
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Auth | Clerk API keys (beta) | Free during beta, zero custom UI needed — tab auto-appears in UserProfile |
| Access model | Public leagues only + key owner must be member | Balances openness with privacy |
| "Member" definition | Team owner OR commissioner OR system admin | Admins can always access for debugging |
| Read/write | Read-only for now | Dramatically simpler to secure |
| Rate limiting | 60 req/min per userId | Conservative starting point, easy to raise |
| Versioning | `/api/v1/` URL prefix | Best DX for curl/spreadsheet users |
| Docs | OpenAPI spec + Scalar renderer | Auto-generates interactive playground, spec is reusable |
| Timer state | Excluded from response | Stale immediately; document as "snapshot data" |
| Recommended polling interval | Every 15 seconds | Document in API docs, not enforced in code |
| Rate limit store | In-memory to start | Swap to `rate-limit-redis` when going multi-instance |
---
## Scope
### In Scope (Phase 1)
- Clerk API keys enabled in dashboard (manual setup step)
- API key verification middleware with in-memory caching (60s TTL)
- Per-userId rate limiting middleware
- `GET /api/v1/leagues/:leagueId/draft-board` endpoint
- Standard JSON error format for all v1 routes
- OpenAPI spec (`docs/openapi.yaml`)
- Scalar docs page at `/api/docs`
### Out of Scope (Future)
- Additional endpoints (standings, participants, teams, etc.)
- Redis rate limit store (add when going multi-instance)
- Usage analytics / per-key call counting
- SDK generation from OpenAPI spec
- Webhook notifications for draft events
- Tiered rate limits
---
## Files
### New Files
```
server/middleware/apiAuth.ts API key verification + scope check + in-memory cache
server/middleware/apiRateLimit.ts Per-userId rate limiting (60 req/min)
server/routes/apiV1.ts Express router for /api/v1/*
docs/openapi.yaml OpenAPI 3.1 spec
```
### Modified Files
```
server/app.ts Mount apiV1 router before React Router handler
```
### No Changes Needed
- `app/routes.ts` — these are Express routes, not React Router routes
- Database schema — no new tables (Clerk manages key storage)
- Existing models — reuse as-is for data fetching
---
## Step-by-Step Implementation
### Step 0: Clerk Dashboard Setup (Manual — Do First)
1. Go to Clerk Dashboard → Configure → **API Keys**
2. Click **Enable API Keys**
3. Select **Enable User API Keys**
4. Click **Enable**
That's it. An "API Keys" tab will now automatically appear in the existing `<UserProfile />` component — users can create, view, and revoke their own keys without any additional frontend code.
Scope enforcement (`read:draft-board`) is handled in your own middleware, not configured in Clerk.
---
### Step 1: API Key Auth Middleware
**File:** `server/middleware/apiAuth.ts`
**Responsibilities:**
- Extract Bearer token from `Authorization` header
- Check in-memory cache (avoid Clerk network call on every request)
- On cache miss: call `clerkClient.apiKeys.verify(token)`
- Validate that the key has the `read:draft-board` scope
- Attach `req.apiUser = { userId: string, scopes: string[] }` for downstream handlers
- Return standardized JSON errors (never HTML)
**In-memory cache design:**
```typescript
type CachedKey = {
userId: string // Clerk user ID (subject from Clerk)
scopes: string[]
cachedAt: number // Date.now()
}
const cache = new Map<string, CachedKey>()
const CACHE_TTL_MS = 60_000 // 60 seconds
```
On each request:
1. Extract token
2. Hash it (sha256) for the cache key — don't store raw tokens in memory
3. Check cache: if hit and `Date.now() - cachedAt < TTL`, use cached value
4. On miss: call `clerkClient.apiKeys.verify(token)`, cache result
**Error responses:**
- Missing/malformed header → `401 { "error": "Missing API key", "status": 401 }`
- Invalid/revoked key → `401 { "error": "Invalid API key", "status": 401 }`
- Missing required scope → `403 { "error": "Insufficient scope", "status": 403 }`
**Extend Express Request type:**
```typescript
// In server/types/express.d.ts (create if needed)
declare namespace Express {
interface Request {
apiUser?: {
userId: string
scopes: string[]
}
}
}
```
---
### Step 2: Rate Limit Middleware
**File:** `server/middleware/apiRateLimit.ts`
**Package:** `express-rate-limit` (already a common dep, or install it)
**Config:**
```typescript
rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per window
keyGenerator: (req) => req.apiUser!.userId, // keyed on userId, not IP
standardHeaders: true, // Returns X-RateLimit-* headers
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
error: 'Rate limit exceeded. Maximum 60 requests per minute.',
status: 429,
retryAfter: res.getHeader('Retry-After'),
})
},
})
```
**Important:** This middleware must run **after** `apiAuth` (needs `req.apiUser.userId`).
**Future:** Swap `store` to `new RedisStore(...)` from `rate-limit-redis` when adding Redis sidecar for multi-instance support.
---
### Step 3: Draft Board Endpoint
**File:** `server/routes/apiV1.ts`
**Route:** `GET /api/v1/leagues/:leagueId/draft-board`
**Middleware chain:** `apiAuth``apiRateLimit` → handler
**Handler logic:**
1. Load league by `leagueId` (use existing `findLeagueById` from `app/models/league.ts`)
2. Check `league.isPublicDraftBoard === true` → else `403 { "error": "This league's draft board is not public" }`
3. Load league's seasons to get the current/active season
4. Authorization: is `req.apiUser.userId` any of:
- System admin: `user.isAdmin === true`
- Commissioner: exists in `commissioners` table for this league
- Team owner: owns a team in any of the league's seasons
→ else `403 { "error": "You are not a member of this league" }`
5. Load draft data using existing model functions:
- `draftPicks` for the current season
- `draftSlots` for draft order
- Available participants (not yet picked)
- Team list
6. Return shaped response (see below)
**Response shape:**
```json
{
"league": {
"id": "uuid",
"name": "My Fantasy League"
},
"season": {
"id": "uuid",
"year": 2025,
"status": "draft",
"draftRounds": 10,
"currentPickNumber": 15,
"totalPicks": 100,
"draftPaused": false,
"draftStartedAt": "2025-01-15T18:00:00Z"
},
"teams": [
{
"id": "uuid",
"name": "Team Alpha",
"draftOrder": 1
}
],
"picks": [
{
"pickNumber": 1,
"round": 1,
"pickInRound": 1,
"team": { "id": "uuid", "name": "Team Alpha" },
"participant": {
"id": "uuid",
"name": "Patrick Mahomes",
"sport": { "name": "NFL", "slug": "nfl" }
}
}
],
"availableParticipants": [
{
"id": "uuid",
"name": "Josh Allen",
"sport": { "name": "NFL", "slug": "nfl" }
}
],
"retrievedAt": "2025-01-15T18:05:30Z"
}
```
**Notes on response design:**
- `retrievedAt` timestamp helps clients know how stale the data is
- Timer/time bank state intentionally omitted — stale immediately
- No autodraft settings or queue data — those are private per-team
- `totalPicks` = `draftRounds * teams.length`, useful for clients to calculate completion %
---
### Step 4: Standard Error Handler for v1 Router
All errors from `/api/v1/*` must return JSON, never HTML (Express's default).
In `apiV1.ts`, add an error handler at the bottom of the router:
```typescript
// Catch-all error handler for v1 routes
router.use((err, req, res, next) => {
console.error('[API v1 error]', err)
res.status(err.status ?? 500).json({
error: err.message ?? 'Internal server error',
status: err.status ?? 500,
})
})
```
Also handle 404s within the v1 namespace:
```typescript
router.use((req, res) => {
res.status(404).json({ error: 'Endpoint not found', status: 404 })
})
```
---
### Step 5: Mount in Express
**File:** `server/app.ts`
Add **before** the React Router handler:
```typescript
import { apiV1Router } from './routes/apiV1'
// Public API — must come before React Router catch-all
app.use('/api/v1', apiV1Router)
```
This ensures `/api/v1/*` requests never reach React Router.
---
### Step 6: OpenAPI Spec
**File:** `docs/openapi.yaml`
```yaml
openapi: 3.1.0
info:
title: Brackt API
version: 1.0.0
description: |
The Brackt public API provides read-only access to fantasy draft data
for public leagues you are a member of.
## Authentication
All requests require an API key sent as a Bearer token:
```
Authorization: Bearer your_api_key
```
Generate API keys from your account settings page.
## Rate Limiting
60 requests per minute per API key. Responses include X-RateLimit-*
headers. Exceeding the limit returns HTTP 429.
## Polling
This is a snapshot API. For live drafts, we recommend polling no more
frequently than every 1015 seconds. Timer state is not included in
responses as it changes every second.
servers:
- url: https://brackt.com/api/v1
description: Production
security:
- bearerAuth: []
paths:
/leagues/{leagueId}/draft-board:
get:
summary: Get draft board state
description: |
Returns the current state of a league's draft board, including all
picks made, available participants, and team draft order.
The league must have its draft board set to public, and your API key
must belong to a member of the league (team owner, commissioner, or
admin).
operationId: getDraftBoard
tags: [Draft]
parameters:
- name: leagueId
in: path
required: true
schema:
type: string
format: uuid
description: The league's UUID
responses:
'200':
description: Draft board state
content:
application/json:
schema:
$ref: '#/components/schemas/DraftBoard'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: API key generated from your account settings
responses:
Unauthorized:
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: Valid key but insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
description: Seconds until the rate limit window resets
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
required: [error, status]
properties:
error:
type: string
status:
type: integer
DraftBoard:
type: object
required: [league, season, teams, picks, availableParticipants, retrievedAt]
properties:
league:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
season:
type: object
properties:
id: { type: string, format: uuid }
year: { type: integer }
status:
type: string
enum: [pre_draft, draft, active, completed]
draftRounds: { type: integer }
currentPickNumber: { type: integer }
totalPicks: { type: integer }
draftPaused: { type: boolean }
draftStartedAt: { type: string, format: date-time, nullable: true }
teams:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
draftOrder: { type: integer }
picks:
type: array
items:
type: object
properties:
pickNumber: { type: integer }
round: { type: integer }
pickInRound: { type: integer }
team:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
participant:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
sport:
type: object
properties:
name: { type: string }
slug: { type: string }
availableParticipants:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
sport:
type: object
properties:
name: { type: string }
slug: { type: string }
retrievedAt:
type: string
format: date-time
```
---
### Step 7: Scalar Docs Page
Scalar can be served as a single HTML file from Express. No npm package needed for the basic setup (it loads from CDN):
**In `server/routes/apiV1.ts` or a separate route in `server/app.ts`:**
```typescript
app.get('/api/docs', (req, res) => {
res.send(`
<!doctype html>
<html>
<head>
<title>Brackt API Reference</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script
id="api-reference"
data-url="/api/docs/openapi.yaml"
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
`)
})
// Serve the OpenAPI spec itself
app.get('/api/docs/openapi.yaml', (req, res) => {
res.sendFile(path.join(process.cwd(), 'docs/openapi.yaml'))
})
```
This gives a full interactive API playground at `https://brackt.com/api/docs`.
---
## Authorization Logic (Detailed)
For the draft board endpoint, the userId extracted from the API key needs to map to a Clerk user in the database. The Clerk `subject` field returns a Clerk user ID (e.g., `user_abc123`). This matches the `clerkId` field on the `users` table.
**Lookup chain:**
```typescript
// 1. Find user by clerkId
const user = await findUserByClerkId(req.apiUser.userId)
if (!user) return 403
// 2. Check admin
if (user.isAdmin) authorized
// 3. Check commissioner
const isCommissioner = await isLeagueCommissioner(leagueId, user.id)
if (isCommissioner) authorized
// 4. Check team owner — user owns a team in any season of this league
const isTeamOwner = await hasTeamInLeague(leagueId, user.clerkId)
if (isTeamOwner) authorized
// else → 403
```
The `ownerId` on teams stores the Clerk user ID directly, so the team owner check queries:
```sql
SELECT 1 FROM teams t
JOIN seasons s ON t.season_id = s.id
WHERE s.league_id = :leagueId AND t.owner_id = :clerkId
LIMIT 1
```
---
## Future Endpoints (Not In Scope Now — For Reference)
When ready to expand, natural next endpoints:
```
GET /api/v1/leagues/:leagueId/seasons List seasons for a league
GET /api/v1/leagues/:leagueId/standings/:seasonId Final standings
GET /api/v1/leagues/:leagueId/teams/:teamId Team roster/picks
GET /api/v1/participants/:sportSlug All participants for a sport
```
Each would need the same auth + rate limit middleware chain, with scopes like `read:standings`, `read:teams`, etc.
---
## Security Checklist
- [ ] API keys never logged (only hash stored in cache)
- [ ] Rate limiting keyed on userId, not IP (harder to bypass with rotating IPs)
- [ ] `isPublicDraftBoard` checked before any data is returned
- [ ] Membership verified before any data is returned
- [ ] All errors return JSON (no stack traces in production)
- [ ] `retrievedAt` timestamp but no internal IDs beyond what's needed
- [ ] No autodraft, queue, or timer data in response (private/volatile)
- [ ] OpenAPI spec served from static file (no runtime generation risk)
---
## Testing Plan
### Unit Tests
- `apiAuth` middleware: valid key, invalid key, expired cache, scope missing, no header
- `apiRateLimit` middleware: under limit passes, over limit returns 429 with headers
- Draft board handler: public league + member → 200, private league → 403, non-member → 403
### Manual Tests
1. Enable Clerk API keys in dashboard
2. Generate a key from UserProfile in the app
3. `curl -H "Authorization: Bearer <key>" https://localhost:3000/api/v1/leagues/<id>/draft-board`
4. Verify 403 on private league
5. Verify 403 on public league where user is not a member
6. Verify 429 after 60 requests in a minute
7. Verify `/api/docs` loads and shows the playground
---
## Dependencies to Install
```bash
npm install express-rate-limit
```
`@clerk/backend` is already installed (used for webhook verification). The `clerkClient.apiKeys.verify()` method is available in the existing Clerk backend client.
Scalar docs load from CDN — no npm install needed for basic setup. If you want to self-host:
```bash
npm install @scalar/api-reference # optional
```