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 1911e1b86..fd141e332 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 6605990c8..964ddc598 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"; @@ -18,7 +19,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 }); } @@ -33,7 +35,7 @@ export async function GET(req: NextRequest) { } if (username === "me") { - username = session.githubLogin as string; + username = session?.githubLogin as string; } const normalizedUsername = normalizeGitHubUsername(username); @@ -52,7 +54,7 @@ export async function GET(req: NextRequest) { const encodedUsername = encodeURIComponent(normalizedUsername); const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); @@ -83,7 +85,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", @@ -133,7 +135,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", }); @@ -157,7 +159,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 1426870dc..c3149ddf9 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"; @@ -109,14 +110,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) { @@ -134,23 +136,25 @@ export async function GET(req: NextRequest) { } if (!accountId) { - try { - const activeDates = await fetchActiveDates( - session.githubLogin, - session.accessToken, - { bypass, userId: session.githubId }, - timeZone - ); - const result = await getConsistencyScoreForDates(activeDates, timeZone, { - bypass, - userId: session.githubId, - accountKey: "default", - }); - return Response.json(result); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } + try { + const activeDates = await fetchActiveDates( + session?.githubLogin, + accessToken, + { bypass, userId: session?.githubId }, + timeZone, + ); + + const result = await getConsistencyScoreForDates(activeDates, timeZone, { + bypass, + userId: session?.githubId, + accountKey: "default", + }); + + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); } +} if (!appUserId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -159,9 +163,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 ); @@ -197,10 +201,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 225440241..7e6fcc9ad 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 { @@ -363,7 +364,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 }); } @@ -391,7 +393,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 }); @@ -412,12 +414,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; @@ -436,10 +438,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, @@ -455,10 +457,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, @@ -472,7 +474,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); @@ -481,11 +483,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 }); @@ -494,9 +496,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 ); @@ -553,19 +555,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, @@ -579,7 +581,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/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/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 f3d1ccb2f..5641d2b16 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"; @@ -146,7 +147,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 }); } @@ -154,7 +156,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 @@ -200,9 +202,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); @@ -226,9 +228,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 ); @@ -264,10 +266,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) { @@ -301,7 +303,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 f799ef316..7d47fdebe 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"; @@ -345,10 +346,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(); } @@ -357,10 +359,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 }); } @@ -371,16 +373,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); @@ -491,15 +493,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/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; +} 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/response-cache.test.ts b/test/response-cache.test.ts index 8b4707d6e..731217b61 100644 --- a/test/response-cache.test.ts +++ b/test/response-cache.test.ts @@ -1,57 +1,35 @@ -import { describe, it, expect } from "vitest"; -import { privateCacheHeaders, publicCacheHeaders } from "../src/lib/response-cache"; - -describe("privateCacheHeaders", () => { - it("returns correct headers with default values", () => { - const headers = privateCacheHeaders(); - expect(headers["Cache-Control"]).toBe("private, max-age=300, stale-while-revalidate=600"); - }); - - it("returns correct headers with custom maxAgeSeconds", () => { - const headers = privateCacheHeaders(600); - expect(headers["Cache-Control"]).toBe("private, max-age=600, stale-while-revalidate=1200"); - }); - - it("returns correct headers with custom swrSeconds", () => { - const headers = privateCacheHeaders(300, 900); - expect(headers["Cache-Control"]).toBe("private, max-age=300, stale-while-revalidate=900"); - }); - - it("returns correct headers with zero values", () => { - const headers = privateCacheHeaders(0, 0); - expect(headers["Cache-Control"]).toBe("private, max-age=0, stale-while-revalidate=0"); - }); -}); - -describe("publicCacheHeaders", () => { - it("returns correct headers with default values", () => { - const headers = publicCacheHeaders(); - expect(headers["Cache-Control"]).toBe("public, s-maxage=300, stale-while-revalidate=600"); - }); - - it("returns correct headers with custom maxAgeSeconds", () => { - const headers = publicCacheHeaders(600); - expect(headers["Cache-Control"]).toBe("public, s-maxage=600, stale-while-revalidate=1200"); - }); - - it("returns correct headers with custom swrSeconds", () => { - const headers = publicCacheHeaders(300, 900); - expect(headers["Cache-Control"]).toBe("public, s-maxage=300, stale-while-revalidate=900"); - }); - - it("returns correct headers with zero values", () => { - const headers = publicCacheHeaders(0, 0); - expect(headers["Cache-Control"]).toBe("public, s-maxage=0, stale-while-revalidate=0"); - }); - - it("includes s-maxage for public caching", () => { - const headers = publicCacheHeaders(); - expect(headers["Cache-Control"]).toContain("s-maxage="); - expect(headers["Cache-Control"]).not.toContain("private"); - }); - - it("does not include private for public caching", () => { - const headers = publicCacheHeaders(); - expect(headers["Cache-Control"]).not.toContain("private"); +import { describe, expect, it } from "vitest"; +import { + privateCacheHeaders, + publicCacheHeaders, +} from "../src/lib/response-cache"; + +describe("response-cache header helpers", () => { + it("returns private cache headers", () => { + expect(privateCacheHeaders(300)).toEqual({ + "Cache-Control": + "private, max-age=300, stale-while-revalidate=600", + }); + }); + + it("supports custom stale-while-revalidate", () => { + expect(privateCacheHeaders(300, 60)).toEqual({ + "Cache-Control": + "private, max-age=300, stale-while-revalidate=60", + }); + }); + + it("returns public cache headers", () => { + expect(publicCacheHeaders(300)).toEqual({ + "Cache-Control": + "public, s-maxage=300, stale-while-revalidate=600", + }); + }); + + it("supports zero max age", () => { + expect(publicCacheHeaders(0)).toEqual({ + "Cache-Control": + "public, s-maxage=0, stale-while-revalidate=0", + }); }); }); 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", () => ({