# 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 `` 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() 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 10–15 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(` Brackt API Reference `) }) // 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 " https://localhost:3000/api/v1/leagues//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 ```