The API surface has 37 route handlers across six groups. All routes live under app/api/ in the Next.js App Router. Every protected route calls requireApiSession — checks either an Authorization: Bearer <sessionId> header or the gh_session cookie. Org-scoped routes additionally call requireOrganizationAccess. Admin mutations add requireOrganizationAdmin.
See authentication and authorization for full guard details.
Auth routes
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/auth/start | GET | none | Begin OAuth — create state JWT + CSRF cookie, redirect to GitHub. Query params: mode (web|mobile, default web), returnTo (default /). Scopes: read:org user:email. |
/api/auth | GET | none (callback) | Exchange OAuth code for token, encrypt (AES-256-GCM), upsert user/orgs/memberships, create session in auth_sessions, sync installations (best-effort), set gh_session cookie. Query params: code, state. If state JWT type === 'install', relays to /api/install/callback. |
/api/auth/session | GET | session | Return SessionView ({ authenticated: true, session: { id, user, installationIds, expiresAt } }). May refresh token if expiring within 5 minutes. Returns { authenticated: false, session: null } (401) if no session. |
/api/auth/logout | POST | session | Delete session row from auth_sessions, clear gh_session + both CSRF cookies. Returns { ok: true }. Proceeds even if no session (soft check). |
Install routes
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/install/start | GET | session | Begin install flow. Creates install state JWT ({ type: 'install', csrf, returnTo, sessionId }), sets gh_auth_install_csrf cookie, redirects to GitHub App install page. Query params: returnTo, targetId, targetType. Redirects to /api/auth/start if no session. |
/api/install/callback | GET | CSRF + session | Validates install CSRF cookie vs state JWT. Calls InstallationService.validateInstallation(installationId) (GitHub API). Writes installation to session_installations. Deletes CSRF cookie. Redirects to returnTo. Errors: 400 (bad params), 401 (no session), 403 (CSRF mismatch). Query params: state (JWT), installation_id, setup_action. |
/api/install/complete | POST | session | Programmatic install registration. Body: { installationId: integer }. Calls InstallationService.validateInstallation (GitHub API). Returns { ok: true, installationId }. Errors: 400 (not integer), 404 (inaccessible). |
/api/install/status | GET | session | Installation snapshot. Returns { installed, installationIds, accounts: [...], summary: { totalInstallations, orgInstallations, totalRepositories, totalAccounts, organizationAccounts, userAccounts } }. |
/api/install/webhook | POST | HMAC-SHA256 | GitHub App webhook receiver. Verifies X-Hub-Signature-256 against GITHUB_WEBHOOK_SECRET using timingSafeEqual. Events: installation(created/unsuspend) → upsert github_installations; installation(deleted) → DELETE; installation(suspend) → update suspended_at; installation_repositories → refresh repos; organization/membership/member → log only. > Warning: If GITHUB_WEBHOOK_SECRET is not set, all requests are accepted. Set it in production. |
Organization routes
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/organizations | GET | session | User’s orgs + install status per org. Returns { organizations: [{ id, login, name, avatarUrl, viewerCanAdminister, hasAppInstalled, installationId, repositoryCount, repositorySelection, suspendedAt }] }. |
/api/organizations/[login] | GET | session + org access | Single org detail + installation metadata. Route param: login. |
/api/organizations/[login]/mass-invite | POST | session + admin + installation | Bulk org invitation. Body: { userLogins: string[] } (not logins). Max 50. All concurrent via Promise.allSettled. Role hardcoded 'direct_member'. Returns { success: string[], failed: [{ login, error }] }. Errors: 400 (no users / invalid), 403 (not admin), 413 (>50). Uses installation token. |
/api/organizations/[login]/bulk-role | POST | session + admin + installation | Bulk org role change. Body: { userLogins: string[], role: 'member' | 'admin' }. Max 50. Sequential execution. Returns { success, invited, failed }. Uses installation token. Note: non-standard auth — inline admin check instead of requireOrganizationAdmin. |
/api/organizations/[login]/teams/bulk-add | POST | session + admin + installation | Bulk add members to a team. Body: { userLogins: string[], teamSlug: string }. Max 50. Sequential execution. Returns { success, invited, failed }. Uses installation token. Note: non-standard auth — inline admin check. |
Org-scoped routes
All routes are prefixed /api/[organization]/.... Auth: requireApiSession + requireOrganizationAccess unless noted.
Organization / access
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/[org]/organization/summary | GET | session + org access | Org entity counts. Returns { repositories, teams, members }. Parallel Supabase reads. |
/api/[org]/installation/access | GET | session + org access | Installation permission check. Calls GitHub API for authoritative permissions. Returns { installed, installationId, organizationId, suspended, canManage, missingPermissions: [{ key, label, category, level, reason }], manageUrl }. |
/api/[org]/teams/members | GET | session + org access | Team member counts. Returns [{ teamSlug, memberCount }]. > Note: Uses N+1 query pattern — one Supabase call per team. |
Contributors
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/[org]/contributors | GET | session + org access | Full contributor list. Returns [{ id, login, avatar_url }] sorted by activity count desc. Scoped to org repos. |
/api/[org]/contributor/heatmap | GET | session + org access | 365-day activity heatmap. Query param: contributor (GitHub login, required — 400 if missing). Excludes spam signals. Returns [{ date, count, level }] (5-level scale, 365 entries). Properly scoped. |
/api/[org]/contributor/profile | GET | session + org access | Contributor activity detail. Query params: contributor (required), from, to (ISO dates). Returns { login, avatarUrl, commits, pullRequests, issues, reviews, activity: [{ date, commits, pullRequests, issues, reviews }] }. Properly scoped. |
Analytics
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/[org]/analytics/overview | GET | session + org access | Aggregate analytics. Returns { activeContributors, pullRequestsOpened, commits, issues }. > Known issue: Queries signals with no org filter — returns cross-org data. DateRange params exist but are unused in the current implementation. |
/api/[org]/analytics/top-contributors | GET | session + org access | Top 10 contributors. Query params: from, to (ISO, optional). Returns [{ userId, userLogin, avatarUrl, commits, pullRequests, issues, totalActivity, rank }]. > Known issue: No org filter on signals query. |
/api/[org]/analytics/trend | GET | session + org access | Activity trend by day. Query params: from, to (ISO, optional). Returns [{ date, commits, pullRequests, issues }]. > Known issue: No org filter on signals query. |
/api/[org]/analytics/activity-feed | GET | session + org access | Recent activity (last 50 signals). Returns [{ type, userLogin, timestamp }]. > Known issue: No org filter — returns global last 50 signals. |
Repository
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/[org]/repository/analytics | GET | session + org access | Per-repo commit volume timeseries. Query param: repositorySlug (required, owner/repo format — 400 if missing). Optional: from, to. Returns { days: [{ date, commits, additions, deletions }] }. Reads commit metadata.sha, metadata.additions, metadata.deletions. Properly scoped. |
/api/[org]/repository/top-contributors | GET | session + org access | Per-repo top 10 contributors. Query param: repositorySlug (required). Returns [{ userId, userLogin, avatarUrl, commits, pullRequests, reviews, issues, totalActivity, rank }]. Properly scoped. |
/api/[org]/repository/activity-trend | GET | session + org access | Per-repo daily trend. Query param: repositorySlug (required). Returns [{ date, commits, pullRequests, issues }]. Properly scoped. |
/api/[org]/repository/metrics | GET | session + org access | Per-repo aggregate metrics. Query param: repositorySlug (required). Returns { commits, pullRequests, issues, reviews, contributors }. Properly scoped. > Note: Contains debug console.log statements visible in production logs. |
Leaderboard
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/[org]/leaderboard/score | POST | session (raw getRequestSession) | Primary leaderboard endpoint. Body/query params: scopeType, scopeId, entityType, from, to, timePeriod, presetId. maxDuration = 300 (requires Vercel Pro). Multi-layer cache (memory → Redis → DB). Background scoring via after(). Returns leaderboard payload { entries, cacheStatus, ... }. POST only — not GET. |
/api/[org]/leaderboard/recompute | POST | session | Destructive full recompute. Always returns 403 in production. Dev-only. Wipes 4 Redis key patterns + signals + ingest_log + computed_scores + leaderboard_materializations, then reruns full pipeline. maxDuration = 300. |
/api/[org]/leaderboard/rules | GET/PUT | GET: session; PUT: session + admin | Read/write scoring rules. GET: query params presetId (numeric or 'default') and/or presetName — returns ruleset object. PUT: body { rules, presetName?, presetId? } — upserts rules; returns { ok: true, preset }. |
/api/[org]/leaderboard/rules/presets | GET/POST/PATCH/DELETE | read: session; mutations: admin | Full preset CRUD. GET: list all presets; returns { presets: [...] }. POST: create (body: presetName, optional rules, setActive); returns 201 { preset }. PATCH: update name/rules or activate/deactivate (body: presetId, setActive, optional presetName, rules). DELETE: query param presetId; returns { ok: true }. |
GraphQL proxy
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/github/graphql | POST | session | Allowlisted GitHub GraphQL proxy. Body: { operationName: string (required), variables?: object }. Validates against USER_GITHUB_GRAPHQL_OPERATIONS (8 operations). Uses user OAuth token (not installation token). May refresh token. Returns { data: <GraphQL response> }. Client-supplied query text is discarded when a server fallback exists. |
Debug routes
All prefixed /api/debug/.... Gated by requireDebugAccess (in lib/auth/debug.ts):
- Non-production (
NODE_ENV !== 'production'): completely open — no auth check at all. - Production,
ENABLE_DEBUG_ROUTESnot'true': returns 403. - Production,
ENABLE_DEBUG_ROUTES='true': requires valid session (401 if absent), no admin check.
ENABLE_DEBUG_ROUTES must be the exact string 'true' — any other value (including 'false', '1') disables debug routes.
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/debug/presets | GET | requireDebugAccess | List scoring presets for an org. Query: organization (required). Returns { installationId, presets: [...] } (full rows). |
/api/debug/recompute | POST | requireDebugAccess | Debug recompute. Query: organization (required), presetId, reingest ('1'). Less destructive than leaderboard/recompute — no Redis wipe, no materialization delete. If reingest=1: deletes signals + ingest log + reruns ingest. Always runs runScoreForPreset. |
/api/debug/computed-scores | GET | requireDebugAccess | Raw computed scores dump. Query: organization (required), timePeriod (optional). Returns { count, rows: [...] } — joined leaderboard_materializations + computed_scores, up to 1000 materializations. |
/api/debug/preset-by-id | GET | requireDebugAccess | Single preset by database ID. Query: presetId (required). Returns { preset: <full scoring_presets row> }. |
Common error codes
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or malformed parameter |
| 401 | No valid session |
| 403 | Session valid but insufficient permissions (or debug routes gated in production, or app not installed on org) |
| 404 | Resource not found (repository/org/user doesn’t exist) |
| 405 | Wrong HTTP method |
| 413 | Payload too large (mass-invite / bulk-role / bulk-add: > 50 logins) |
| 429 | Rate limited — leaderboard ingest within 24h cooldown |
| 500 | Upstream GitHub error or unhandled exception |
Auth summary
Every protected route calls requireApiSession which checks either an Authorization: Bearer <sessionId> header or the gh_session cookie. Org-access routes additionally call requireOrganizationAccess. Admin mutations call requireOrganizationAdmin. Mass-invite additionally calls resolveInstallationForOrganization to verify the GitHub App is installed.
See authentication and authorization for full detail.