brackt/plans/public-api-v1.md

18 KiB
Raw Blame History

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:

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:

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

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: apiAuthapiRateLimit → 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:

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

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

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:

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

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:

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:

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

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

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:

npm install @scalar/api-reference  # optional