From e0060e280c8018319600a42acfc91115557c4d82 Mon Sep 17 00:00:00 2001 From: BCA-krishna Date: Sat, 27 Jun 2026 16:36:01 +0530 Subject: [PATCH 1/2] wip: migrate repos/route.ts off session.accessToken --- src/app/api/metrics/repos/route.ts | 13 ++++++++----- src/lib/auth.ts | 2 -- src/lib/get-session-token.ts | 19 +++++++++++++++++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts index 11ef12f67..a631dd452 100644 --- a/src/app/api/metrics/repos/route.ts +++ b/src/app/api/metrics/repos/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { @@ -224,11 +225,13 @@ async function fetchReposForAccount( } export async function GET(req: NextRequest) { - // Session contains the GitHub OAuth token issued at sign-in. + // Session no longer carries accessToken (see auth.ts session callback) — + // it's read separately, server-side only, via getAccessToken(). // Both accessToken and githubLogin are required: token for API auth, // login for the Commit Search query filter. const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } if (session.error === "TokenRevoked") { @@ -274,7 +277,7 @@ export async function GET(req: NextRequest) { if (!targetAccountId) { try { const result = await fetchReposForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId ?? session.githubLogin }, @@ -301,7 +304,7 @@ export async function GET(req: NextRequest) { if (targetAccountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -342,7 +345,7 @@ export async function GET(req: NextRequest) { if (targetAccountId === session.githubId) { try { const result = await fetchReposForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId }, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 66b24a92a..b80a6b803 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -175,8 +175,6 @@ export const authOptions: NextAuthOptions = { return token; }, async session({ session, token }) { - if (typeof token.accessToken === "string") - session.accessToken = token.accessToken; if (typeof token.githubId === "string") session.githubId = token.githubId; if (typeof token.githubLogin === "string") diff --git a/src/lib/get-session-token.ts b/src/lib/get-session-token.ts index a9a27e320..6bfb6115e 100644 --- a/src/lib/get-session-token.ts +++ b/src/lib/get-session-token.ts @@ -1,5 +1,7 @@ import "server-only"; import { getServerSession } from "next-auth"; +import { getToken } from "next-auth/jwt"; +import { headers, cookies } from "next/headers"; import { authOptions } from "@/lib/auth"; import { resolveAppUser } from "@/lib/resolve-user"; import type { Session } from "next-auth"; @@ -13,11 +15,24 @@ export async function getSessionWithToken(): Promise { const session = await getServerSession(authOptions); if (!session?.githubId || !session?.githubLogin) return null; - const accessToken = session.accessToken; + // accessToken no longer lives on `session` (see auth.ts session callback). + // Read it server-side, directly from the encrypted JWT cookie instead. + const token = await getToken({ + req: { headers: headers(), cookies: cookies() } as any, + secret: process.env.NEXTAUTH_SECRET, + }); + const accessToken = typeof token?.accessToken === "string" ? token.accessToken : null; if (!accessToken) return null; const userRow = await resolveAppUser(session.githubId, session.githubLogin); if (!userRow) return null; return { session, accessToken }; -} \ No newline at end of file +} +export async function getAccessToken(): Promise { + const token = await getToken({ + req: { headers: headers(), cookies: cookies() } as any, + secret: process.env.NEXTAUTH_SECRET, + }); + return typeof token?.accessToken === "string" ? token.accessToken : null; +} From 37dcd9758048101416ba3ae5599d32890642c7b7 Mon Sep 17 00:00:00 2001 From: BCA-krishna Date: Sat, 27 Jun 2026 17:19:33 +0530 Subject: [PATCH 2/2] fix(security): remove GitHub access token from client-exposed session - Stop copying accessToken onto the NextAuth session object in the session() callback (auth.ts), since session is exposed to client-side JS via useSession()/getSession() and was readable via XSS (#2845) - Add getAccessToken() helper (lib/get-session-token.ts) that reads the token server-side only, directly from the encrypted JWT cookie via next-auth/jwt's getToken() - Migrate all API routes that previously read session.accessToken to use the new server-only getAccessToken() helper instead - Update/add tests to mock getAccessToken() and verify accessToken is no longer present on the session object Closes #2845 --- src/app/api/cv/analyze/route.ts | 4 +- src/app/api/goals/sync/route.ts | 30 +++++++------ .../api/metrics/achievement-progress/route.ts | 8 ++-- src/app/api/metrics/commit-times/route.ts | 10 +++-- .../api/metrics/community-engagement/route.ts | 10 +++-- src/app/api/metrics/compare/route.ts | 16 ++++--- .../api/metrics/consistency-score/route.ts | 26 ++++++----- .../api/metrics/contributions/hourly/route.ts | 10 +++-- src/app/api/metrics/contributions/route.ts | 44 ++++++++++--------- src/app/api/metrics/devtrack-badges/route.ts | 18 ++++---- src/app/api/metrics/discussions/route.ts | 28 ++++++------ src/app/api/metrics/issues/route.ts | 35 ++++++++------- src/app/api/metrics/languages/route.ts | 18 ++++---- src/app/api/metrics/pinned-repos/route.ts | 9 ++-- src/app/api/metrics/pr-breakdown/route.ts | 16 ++++--- src/app/api/metrics/productive-hours/route.ts | 30 +++++++------ src/app/api/metrics/prs/route.ts | 40 +++++++++-------- src/app/api/metrics/repo-analytics/route.ts | 16 ++++--- src/app/api/metrics/repo-health/route.ts | 10 +++-- .../repos/[owner]/[name]/commits/route.ts | 14 +++--- src/app/api/metrics/sponsors/route.ts | 12 ++--- src/app/api/metrics/streak/route.ts | 26 ++++++----- src/app/api/metrics/weekly-summary/route.ts | 32 +++++++------- src/app/api/user/github-orgs/route.ts | 6 ++- src/app/api/user/orgs/route.ts | 8 ++-- .../api/user/pinned-repos/details/route.ts | 10 +++-- .../api/webhooks/dispatch/metrics/route.ts | 6 ++- src/app/api/wrapped/route.ts | 16 ++++--- test/ai-insights-ownership.test.ts | 7 +++ test/ai-weekly-summary.test.ts | 7 +++ test/auth.test.ts | 6 ++- test/ci-metrics.test.ts | 7 +++ test/contributions-hourly.test.ts | 7 +++ test/data-export.test.ts | 7 +++ test/debug-health.test.ts | 7 +++ test/github-accounts-api.test.ts | 7 +++ test/github-auth-metrics.test.ts | 7 +++ test/github-orgs.test.ts | 7 +++ test/goals-crud.test.ts | 7 +++ test/goals-patch-integrity.test.ts | 7 +++ test/goals-sync.test.ts | 10 ++++- test/jira-credentials.test.ts | 7 +++ test/leaderboard-cache-invalidation.test.ts | 7 +++ test/local-coding-auth-regression.test.ts | 7 +++ test/local-coding-keys.test.ts | 7 +++ test/local-coding-stats.test.ts | 7 +++ test/metrics-languages.test.ts | 8 ++++ test/repo-analytics-validation.test.ts | 7 +++ test/repos-api-db-failure.test.ts | 7 +++ test/rooms-messages.test.ts | 7 +++ test/sse-stream-route.test.ts | 7 +++ test/token-expired.test.ts | 7 +++ test/user-export.test.ts | 7 +++ test/user-settings-api.test.ts | 7 +++ test/weekly-summary-combined.test.ts | 7 +++ 55 files changed, 471 insertions(+), 229 deletions(-) diff --git a/src/app/api/cv/analyze/route.ts b/src/app/api/cv/analyze/route.ts index a1b584cb9..7bbe119fc 100644 --- a/src/app/api/cv/analyze/route.ts +++ b/src/app/api/cv/analyze/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getAccessToken } from "@/lib/get-session-token"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; @@ -25,6 +26,7 @@ export async function POST() { try { /* ── 1. Auth ─────────────────────────────────────────────── */ const session = await getServerSession(authOptions); + const accessToken = await getAccessToken(); if (!session?.githubId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -83,7 +85,7 @@ export async function POST() { ); const contributionData = await fetchContributionData( - session.accessToken as string, + accessToken as string, session.githubId ); diff --git a/src/app/api/goals/sync/route.ts b/src/app/api/goals/sync/route.ts index 0cd8d8e4c..dbfa0d5da 100644 --- a/src/app/api/goals/sync/route.ts +++ b/src/app/api/goals/sync/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { extractValidRepoFromGoal, type ActivityGoal } from "@/lib/goals-sync-utils"; @@ -36,7 +37,8 @@ const GITHUB_API = "https://api.github.com"; export async function POST() { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubId || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -44,7 +46,7 @@ export async function POST() { const { data: user } = await supabaseAdmin .from("users") .select("id") - .eq("github_id", session.githubId) + .eq("github_id", session?.githubId) .single(); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); @@ -97,7 +99,7 @@ export async function POST() { // Build the GitHub Search query using URLSearchParams so that the // combined qualifier string is URL-encoded as a single atomic value // and cannot be split by embedded special characters. - const qParts = [`author:${session.githubLogin}`]; + const qParts = [`author:${session?.githubLogin}`]; if (repo) qParts.push(`repo:${repo}`); qParts.push(`author-date:${weekStart}..${weekEnd}`); @@ -111,7 +113,7 @@ export async function POST() { `${GITHUB_API}/search/commits?${commitSearchParams.toString()}`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -169,7 +171,7 @@ export async function POST() { // Count PRs for the current week if (prGoalsToUpdate.length > 0) { const prSearchParams = new URLSearchParams({ - q: `author:${session.githubLogin} type:pr is:merged merged:${weekStart}..${weekEnd}`, + q: `author:${session?.githubLogin} type:pr is:merged merged:${weekStart}..${weekEnd}`, per_page: "100", }); @@ -177,7 +179,7 @@ export async function POST() { `${GITHUB_API}/search/issues?${prSearchParams.toString()}`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -216,9 +218,9 @@ export async function POST() { // ── Reviews sync ────────────────────────────────────────────────────────── if (reviewGoals.length > 0) { const reviewRes = await fetch( - `${GITHUB_API}/search/issues?q=reviewed-by:${session.githubLogin}+type:pr+updated:${weekStart}..${weekEnd}&per_page=1`, + `${GITHUB_API}/search/issues?q=reviewed-by:${session?.githubLogin}+type:pr+updated:${weekStart}..${weekEnd}&per_page=1`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", } ); @@ -232,9 +234,9 @@ export async function POST() { // ── Issues closed sync ──────────────────────────────────────────────────── if (issuesClosedGoals.length > 0) { const icRes = await fetch( - `${GITHUB_API}/search/issues?q=assignee:${session.githubLogin}+type:issue+state:closed+closed:${weekStart}..${weekEnd}&per_page=1`, + `${GITHUB_API}/search/issues?q=assignee:${session?.githubLogin}+type:issue+state:closed+closed:${weekStart}..${weekEnd}&per_page=1`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", } ); @@ -248,9 +250,9 @@ export async function POST() { // ── Issues opened sync ──────────────────────────────────────────────────── if (issuesOpenedGoals.length > 0) { const ioRes = await fetch( - `${GITHUB_API}/search/issues?q=author:${session.githubLogin}+type:issue+created:${weekStart}..${weekEnd}&per_page=1`, + `${GITHUB_API}/search/issues?q=author:${session?.githubLogin}+type:issue+created:${weekStart}..${weekEnd}&per_page=1`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", } ); @@ -264,9 +266,9 @@ export async function POST() { // ── Open source PRs sync (PRs to repos the user doesn't own) ───────────── if (openSourcePrGoals.length > 0) { const osRes = await fetch( - `${GITHUB_API}/search/issues?q=author:${session.githubLogin}+type:pr+is:merged+merged:${weekStart}..${weekEnd}+-user:${session.githubLogin}&per_page=1`, + `${GITHUB_API}/search/issues?q=author:${session?.githubLogin}+type:pr+is:merged+merged:${weekStart}..${weekEnd}+-user:${session?.githubLogin}&per_page=1`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", } ); diff --git a/src/app/api/metrics/achievement-progress/route.ts b/src/app/api/metrics/achievement-progress/route.ts index 7dd2d6153..96db8bc18 100644 --- a/src/app/api/metrics/achievement-progress/route.ts +++ b/src/app/api/metrics/achievement-progress/route.ts @@ -1,5 +1,6 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; +import { getAccessToken } from "@/lib/get-session-token"; import { authOptions } from "@/lib/auth"; import { GitHubAuthError, githubAuthErrorResponse } from "@/lib/github-fetch"; import { @@ -96,12 +97,13 @@ async function fetchAchievementMetrics( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); + const accessToken = await getAccessToken(); - if (!session?.accessToken || !session.githubId || !session.githubLogin) { + if (!accessToken || !session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); + const user = await resolveAppUser(session?.githubId, session?.githubLogin); if (!user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -110,7 +112,7 @@ export async function GET(req: NextRequest) { let metrics: { mergedPRs: number; acceptedAnswers: number } | null; try { - metrics = await fetchAchievementMetrics(session.accessToken, user.id, bypass); + metrics = await fetchAchievementMetrics(accessToken, user.id, bypass); } catch (err) { if (err instanceof GitHubAuthError) { return githubAuthErrorResponse(); diff --git a/src/app/api/metrics/commit-times/route.ts b/src/app/api/metrics/commit-times/route.ts index 7cbc3cbb0..91972437b 100644 --- a/src/app/api/metrics/commit-times/route.ts +++ b/src/app/api/metrics/commit-times/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; @@ -13,13 +14,14 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey( - session.githubId ?? session.githubLogin, + session?.githubId ?? session?.githubLogin, "commit-times", { days: 90 } ); @@ -40,10 +42,10 @@ export async function GET(req: NextRequest) { let page = 1; while (true) { const res = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, + `${GITHUB_API}/search/commits?q=author:${session?.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", diff --git a/src/app/api/metrics/community-engagement/route.ts b/src/app/api/metrics/community-engagement/route.ts index d5cfb5166..9c53b89ee 100644 --- a/src/app/api/metrics/community-engagement/route.ts +++ b/src/app/api/metrics/community-engagement/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { resolveAppUser } from "@/lib/resolve-user"; @@ -198,11 +199,12 @@ async function fetchUserStats( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubId || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); + const user = await resolveAppUser(session?.githubId, session?.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); const bypass = isMetricsCacheBypassed(req); @@ -213,8 +215,8 @@ export async function GET(req: NextRequest) { async () => { const stats = await fetchUserStats( user.id, - session.githubLogin!, - session.accessToken! + session?.githubLogin!, + accessToken! ); // Fetch previously-earned badge timestamps from DB diff --git a/src/app/api/metrics/compare/route.ts b/src/app/api/metrics/compare/route.ts index 101675191..07c091c35 100644 --- a/src/app/api/metrics/compare/route.ts +++ b/src/app/api/metrics/compare/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { toDateStr } from "@/lib/date-utils"; @@ -12,7 +13,8 @@ const GITHUB_API = "https://api.github.com"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -27,7 +29,7 @@ export async function GET(req: NextRequest) { } if (username === "me") { - username = session.githubLogin as string; + username = session?.githubLogin as string; } const normalizedUsername = normalizeGitHubUsername(username); @@ -41,7 +43,7 @@ export async function GET(req: NextRequest) { // be served to a different authenticated user. // Use githubId (stable numeric ID) with githubLogin as fallback. const today = toDateStr(new Date()); - const viewerId = session.githubId ?? session.githubLogin; + const viewerId = session?.githubId ?? session?.githubLogin; const cacheKey = `${viewerId}::${normalizedUsername}::${today}`; const { data: cached } = await supabaseAdmin @@ -58,7 +60,7 @@ export async function GET(req: NextRequest) { // 1. Verify user exists const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); @@ -91,7 +93,7 @@ export async function GET(req: NextRequest) { const commitsRes = await fetch(commitsUrl.toString(), { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -144,7 +146,7 @@ export async function GET(req: NextRequest) { reposUrl.searchParams.set("sort", "pushed"); const reposRes = await fetch(reposUrl.toString(), { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); @@ -167,7 +169,7 @@ export async function GET(req: NextRequest) { prsUrl.searchParams.set("per_page", "1"); const prsRes = await fetch(prsUrl.toString(), { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); let prs = 0; diff --git a/src/app/api/metrics/consistency-score/route.ts b/src/app/api/metrics/consistency-score/route.ts index 150bba068..3a0fc4f95 100644 --- a/src/app/api/metrics/consistency-score/route.ts +++ b/src/app/api/metrics/consistency-score/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; @@ -105,14 +106,15 @@ async function getConsistencyScoreForDates( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin || !session.githubId) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin || !session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); const appUserId = userRow?.id ?? null; if (accountId && !appUserId) { @@ -132,14 +134,14 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const activeDates = await fetchActiveDates( - session.githubLogin, - session.accessToken, - { bypass, userId: session.githubId }, + session?.githubLogin, + accessToken, + { bypass, userId: session?.githubId }, timeZone, ); const result = await getConsistencyScoreForDates(activeDates, timeZone, { bypass, - userId: session.githubId, + userId: session?.githubId, accountKey: "default", }); return Response.json(result); @@ -155,9 +157,9 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, + token: accessToken, + githubId: session?.githubId, + githubLogin: session?.githubLogin, }, appUserId, ); @@ -189,10 +191,10 @@ export async function GET(req: NextRequest) { return Response.json(scoreData); } - let resolvedToken = session.accessToken; - let resolvedLogin = session.githubLogin; + let resolvedToken = accessToken; + let resolvedLogin = session?.githubLogin; - if (accountId !== session.githubId) { + if (accountId !== session?.githubId) { const accountToken = await getAccountToken(appUserId, accountId); if (!accountToken) { diff --git a/src/app/api/metrics/contributions/hourly/route.ts b/src/app/api/metrics/contributions/hourly/route.ts index 6c8740c62..1ead983ba 100644 --- a/src/app/api/metrics/contributions/hourly/route.ts +++ b/src/app/api/metrics/contributions/hourly/route.ts @@ -1,5 +1,6 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; +import { getAccessToken } from "@/lib/get-session-token"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; import { @@ -13,7 +14,8 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -22,7 +24,7 @@ export async function GET(req: NextRequest) { const days = isNaN(parsedDays) ? 30 : Math.max(1, Math.min(365, parsedDays)); const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey( - session.githubId ?? session.githubLogin, + session?.githubId ?? session?.githubLogin, "contributions", { days } ); @@ -44,10 +46,10 @@ export async function GET(req: NextRequest) { while (true) { const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, + `${GITHUB_API}/search/commits?q=author:${session?.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 40b51706f..d007eb68e 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -3,6 +3,7 @@ import { throwIfGitHubRateLimited, } from "@/lib/github-rate-limit"; import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { @@ -362,7 +363,8 @@ async function mergeGitLabContributions( export async function GET(req: NextRequest) { const timezone = req.nextUrl.searchParams.get("timezone") || "UTC"; const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -390,7 +392,7 @@ export async function GET(req: NextRequest) { const username = usernameParam ? normalizeGitHubUsername(usernameParam) : null; const bypass = isMetricsCacheBypassed(req); const gitlabToken = - typeof session.gitlabToken === "string" ? session.gitlabToken : undefined; + typeof session?.gitlabToken === "string" ? session?.gitlabToken : undefined; if (usernameParam && !username) { return Response.json({ error: "Invalid GitHub username" }, { status: 400 }); @@ -411,12 +413,12 @@ export async function GET(req: NextRequest) { // Load excluded organizations config let excludedOrgs: string[] = []; - if (isSupabaseAdminAvailable && session.githubId) { + if (isSupabaseAdminAvailable && session?.githubId) { try { const { data: dbUser } = await supabaseAdmin .from("users") .select("organizations_config") - .eq("github_id", session.githubId) + .eq("github_id", session?.githubId) .single(); const orgsConfig = (dbUser?.organizations_config || {}) as Record; @@ -435,10 +437,10 @@ export async function GET(req: NextRequest) { if (username) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, username, days, - { bypass, userId: session.githubId ?? session.githubLogin }, + { bypass, userId: session?.githubId ?? session?.githubLogin }, timezone, fromDate, repoParam, @@ -454,10 +456,10 @@ export async function GET(req: NextRequest) { if (!targetAccountId) { try { const result = await fetchContributionsForAccount( - session.accessToken, - session.githubLogin, + accessToken, + session?.githubLogin, days, - { bypass, userId: session.githubId ?? session.githubLogin }, + { bypass, userId: session?.githubId ?? session?.githubLogin }, timezone, fromDate, repoParam, @@ -471,7 +473,7 @@ export async function GET(req: NextRequest) { const merged = await mergeGitLabContributions(result, gitlabToken, days, { bypass, - userId: session.githubId ?? session.githubLogin, + userId: session?.githubId ?? session?.githubLogin, }); return Response.json(merged); @@ -480,11 +482,11 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -493,9 +495,9 @@ export async function GET(req: NextRequest) { if (targetAccountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, + token: accessToken, + githubId: session?.githubId, + githubLogin: session?.githubLogin, }, userRow.id ); @@ -552,19 +554,19 @@ if (rateLimitedResult) { const combined = await mergeGitLabContributions(merged, gitlabToken, days, { bypass, - userId: session.githubId, + userId: session?.githubId, }); return Response.json(combined); } - if (targetAccountId === session.githubId) { + if (targetAccountId === session?.githubId) { try { const result = await fetchContributionsForAccount( - session.accessToken, - session.githubLogin, + accessToken, + session?.githubLogin, days, - { bypass, userId: session.githubId }, + { bypass, userId: session?.githubId }, timezone, fromDate, repoParam, @@ -578,7 +580,7 @@ if (rateLimitedResult) { const merged = await mergeGitLabContributions(result, gitlabToken, days, { bypass, - userId: session.githubId, + userId: session?.githubId, }); return Response.json(merged); diff --git a/src/app/api/metrics/devtrack-badges/route.ts b/src/app/api/metrics/devtrack-badges/route.ts index 1125e51ad..22c9e11c4 100644 --- a/src/app/api/metrics/devtrack-badges/route.ts +++ b/src/app/api/metrics/devtrack-badges/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; @@ -33,7 +34,8 @@ function scoreLabel(total: number): CommunityEngagementScore["label"] { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -42,7 +44,7 @@ export async function GET(req: NextRequest) { const sinceStr = since.toISOString().slice(0, 10); const key = metricsCacheKey( - session.githubId ?? session.githubLogin, + session?.githubId ?? session?.githubLogin, "community-engagement" as any, { since: sinceStr } ); @@ -52,30 +54,30 @@ export async function GET(req: NextRequest) { { bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.contributions }, async () => { const headers = { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }; const [reviewsRes, issuesOpenRes, issuesClosedRes, openSourceRes, docsRes] = await Promise.allSettled([ fetch( - `${GITHUB_API}/search/issues?q=reviewed-by:${session.githubLogin}+type:pr+updated:>=${sinceStr}&per_page=1`, + `${GITHUB_API}/search/issues?q=reviewed-by:${session?.githubLogin}+type:pr+updated:>=${sinceStr}&per_page=1`, { headers, cache: "no-store" } ), fetch( - `${GITHUB_API}/search/issues?q=author:${session.githubLogin}+type:issue+created:>=${sinceStr}&per_page=1`, + `${GITHUB_API}/search/issues?q=author:${session?.githubLogin}+type:issue+created:>=${sinceStr}&per_page=1`, { headers, cache: "no-store" } ), fetch( - `${GITHUB_API}/search/issues?q=assignee:${session.githubLogin}+type:issue+state:closed+closed:>=${sinceStr}&per_page=1`, + `${GITHUB_API}/search/issues?q=assignee:${session?.githubLogin}+type:issue+state:closed+closed:>=${sinceStr}&per_page=1`, { headers, cache: "no-store" } ), fetch( - `${GITHUB_API}/search/issues?q=author:${session.githubLogin}+type:pr+is:merged+-user:${session.githubLogin}+merged:>=${sinceStr}&per_page=1`, + `${GITHUB_API}/search/issues?q=author:${session?.githubLogin}+type:pr+is:merged+-user:${session?.githubLogin}+merged:>=${sinceStr}&per_page=1`, { headers, cache: "no-store" } ), fetch( - `${GITHUB_API}/search/issues?q=author:${session.githubLogin}+type:pr+is:merged+label:documentation+merged:>=${sinceStr}&per_page=1`, + `${GITHUB_API}/search/issues?q=author:${session?.githubLogin}+type:pr+is:merged+label:documentation+merged:>=${sinceStr}&per_page=1`, { headers, cache: "no-store" } ), ]); diff --git a/src/app/api/metrics/discussions/route.ts b/src/app/api/metrics/discussions/route.ts index b2904626f..3f73dbeb0 100644 --- a/src/app/api/metrics/discussions/route.ts +++ b/src/app/api/metrics/discussions/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { @@ -121,10 +122,11 @@ function formatDiscussionsMetrics(metrics: DiscussionsMetrics) { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getAccessToken(); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - if (session.error === "TokenRevoked") { + if (session?.error === "TokenRevoked") { return githubAuthErrorResponse(); } @@ -134,9 +136,9 @@ export async function GET(req: NextRequest) { if (!accountId) { try { - const result = await fetchDiscussionsMetrics(session.accessToken, days, { + const result = await fetchDiscussionsMetrics(accessToken, days, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }); return Response.json(formatDiscussionsMetrics(result)); } catch (e) { @@ -151,11 +153,11 @@ export async function GET(req: NextRequest) { targetAccountId = parts[1]; } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (targetAccountId === "combined") { if (!userRow) { @@ -163,9 +165,9 @@ export async function GET(req: NextRequest) { } const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, + token: accessToken, + githubId: session?.githubId, + githubLogin: session?.githubLogin, }, userRow.id ); @@ -190,11 +192,11 @@ export async function GET(req: NextRequest) { let token: string | null = null; if (!userRow) { - token = session.accessToken; + token = accessToken; } else { token = - targetAccountId === session.githubId - ? session.accessToken + targetAccountId === session?.githubId + ? accessToken : await getAccountToken(userRow.id, targetAccountId); } @@ -205,7 +207,7 @@ export async function GET(req: NextRequest) { try { const result = await fetchDiscussionsMetrics(token, days, { bypass, - userId: targetAccountId === session.githubId ? session.githubId : targetAccountId, + userId: targetAccountId === session?.githubId ? session?.githubId : targetAccountId, }); return Response.json(formatDiscussionsMetrics(result)); } catch (e) { diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index 1c703269c..79112a3f2 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -1,4 +1,5 @@ import { getServerSession, type Session } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { fetchIssuesMetrics } from "@/lib/github"; @@ -18,17 +19,18 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - if (session.error === "TokenRevoked") { + if (session?.error === "TokenRevoked") { return githubAuthErrorResponse(); } const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); if (accountId === "combined") { - return await getCombinedIssuesMetrics(session, req); + return await getCombinedIssuesMetrics(session, accessToken, req); } let orgName: string | null = null; let targetAccountId: string | null = accountId; @@ -42,8 +44,8 @@ export async function GET(req: NextRequest) { // Load excluded organizations config let excludedOrgs: string[] = []; let userRow: AppUser | null = null; - if (isSupabaseAdminAvailable && session.githubId) { - userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (isSupabaseAdminAvailable && session?.githubId) { + userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (userRow) { try { const { data: dbUser } = await supabaseAdmin @@ -62,12 +64,12 @@ export async function GET(req: NextRequest) { } } - let token = session.accessToken; - let userId = session.githubId ?? session.githubLogin; - let githubLogin = session.githubLogin; + let token = accessToken; + let userId = session?.githubId ?? session?.githubLogin; + let githubLogin = session?.githubLogin; - if (targetAccountId && targetAccountId !== session.githubId) { - if (!session.githubId) { + if (targetAccountId && targetAccountId !== session?.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } if (!userRow) { @@ -111,13 +113,14 @@ export async function GET(req: NextRequest) { } async function getCombinedIssuesMetrics( session: Session, + accessToken: string, req: NextRequest ) { - if (!session.githubId || !session.accessToken || !session.githubLogin) { + if (!session?.githubId || !accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -126,9 +129,9 @@ async function getCombinedIssuesMetrics( const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, + token: accessToken, + githubId: session?.githubId, + githubLogin: session?.githubLogin, }, userRow.id ); @@ -150,4 +153,4 @@ const merged = mergeMetrics(results, (a, b) => ({ } return Response.json(merged); -} \ No newline at end of file +} diff --git a/src/app/api/metrics/languages/route.ts b/src/app/api/metrics/languages/route.ts index ed24444af..53d8444d6 100644 --- a/src/app/api/metrics/languages/route.ts +++ b/src/app/api/metrics/languages/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache, METRICS_CACHE_TTL_SECONDS} from "@/lib/metrics-cache"; @@ -11,20 +12,21 @@ const GITHUB_API = "https://api.github.com"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - let token = session.accessToken; - let githubLogin = session.githubLogin; - let userId = session.githubId ?? session.githubLogin; + let token = accessToken; + let githubLogin = session?.githubLogin; + let userId = session?.githubId ?? session?.githubLogin; - if (accountId && accountId !== session.githubId) { - if (!session.githubId) { + if (accountId && accountId !== session?.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -139,7 +141,7 @@ export async function GET(req: NextRequest) { } catch (e) { const errorMessage = e instanceof Error ? e.message : "Unknown error"; console.error("[METRICS] Language metrics endpoint error", { - userId: session.githubId ?? session.githubLogin, + userId: session?.githubId ?? session?.githubLogin, error: errorMessage, }); return Response.json({ error: "GitHub API error", isComplete: false }, { status: 502 }); diff --git a/src/app/api/metrics/pinned-repos/route.ts b/src/app/api/metrics/pinned-repos/route.ts index fb8722101..0aaa5307c 100644 --- a/src/app/api/metrics/pinned-repos/route.ts +++ b/src/app/api/metrics/pinned-repos/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import type { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { @@ -58,17 +59,17 @@ const PINNED_REPOS_QUERY = ` export async function GET(req?: NextRequest) { const session = await getServerSession(authOptions); + const accessToken = await getAccessToken(); - if (!session?.accessToken) { + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - if (session.error === "TokenRevoked") { + if (session?.error === "TokenRevoked") { return githubAuthErrorResponse(); } - const accessToken = session.accessToken; - const cacheUserId = session.githubId ?? session.githubLogin; + const cacheUserId = session?.githubId ?? session?.githubLogin; if (!cacheUserId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/src/app/api/metrics/pr-breakdown/route.ts b/src/app/api/metrics/pr-breakdown/route.ts index 259b8e3ef..fcd804b0a 100644 --- a/src/app/api/metrics/pr-breakdown/route.ts +++ b/src/app/api/metrics/pr-breakdown/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GitHubAuthError, githubAuthErrorResponse } from "@/lib/github-fetch"; @@ -13,20 +14,21 @@ interface PRItem { state: string; draft?: boolean; pull_request?: { merged_at: s export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 }); - if (session.error === "TokenRevoked") return githubAuthErrorResponse(); + const accessToken = await getAccessToken(); + if (!accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 }); + if (session?.error === "TokenRevoked") return githubAuthErrorResponse(); const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - let token = session.accessToken; - let userId = session.githubId ?? "unknown"; + let token = accessToken; + let userId = session?.githubId ?? "unknown"; - if (accountId && accountId !== session.githubId) { - if (!session.githubId) { + if (accountId && accountId !== session?.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/metrics/productive-hours/route.ts b/src/app/api/metrics/productive-hours/route.ts index 5bb62d383..7a0bafde0 100644 --- a/src/app/api/metrics/productive-hours/route.ts +++ b/src/app/api/metrics/productive-hours/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { @@ -195,10 +196,11 @@ function toLocalDateStr(d: Date): string { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - if (session.error === "TokenRevoked") { + if (session?.error === "TokenRevoked") { return githubAuthErrorResponse(); } @@ -232,11 +234,11 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchProductiveHoursForAccount( - session.accessToken, - session.githubLogin, + accessToken, + session?.githubLogin, days, timezone, - { bypass, userId: session.githubId ?? session.githubLogin }, + { bypass, userId: session?.githubId ?? session?.githubLogin }, fromDate, repoParam ); @@ -247,11 +249,11 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -260,9 +262,9 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, + token: accessToken, + githubId: session?.githubId, + githubLogin: session?.githubLogin, }, userRow.id ); @@ -290,14 +292,14 @@ export async function GET(req: NextRequest) { return Response.json(merged); } - if (accountId === session.githubId) { + if (accountId === session?.githubId) { try { const result = await fetchProductiveHoursForAccount( - session.accessToken, - session.githubLogin, + accessToken, + session?.githubLogin, days, timezone, - { bypass, userId: session.githubId }, + { bypass, userId: session?.githubId }, fromDate, repoParam ); diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index b5f734133..eaa3618ad 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; @@ -537,18 +538,19 @@ async function fetchReviewMetrics(token: string): Promise { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getAccessToken(); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const gitlabToken = typeof session.gitlabToken === "string" ? session.gitlabToken : undefined; + const gitlabToken = typeof session?.gitlabToken === "string" ? session?.gitlabToken : undefined; const accountId = req.nextUrl.searchParams.get("accountId"); const range = req.nextUrl.searchParams.get("range") || "30d"; const bypass = isMetricsCacheBypassed(req); const gitlabCacheContext = { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }; let orgName: string | null = null; @@ -563,8 +565,8 @@ export async function GET(req: NextRequest) { // Load excluded organizations config let excludedOrgs: string[] = []; let userRow: AppUser | null = null; - if (isSupabaseAdminAvailable && session.githubId) { - userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (isSupabaseAdminAvailable && session?.githubId) { + userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (userRow) { try { const { data: dbUser } = await supabaseAdmin @@ -586,12 +588,12 @@ export async function GET(req: NextRequest) { if (!targetAccountId) { try { const result = await fetchCachedPRMetrics( - session.accessToken, + accessToken, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }, - session.githubLogin, + session?.githubLogin, orgName, excludedOrgs, range @@ -599,7 +601,7 @@ export async function GET(req: NextRequest) { const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), - fetchReviewMetrics(session.accessToken).catch(() => null), + fetchReviewMetrics(accessToken).catch(() => null), ]); return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews }); @@ -610,7 +612,7 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -621,13 +623,13 @@ export async function GET(req: NextRequest) { if (targetAccountId === "combined") { try { const allAccounts = await getAllAccounts( - { token: session.accessToken!, githubId: session.githubId, githubLogin: session.githubLogin }, + { token: accessToken!, githubId: session?.githubId, githubLogin: session?.githubLogin }, userRow.id ); const metricsPromises = allAccounts.map(async (acc) => { - const token = acc.githubId === session.githubId - ? session.accessToken + const token = acc.githubId === session?.githubId + ? accessToken : await getAccountToken(userRow.id, acc.githubId); if (!token) return null; return fetchCachedPRMetrics(token, { bypass, userId: acc.githubId }, acc.githubLogin, orgName, excludedOrgs, range); @@ -702,7 +704,7 @@ export async function GET(req: NextRequest) { const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), - fetchReviewMetrics(session.accessToken).catch(() => null), + fetchReviewMetrics(accessToken).catch(() => null), ]); return Response.json({ ...formatPRMetricsResponse(combinedMetrics, gitlab), reviews }); @@ -711,8 +713,8 @@ export async function GET(req: NextRequest) { } } - const token = !targetAccountId || targetAccountId === session.githubId - ? session.accessToken + const token = !targetAccountId || targetAccountId === session?.githubId + ? accessToken : await getAccountToken(userRow.id, targetAccountId); if (!token) return Response.json({ error: "Account not found" }, { status: 404 }); @@ -724,7 +726,7 @@ export async function GET(req: NextRequest) { .eq("github_id", targetAccountId) .single(); - const githubLogin = targetAccountId === session.githubId ? session.githubLogin : accountRow?.github_login; + const githubLogin = targetAccountId === session?.githubId ? session?.githubLogin : accountRow?.github_login; if (!githubLogin) { return Response.json({ error: "Account not found" }, { status: 404 }); @@ -735,7 +737,7 @@ export async function GET(req: NextRequest) { token, { bypass, - userId: targetAccountId === session.githubId ? session.githubId : targetAccountId, + userId: targetAccountId === session?.githubId ? session?.githubId : targetAccountId, }, githubLogin, orgName, @@ -745,7 +747,7 @@ export async function GET(req: NextRequest) { const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), - fetchReviewMetrics(session.accessToken).catch(() => null), + fetchReviewMetrics(accessToken).catch(() => null), ]); return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews }); diff --git a/src/app/api/metrics/repo-analytics/route.ts b/src/app/api/metrics/repo-analytics/route.ts index caf5e0946..a61adb7ef 100644 --- a/src/app/api/metrics/repo-analytics/route.ts +++ b/src/app/api/metrics/repo-analytics/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; @@ -14,7 +15,8 @@ const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899" export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -47,7 +49,7 @@ export async function GET(req: NextRequest) { const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey( - session.githubId ?? session.githubLogin, + session?.githubId ?? session?.githubLogin, `repo-analytics-${owner}/${repo}` as any, { days: 30 } ); @@ -55,20 +57,20 @@ export async function GET(req: NextRequest) { try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 60 * 60 }, async () => { const repoRes = await fetch(repoUrl, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); if (!repoRes.ok) throw new Error("API error fetching repo overview"); const repoData = await repoRes.json(); const contribRes = await fetch(`${GITHUB_API}/repos/${safeRepoPath}/contributors?per_page=10`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); const contribData = contribRes.ok ? await contribRes.json() : []; const langRes = await fetch(`${GITHUB_API}/repos/${safeRepoPath}/languages`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); const langData = langRes.ok ? await langRes.json() : {}; @@ -85,7 +87,7 @@ export async function GET(req: NextRequest) { const primaryStack = languageBreakdown.slice(0, 3).map((l) => l.name); const activityRes = await fetch(`${GITHUB_API}/repos/${safeRepoPath}/stats/commit_activity`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); @@ -136,7 +138,7 @@ export async function GET(req: NextRequest) { // Fetch PR activity for this repo (Issue 1: top repos by PR activity) const prRes = await fetch(`${GITHUB_API}/repos/${safeRepoPath}/pulls?state=all&per_page=1`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); const prLinkHeader = prRes.headers.get("link") ?? ""; diff --git a/src/app/api/metrics/repo-health/route.ts b/src/app/api/metrics/repo-health/route.ts index 8e059da02..d7147a8d9 100644 --- a/src/app/api/metrics/repo-health/route.ts +++ b/src/app/api/metrics/repo-health/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { computeHealthScore } from "@/lib/repo-health"; @@ -146,7 +147,8 @@ export async function GET(req: NextRequest) { // Session contains the GitHub OAuth token issued at sign-in. // Both accessToken and githubLogin are required for the API calls below. const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -155,7 +157,7 @@ export async function GET(req: NextRequest) { const days = requestedDays === 7 || requestedDays === 30 || requestedDays === 90 ? requestedDays : 30; const bypass = isMetricsCacheBypassed(req); - const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repo-health" as any, { days }); + const key = metricsCacheKey(session?.githubId ?? session?.githubLogin, "repo-health" as any, { days }); try { // Cache TTL of 10 minutes (600 seconds) — longer than most other metrics because @@ -165,7 +167,7 @@ export async function GET(req: NextRequest) { // the 30 req/min Search API quota in under 2 minutes. const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { // Step 1: identify the top 6 repos via Commit Search (1 Search API request). - const topRepos = (await fetchReposForAccount(session.accessToken!, session.githubLogin!, days)).repos; + const topRepos = (await fetchReposForAccount(accessToken!, session?.githubLogin!, days)).repos; const scores: RepoHealthScore[] = []; for (const repo of topRepos) { @@ -173,7 +175,7 @@ export async function GET(req: NextRequest) { // Step 2: fetch health signals for each repo (up to 4 Search + 1 REST per repo). // Individual repo failures are silently skipped — a rate limit on one repo // should not prevent health scores for the remaining repos from loading. - const signals = await fetchSignalsForRepo(session.accessToken!, repo.name, days); + const signals = await fetchSignalsForRepo(accessToken!, repo.name, days); scores.push(computeHealthScore(repo.name, signals)); } catch (e) { // Swallow per-repo errors (rate limit, private repo, network blip). diff --git a/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts b/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts index 201bd399f..b0734b00c 100644 --- a/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts +++ b/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; @@ -15,19 +16,20 @@ export async function GET( const resolvedParams = await params; const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const repoFullName = `${resolvedParams.owner}/${resolvedParams.name}`; const accountId = req.nextUrl.searchParams.get("accountId"); - let token = session.accessToken; - let authorLogin = session.githubLogin; + let token = accessToken; + let authorLogin = session?.githubLogin; - if (accountId && accountId !== "combined" && accountId !== session.githubId) { - if (session.githubId) { - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (accountId && accountId !== "combined" && accountId !== session?.githubId) { + if (session?.githubId) { + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (userRow) { const accountToken = await getAccountToken(userRow.id, accountId); if (accountToken) { diff --git a/src/app/api/metrics/sponsors/route.ts b/src/app/api/metrics/sponsors/route.ts index e692c4eeb..a5ede86fa 100644 --- a/src/app/api/metrics/sponsors/route.ts +++ b/src/app/api/metrics/sponsors/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { resolveAppUser } from "@/lib/resolve-user"; @@ -10,15 +11,16 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getAccessToken(); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - if (session.error === "TokenRevoked") { + if (session?.error === "TokenRevoked") { return githubAuthErrorResponse(); } - const githubId = session.githubId; - const githubLogin = session.githubLogin; + const githubId = session?.githubId; + const githubLogin = session?.githubLogin; if (!githubId || !githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -36,7 +38,7 @@ export async function GET(req: NextRequest) { try { const data = await syncSponsorMetricsForUser({ userId, - token: session.accessToken, + token: accessToken, force, }); return Response.json(data); diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index a279499be..fe684a4e2 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; @@ -143,7 +144,8 @@ export async function GET(req: NextRequest) { // githubLogin and githubId are both required: login for the Search API query, // githubId for cache key scoping and multi-account lookups. const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin || !session.githubId) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin || !session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -151,7 +153,7 @@ export async function GET(req: NextRequest) { const bypass = isMetricsCacheBypassed(req); let appUserId: string | null = null; - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); appUserId = userRow?.id ?? null; // accountId param requires a resolved app user — without one we can't look @@ -197,9 +199,9 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const activeDates = await fetchActiveDates( - session.githubLogin, - session.accessToken, - { bypass, userId: session.githubId }, + session?.githubLogin, + accessToken, + { bypass, userId: session?.githubId }, timeZone ); const streakData = calculateStreakFromDates(activeDates, freezeDates, timeZone); @@ -223,9 +225,9 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, + token: accessToken, + githubId: session?.githubId, + githubLogin: session?.githubLogin, }, appUserId ); @@ -261,10 +263,10 @@ export async function GET(req: NextRequest) { } // Single specific account — resolve its token and login from Supabase. - let resolvedToken = session.accessToken; - let resolvedLogin = session.githubLogin; + let resolvedToken = accessToken; + let resolvedLogin = session?.githubLogin; - if (accountId !== session.githubId) { + if (accountId !== session?.githubId) { const accountToken = await getAccountToken(appUserId, accountId); if (!accountToken) { @@ -298,7 +300,7 @@ export async function GET(req: NextRequest) { ); const streakData = calculateStreakFromDates(activeDates, freezeDates, timeZone); - if (accountId === session.githubId && streakData.current > 0) { + if (accountId === session?.githubId && streakData.current > 0) { checkAndRecordMilestone(appUserId, streakData.current).catch(() => {}); } diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index eeb497c9c..aa8f021c4 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; @@ -254,10 +255,11 @@ export async function GET(req: NextRequest) { // Session contains the GitHub OAuth token issued at sign-in. // Both accessToken and githubLogin are required for all API calls below. const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - if (session.error === "TokenRevoked") { + if (session?.error === "TokenRevoked") { return githubAuthErrorResponse(); } @@ -266,10 +268,10 @@ export async function GET(req: NextRequest) { // If combined account view is requested if (accountId === "combined") { - if (!session.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -280,16 +282,16 @@ export async function GET(req: NextRequest) { const data = await withMetricsCache({ bypass, key: combinedKey, ttlSeconds: 5 * 60 }, async () => { const accounts = await getAllAccounts( { - token: session.accessToken!, - githubId: session.githubId!, - githubLogin: session.githubLogin!, + token: accessToken!, + githubId: session?.githubId!, + githubLogin: session?.githubLogin!, }, userRow.id ); const summaryPromises = accounts.map(async (acc) => { - const token = acc.githubId === session.githubId - ? session.accessToken + const token = acc.githubId === session?.githubId + ? accessToken : await getAccountToken(userRow.id, acc.githubId); if (!token) return null; return fetchWeeklySummaryForAccount(token, acc.githubLogin, acc.githubId, bypass); @@ -356,15 +358,15 @@ export async function GET(req: NextRequest) { } } - let token = session.accessToken; - let githubLogin = session.githubLogin; - let userId = session.githubId ?? session.githubLogin; + let token = accessToken; + let githubLogin = session?.githubLogin; + let userId = session?.githubId ?? session?.githubLogin; - if (accountId && accountId !== session.githubId) { - if (!session.githubId) { + if (accountId && accountId !== session?.githubId) { + if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session?.githubId, session?.githubLogin); if (!userRow) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/user/github-orgs/route.ts b/src/app/api/user/github-orgs/route.ts index a967d3076..2722842bb 100644 --- a/src/app/api/user/github-orgs/route.ts +++ b/src/app/api/user/github-orgs/route.ts @@ -16,6 +16,7 @@ */ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { type NextRequest, NextResponse } from "next/server"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; @@ -42,7 +43,8 @@ export interface OrgRecord { export async function GET() { const session = await getServerSession(authOptions); - if (!session?.githubId || !session.accessToken) { + const accessToken = await getAccessToken(); + if (!session?.githubId || !accessToken) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -52,7 +54,7 @@ export async function GET() { } // Fetch live org list from GitHub (graceful on missing read:org scope). - const githubOrgs = await fetchUserOrgs(session.accessToken); + const githubOrgs = await fetchUserOrgs(accessToken); // Upsert discovered orgs into the preference table so users can control // per-org inclusion. Existing rows keep their include_in_metrics value. diff --git a/src/app/api/user/orgs/route.ts b/src/app/api/user/orgs/route.ts index 5bc5302c2..a92631770 100644 --- a/src/app/api/user/orgs/route.ts +++ b/src/app/api/user/orgs/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest, NextResponse } from "next/server"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; @@ -16,8 +17,9 @@ interface GitHubOrg { export async function GET() { try { const session = await getServerSession(authOptions); + const accessToken = await getAccessToken(); - if (!session?.githubId || !session?.accessToken) { + if (!session?.githubId || !accessToken) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -40,7 +42,7 @@ export async function GET() { // Get all accounts (primary and linked) to fetch orgs for all of them allAccounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin || "", }, @@ -50,7 +52,7 @@ export async function GET() { // Fallback if Supabase is unavailable (or mock login): use active session details allAccounts = [ { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin || "", orgs: [ diff --git a/src/app/api/user/pinned-repos/details/route.ts b/src/app/api/user/pinned-repos/details/route.ts index 68fba5baa..7c1f057d8 100644 --- a/src/app/api/user/pinned-repos/details/route.ts +++ b/src/app/api/user/pinned-repos/details/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { fetchPinnedRepoDetails } from "@/lib/pinned-repos"; @@ -7,7 +8,8 @@ export const dynamic = "force-dynamic"; export async function GET() { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin || !session.githubId) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin || !session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -16,7 +18,7 @@ export async function GET() { const { data: userRow, error } = await supabaseAdmin .from("users") .select("pinned_repos") - .eq("github_id", session.githubId) + .eq("github_id", session?.githubId) .single(); if (error) { @@ -41,9 +43,9 @@ export async function GET() { // 2. Load fresh repository metadata and 30-day sparkline counts from GitHub API const details = await fetchPinnedRepoDetails( - session.githubLogin, + session?.githubLogin, pinnedReposArray, - session.accessToken + accessToken ); return Response.json({ pinnedRepos: details }); diff --git a/src/app/api/webhooks/dispatch/metrics/route.ts b/src/app/api/webhooks/dispatch/metrics/route.ts index e200a1995..ff669c59e 100644 --- a/src/app/api/webhooks/dispatch/metrics/route.ts +++ b/src/app/api/webhooks/dispatch/metrics/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getAccessToken } from "@/lib/get-session-token"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { resolveAppUser } from "@/lib/resolve-user"; @@ -45,11 +46,12 @@ export async function POST(req: Request) { export async function GET(req: Request) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubId) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); + const user = await resolveAppUser(session?.githubId, session?.githubLogin); if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); const { searchParams } = new URL(req.url); diff --git a/src/app/api/wrapped/route.ts b/src/app/api/wrapped/route.ts index 400e948ac..c8ed72200 100644 --- a/src/app/api/wrapped/route.ts +++ b/src/app/api/wrapped/route.ts @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getAccessToken } from "@/lib/get-session-token"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API, GitHubCommitSearchItem } from "@/lib/github"; @@ -173,7 +174,8 @@ async function fetchTopLanguages(token: string, repos: string[]) { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getAccessToken(); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -183,8 +185,8 @@ export async function GET(req: NextRequest) { try { const { commits, contributionsByDate, hours, totalCommits } = await fetchYearCommits( - session.accessToken, - session.githubLogin, + accessToken, + session?.githubLogin, startDate, endDate ); @@ -192,10 +194,10 @@ export async function GET(req: NextRequest) { (repo) => repo !== "unknown" ); const [topLanguages, prsMerged] = await Promise.all([ - fetchTopLanguages(session.accessToken, repos), + fetchTopLanguages(accessToken, repos), fetchMergedPRCount( - session.accessToken, - session.githubLogin, + accessToken, + session?.githubLogin, startDate, endDate ), @@ -218,7 +220,7 @@ export async function GET(req: NextRequest) { return Response.json({ year, - username: session.githubLogin, + username: session?.githubLogin, totalCommits, activeDays, longestStreak, diff --git a/test/ai-insights-ownership.test.ts b/test/ai-insights-ownership.test.ts index 8726458c6..95a284fa9 100644 --- a/test/ai-insights-ownership.test.ts +++ b/test/ai-insights-ownership.test.ts @@ -10,6 +10,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/ai-weekly-summary.test.ts b/test/ai-weekly-summary.test.ts index 1442ae467..afb973c8e 100644 --- a/test/ai-weekly-summary.test.ts +++ b/test/ai-weekly-summary.test.ts @@ -46,6 +46,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser, diff --git a/test/auth.test.ts b/test/auth.test.ts index ce54f3046..73854bfca 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -162,7 +162,7 @@ describe('auth.ts NextAuth callbacks', () => { }); describe('session callback', () => { - it('populates session.accessToken from token.jwt', async () => { + it('does NOT expose accessToken on the session object (security: token must stay server-side)', async () => { const sessionCallback = authOptions.callbacks?.session; if (!sessionCallback) return; @@ -170,7 +170,9 @@ describe('auth.ts NextAuth callbacks', () => { const token = { accessToken: 'jwt-token-xyz', githubId: '111', githubLogin: 'user1' } as any; const result = await sessionCallback({ session, token, user: {} } as any); - expect((result as any).accessToken).toBe('jwt-token-xyz'); + // session is exposed to client-side code via useSession()/getSession(), + // so the raw GitHub token must never be copied onto it. + expect((result as any).accessToken).toBeUndefined(); }); it('populates session.githubId from token.jwt', async () => { diff --git a/test/ci-metrics.test.ts b/test/ci-metrics.test.ts index 578fd3032..68f9b7d88 100644 --- a/test/ci-metrics.test.ts +++ b/test/ci-metrics.test.ts @@ -15,6 +15,13 @@ vi.mock("@/lib/metrics-cache", () => ({ withMetricsCache: vi.fn(), })); vi.mock("next-auth", () => ({ getServerSession: vi.fn() })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); describe("CI Metrics Route Helpers", () => { diff --git a/test/contributions-hourly.test.ts b/test/contributions-hourly.test.ts index 91803e66a..9d21af4a7 100644 --- a/test/contributions-hourly.test.ts +++ b/test/contributions-hourly.test.ts @@ -11,6 +11,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/metrics-cache", () => ({ isMetricsCacheBypassed: mocks.isMetricsCacheBypassed, diff --git a/test/data-export.test.ts b/test/data-export.test.ts index cb4036645..d97237156 100644 --- a/test/data-export.test.ts +++ b/test/data-export.test.ts @@ -22,6 +22,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/debug-health.test.ts b/test/debug-health.test.ts index d60010f05..a441db9e0 100644 --- a/test/debug-health.test.ts +++ b/test/debug-health.test.ts @@ -33,6 +33,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/supabase", () => ({ supabaseAdmin: { from: mocks.supabaseFrom }, diff --git a/test/github-accounts-api.test.ts b/test/github-accounts-api.test.ts index 893b2f5ee..41523841e 100644 --- a/test/github-accounts-api.test.ts +++ b/test/github-accounts-api.test.ts @@ -9,6 +9,13 @@ import { resolveAppUser } from "@/lib/resolve-user"; vi.mock("next-auth", () => ({ getServerSession: vi.fn(), })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); // Mock resolve-user vi.mock("@/lib/resolve-user", () => ({ diff --git a/test/github-auth-metrics.test.ts b/test/github-auth-metrics.test.ts index 6d01432cd..4f6608675 100644 --- a/test/github-auth-metrics.test.ts +++ b/test/github-auth-metrics.test.ts @@ -18,6 +18,13 @@ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); vi.mock("next-auth", () => ({ getServerSession: vi.fn() })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/metrics-cache", () => ({ diff --git a/test/github-orgs.test.ts b/test/github-orgs.test.ts index 80b7023b2..1780386ee 100644 --- a/test/github-orgs.test.ts +++ b/test/github-orgs.test.ts @@ -24,6 +24,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser, diff --git a/test/goals-crud.test.ts b/test/goals-crud.test.ts index c46242145..f70220589 100644 --- a/test/goals-crud.test.ts +++ b/test/goals-crud.test.ts @@ -10,6 +10,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/goals-patch-integrity.test.ts b/test/goals-patch-integrity.test.ts index abcc1c933..e4cb46c43 100644 --- a/test/goals-patch-integrity.test.ts +++ b/test/goals-patch-integrity.test.ts @@ -11,6 +11,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/goals-sync.test.ts b/test/goals-sync.test.ts index 1c199c8a9..143502874 100644 --- a/test/goals-sync.test.ts +++ b/test/goals-sync.test.ts @@ -24,6 +24,14 @@ vi.mock("@/lib/auth", () => ({ authOptions: {}, })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); + vi.mock("@/lib/supabase", () => ({ supabaseAdmin: { from: vi.fn(), @@ -701,4 +709,4 @@ describe("POST /api/goals/sync", () => { expect(updateChain.update).toHaveBeenCalledTimes(2); }); }); -}); \ No newline at end of file +}); diff --git a/test/jira-credentials.test.ts b/test/jira-credentials.test.ts index 82d49525c..56db8e8dd 100644 --- a/test/jira-credentials.test.ts +++ b/test/jira-credentials.test.ts @@ -36,6 +36,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/leaderboard-cache-invalidation.test.ts b/test/leaderboard-cache-invalidation.test.ts index 524cde0fc..dcbd0933b 100644 --- a/test/leaderboard-cache-invalidation.test.ts +++ b/test/leaderboard-cache-invalidation.test.ts @@ -36,6 +36,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/crypto", () => ({ encryptToken: vi.fn() })); diff --git a/test/local-coding-auth-regression.test.ts b/test/local-coding-auth-regression.test.ts index 3e8fde658..a17d30219 100644 --- a/test/local-coding-auth-regression.test.ts +++ b/test/local-coding-auth-regression.test.ts @@ -37,6 +37,13 @@ const m = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: m.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: m.resolveAppUser })); diff --git a/test/local-coding-keys.test.ts b/test/local-coding-keys.test.ts index ea1abac70..065e740dd 100644 --- a/test/local-coding-keys.test.ts +++ b/test/local-coding-keys.test.ts @@ -16,6 +16,13 @@ const mocks = vi.hoisted(() => ({ vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession, })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {}, diff --git a/test/local-coding-stats.test.ts b/test/local-coding-stats.test.ts index a35d0a861..da5a06edf 100644 --- a/test/local-coding-stats.test.ts +++ b/test/local-coding-stats.test.ts @@ -15,6 +15,13 @@ const mocks = vi.hoisted(() => ({ vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession, })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {}, diff --git a/test/metrics-languages.test.ts b/test/metrics-languages.test.ts index 95e83d5c3..498eefeb0 100644 --- a/test/metrics-languages.test.ts +++ b/test/metrics-languages.test.ts @@ -10,6 +10,14 @@ vi.mock('next-auth', () => ({ getServerSession: vi.fn(), })); +vi.mock('@/lib/get-session-token', () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import('next-auth'); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); + // Mock resolve-user vi.mock('@/lib/resolve-user', () => ({ resolveAppUser: vi.fn(), diff --git a/test/repo-analytics-validation.test.ts b/test/repo-analytics-validation.test.ts index 7d6366e95..36d101ded 100644 --- a/test/repo-analytics-validation.test.ts +++ b/test/repo-analytics-validation.test.ts @@ -15,6 +15,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/metrics-cache", () => ({ isMetricsCacheBypassed: mocks.isMetricsCacheBypassed, diff --git a/test/repos-api-db-failure.test.ts b/test/repos-api-db-failure.test.ts index 7c213dcc9..b705d9eb1 100644 --- a/test/repos-api-db-failure.test.ts +++ b/test/repos-api-db-failure.test.ts @@ -10,6 +10,13 @@ import { supabaseAdmin } from "@/lib/supabase"; vi.mock("next-auth", () => ({ getServerSession: vi.fn(), })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); // Mock resolve-user vi.mock("@/lib/resolve-user", () => ({ diff --git a/test/rooms-messages.test.ts b/test/rooms-messages.test.ts index 048a44c84..eebdfb7e2 100644 --- a/test/rooms-messages.test.ts +++ b/test/rooms-messages.test.ts @@ -22,6 +22,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/supabase-rooms", () => ({ getRoomById: mocks.getRoomById, diff --git a/test/sse-stream-route.test.ts b/test/sse-stream-route.test.ts index 72716e34a..90d91420f 100644 --- a/test/sse-stream-route.test.ts +++ b/test/sse-stream-route.test.ts @@ -13,6 +13,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/token-expired.test.ts b/test/token-expired.test.ts index adee6e36c..70df9ee8c 100644 --- a/test/token-expired.test.ts +++ b/test/token-expired.test.ts @@ -20,6 +20,13 @@ vi.stubGlobal("fetch", mockFetch); vi.mock("next-auth", () => ({ getServerSession: vi.fn(), })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {}, diff --git a/test/user-export.test.ts b/test/user-export.test.ts index 4ff1be15c..ef37dfd73 100644 --- a/test/user-export.test.ts +++ b/test/user-export.test.ts @@ -22,6 +22,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); vi.mock("@/lib/supabase", () => ({ diff --git a/test/user-settings-api.test.ts b/test/user-settings-api.test.ts index 589861411..e9b32043b 100644 --- a/test/user-settings-api.test.ts +++ b/test/user-settings-api.test.ts @@ -8,6 +8,13 @@ import { resolveAppUser } from "@/lib/resolve-user"; vi.mock("next-auth", () => ({ getServerSession: vi.fn(), })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); // Mock resolve-user vi.mock("@/lib/resolve-user", () => ({ diff --git a/test/weekly-summary-combined.test.ts b/test/weekly-summary-combined.test.ts index d02c28ef0..d6eafb272 100644 --- a/test/weekly-summary-combined.test.ts +++ b/test/weekly-summary-combined.test.ts @@ -9,6 +9,13 @@ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); vi.mock("next-auth", () => ({ getServerSession: vi.fn() })); +vi.mock("@/lib/get-session-token", () => ({ + getAccessToken: vi.fn(async () => { + const { getServerSession } = await import("next-auth"); + const session = await (getServerSession as any)(); + return session?.accessToken ?? null; + }), +})); vi.mock("@/lib/auth", () => ({ authOptions: {} })); vi.mock("@/lib/metrics-cache", () => ({