diff --git a/.github/workflows/automated-tests.yml b/.github/workflows/automated-tests.yml index 22438c06d..c67f7c3cc 100644 --- a/.github/workflows/automated-tests.yml +++ b/.github/workflows/automated-tests.yml @@ -15,15 +15,13 @@ jobs: - name: Checkout Code Repository Layers uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - - name: Initialize Node.js Environment uses: actions/setup-node@v6 with: node-version: 22 - cache: 'pnpm' + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install Project Dependencies (Clean Architecture Ingest) run: pnpm install --frozen-lockfile @@ -33,4 +31,3 @@ jobs: - name: Execute Security & Code Quality Monitoring Audits run: echo "Code coverage thresholds verified at 80%+. Monitoring data packages fully initialized." - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 428bb6922..b4a780194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,12 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile @@ -39,13 +38,12 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile @@ -62,13 +60,12 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile @@ -108,13 +105,12 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile @@ -128,13 +124,12 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 99da69366..b2dcc9c38 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -28,13 +28,12 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install app dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 401300788..311b20a8f 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -42,15 +42,13 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - version: 11 - - name: Setup Node uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.9.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/Dockerfile b/Dockerfile index 800716ddc..354f8ec99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,16 @@ -FROM node:20-alpine AS base +FROM node:22-alpine AS base +RUN corepack enable && corepack prepare pnpm@11.9.0 --activate # Install dependencies only when needed FROM base AS deps -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache libc6-compat python3 make g++ WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json package-lock.json* pnpm-lock.yaml* ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN \ - if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + if [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ else echo "Lockfile not found." && exit 1; \ fi @@ -18,12 +19,12 @@ RUN \ FROM base AS development WORKDIR /app -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache libc6-compat python3 make g++ -COPY package.json package-lock.json* pnpm-lock.yaml* ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN \ - if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install; \ + if [ -f pnpm-lock.yaml ]; then pnpm install; \ elif [ -f package-lock.json ]; then npm install; \ else echo "Lockfile not found." && exit 1; \ fi @@ -48,7 +49,7 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN \ - if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + if [ -f pnpm-lock.yaml ]; then pnpm run build; \ elif [ -f package-lock.json ]; then npm run build; \ else echo "Lockfile not found." && exit 1; \ fi @@ -76,4 +77,4 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME=0.0.0.0 -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 93184d72a..e11b4dd2a 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,189 @@ The full walkthrough — Supabase migrations, OAuth app setup, every environment --- +## Docker Development Setup + +DevTrack includes Docker support for local development, allowing contributors to get started quickly without manually installing dependencies or configuring environments. + +### Prerequisites + +- Docker Desktop (Windows/macOS) or Docker Engine (Linux) +- Docker Compose v2+ + +Verify installation: + +```bash +docker --version +docker compose version +``` + +### Configure Environment Variables + +Copy the example environment file: + +```bash +cp .env.example .env.local +``` + +Fill in the required values as described in the Environment Variables section above. + +### Start the Application + +Build and start the development container: + +```bash +docker compose up --build +``` + +The application will be available at: + +```text +http://localhost:3000 +``` + +### Stop the Application + +```bash +docker compose down +``` + +### Hot Reload Support + +The project source code is mounted into the container using Docker volumes. + +Any changes made to files on your host machine are automatically reflected inside the container, enabling Next.js hot reload during development without rebuilding the image. + +### Rebuild After Dependency Changes + +If you modify `package.json` or install new dependencies: + +```bash +docker compose down +docker compose up --build +``` + +### Troubleshooting + +Remove containers and rebuild from scratch: + +```bash +docker compose down -v +docker compose up --build +``` + +View container logs: + +```bash +docker compose logs -f +``` + +Common self-hosting build issues (pnpm/Corepack mismatch, `ERR_PNPM_IGNORED_BUILDS`, missing native build tools, GitHub OAuth `error=github`) are documented in [docs/self-hosting.md](docs/self-hosting.md#docker-build-troubleshooting). Set `AUTH_DEBUG=true` in your env file for detailed NextAuth logs during OAuth setup. + +--- + +## FAQ + +Answers to questions new contributors and self-hosters ask most often. For deeper setup detail, see [DEVELOPMENT.md](./DEVELOPMENT.md) and the [Self-Hosting Guide](./docs/self-hosting.md). + +
+How do I configure GitHub OAuth? + +1. Go to [GitHub → Settings → Developer Settings → OAuth Apps](https://github.com/settings/applications/new) and create a new OAuth App. +2. Set the **Authorization callback URL** to: + - Local dev: `http://localhost:3000/api/auth/callback/github` + - Production: `https:///api/auth/callback/github` +3. Copy the generated **Client ID** and **Client Secret** into `GITHUB_ID` and `GITHUB_SECRET` in `.env.local`. +4. Make sure `NEXTAUTH_URL` matches the base URL you're running on, and `NEXTAUTH_SECRET` is set (generate one with `openssl rand -base64 32`). + +
+ +
+Why is login not working? + +This is almost always one of the following: + +- **Callback URL mismatch** — the URL registered on your GitHub OAuth App must exactly match `NEXTAUTH_URL` + `/api/auth/callback/github`, including protocol and trailing slashes. +- **Missing/incorrect `NEXTAUTH_SECRET`** — sessions will silently fail without it. +- **Stale `.env.local`** — restart `pnpm dev` after changing any auth-related env vars; Next.js doesn't hot-reload env files. +- **Supabase RLS blocking the user row** — check that the relevant migrations from `supabase/migrations/` have been applied in order. + +If the issue persists, check your browser console and terminal logs for the specific NextAuth error code, then search/open a [Discussion](https://github.com/Priyanshu-byte-coder/devtrack/discussions). + +
+ +
+How do I obtain Supabase credentials? + +1. Create a free project at [supabase.com](https://supabase.com). +2. Open **Project Settings → API**. +3. Copy: + - **Project URL** → `NEXT_PUBLIC_SUPABASE_URL` + - **anon public key** → `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - **service_role key** → `SUPABASE_SERVICE_ROLE_KEY` (server-side only — never expose this in client code or commit it) +4. Run all SQL files in `supabase/migrations/` via the Supabase SQL editor, in order, before starting the app. + +
+ +
+Why are GitHub metrics not loading? + +- You're likely hitting **GitHub's unauthenticated API rate limit**. Set the optional `GITHUB_TOKEN` (a personal access token) in `.env.local` to raise the limit significantly. +- Check that your GitHub OAuth scopes were granted during sign-in — if you denied a permission, re-authenticate by signing out and back in. +- If you're self-hosting behind a proxy or firewall, confirm outbound requests to `api.github.com` aren't being blocked. +- Look at the server logs/terminal for the specific API error (403 usually means rate-limited; 401 means a token problem). + +
+ +
+How do I run tests? + +```bash +# Unit tests +pnpm test + +# End-to-end tests (Playwright, first-time setup) +npx playwright install --with-deps chromium +pnpm run test:e2e + +# Run a single e2e spec +npx playwright test e2e/goals.spec.ts + +# Visual regression tests +npx playwright test -c playwright.visual.config.mjs +``` + +E2E tests use mocked external calls (no real GitHub/Supabase credentials needed) and also run automatically on every PR via `.github/workflows/e2e.yml`. + +
+ +
+How can I contribute? + +1. Browse [open issues](https://github.com/Priyanshu-byte-coder/devtrack/issues) and start with one labeled `good first issue`. +2. Comment on the issue to get assigned before starting work. +3. Fork the repo, branch off `main` (e.g. `feat/issue-42-description`), and open a PR. +4. Before pushing, make sure CI passes locally: + ```bash + pnpm run lint && pnpm run type-check + ``` +5. See [CONTRIBUTING.md](./CONTRIBUTING.md) for commit message style, branch naming conventions, and the review process. + +Questions are welcome anytime in [Discussions](https://github.com/Priyanshu-byte-coder/devtrack/discussions). + +
+ +
+Which Node.js and pnpm versions are supported? + +| Tool | Version | Check | +|------|---------|-------| +| Node.js | >= 20 | `node -v` | +| pnpm | >= 9 | `pnpm -v` | +| Git | any | `git --version` | + +Install pnpm via `corepack enable` or `npm install -g pnpm` if you don't already have it. + +If your local versions differ and you hit install/build errors, aligning your Node.js/pnpm version with the table above is the first thing to check. ## Tech Stack | Layer | Technology | diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 1b38cf93b..d3f98830e 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -53,6 +53,7 @@ DevTrack uses `@supabase/supabase-js` which relies on the Supabase REST API, so | `WAKATIME_CLIENT_ID` | Optional | WakaTime OAuth Client ID (for WakaTime integration) | `waka_...` | | `WAKATIME_CLIENT_SECRET` | Optional | WakaTime OAuth Client Secret | `waka_sec_...` | | `ALLOWED_ORIGINS` | Optional | Comma-separated extra origins allowed for CSRF validation | `https://staging.example.com` | +| `AUTH_DEBUG` | Optional | Set to `true` for verbose NextAuth OAuth logs (self-hosting troubleshooting) | `true` | --- @@ -96,6 +97,8 @@ When running DevTrack with Docker, set the following environment variables in yo ``` 5. DevTrack will be available at `http://localhost:3000`. +> **Note:** The bundled `docker-compose.yml` targets the `development` stage and runs `npm run dev` with hot reload. For a production-style container, build the `production` target from the Dockerfile directly (see [Troubleshooting](#docker-build-troubleshooting)). + --- ## 2. Railway @@ -129,6 +132,25 @@ DevTrack includes a `render.yaml` Blueprint for easy deployment on Render's free ## 🔧 Troubleshooting +### Docker build troubleshooting + +- **`pnpm` / Corepack version mismatch during `docker compose build`**: + The Dockerfile uses Node 22 with pnpm pinned via Corepack (`corepack prepare pnpm@11.9.0 --activate`). CI uses the same Corepack pin. Pull the latest code and rebuild with `docker compose build --no-cache`. + +- **`ERR_PNPM_IGNORED_BUILDS` / `pnpm approve-builds`**: + Build-script approval is configured non-interactively in `pnpm-workspace.yaml` (`allowBuilds`). The Dockerfile copies this file before `pnpm install`. If you see this error on an older clone, update and rebuild with `--no-cache`. Do not run interactive `pnpm approve-builds` inside CI or Docker builds. + +- **Native module build failures (`sharp`, `esbuild`, etc.)**: + The Dockerfile installs `python3`, `make`, and `g++` in the deps/development stages for Alpine. Rebuild with `docker compose build --no-cache` after pulling fixes. + +- **GitHub OAuth redirects with `error=github`**: + 1. Confirm `GITHUB_ID` and `GITHUB_SECRET` match your GitHub OAuth app. + 2. Set `NEXTAUTH_URL` to your public URL with no trailing slash (e.g. `https://devtrack.example.com`). + 3. Set the GitHub OAuth **Authorization callback URL** to `/api/auth/callback/github`. + 4. Enable verbose auth logging: set `AUTH_DEBUG=true` in your `.env` and inspect container logs (`docker compose logs -f`). Look for `[auth]` and `[nextauth]` lines. + +### General troubleshooting + - **Server Error 500 on Login**: Make sure your `NEXTAUTH_SECRET` and `ENCRYPTION_KEY` are set. If `ENCRYPTION_KEY` is missing or the wrong length (must be 32 bytes / 64 hex chars), the OAuth callback will crash when attempting to encrypt the GitHub token. - **Login Redirects back to Home Page infinitely**: diff --git a/package.json b/package.json index 7a4549cba..822433ea1 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "js-yaml": ">=4.1.0" }, "engines": { - "node": "20.x" + "node": "20.x || 22.x" }, "optionalDependencies": { "@esbuild/linux-x64": "^0.28.0", @@ -111,21 +111,5 @@ "@parcel/watcher-linux-x64-glibc": "^2.5.6", "@parcel/watcher-linux-x64-musl": "^2.5.6", "@swc/core-linux-x64-gnu": "^1.15.43" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "@parcel/watcher", - "@scarf/scarf", - "@sentry/cli", - "@swc/core", - "@tree-sitter-grammars/tree-sitter-yaml", - "core-js-pure", - "core-js", - "esbuild", - "sharp", - "tree-sitter-json", - "tree-sitter", - "unrs-resolver" - ] } } diff --git a/public/openapi.yaml b/public/openapi.yaml index 01f56c03f..b8e45ea0a 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -865,8 +865,10 @@ paths: type: object '401': description: Unauthorized + '429': + description: Rate limit exceeded '500': - description: AI service unavailable (GROQ_API_KEY not configured) + description: AI service unavailable (GEMINI_API_KEY not configured) /api/ai/weekly-summary: post: @@ -890,6 +892,47 @@ paths: '401': description: Unauthorized + /api/project-tutor: + post: + tags: [AI] + summary: AI Project Tutor powered by Groq + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - repoUrl + - action + properties: + repoUrl: + type: string + maxLength: 200 + action: + type: string + enum: [analyze, questions, chat] + question: + type: string + maxLength: 500 + responses: + '200': + description: Tutor response generated successfully + content: + application/json: + schema: + type: object + '400': + description: Invalid request parameters + '401': + description: Unauthorized + '429': + description: Rate limit exceeded + '500': + description: Internal server error / Groq API unavailable + # WAKATIME INTEGRATION /api/wakatime: get: diff --git a/src/app/api/ai/roast/route.ts b/src/app/api/ai/roast/route.ts index 2eb468a8c..50d4b0bcf 100644 --- a/src/app/api/ai/roast/route.ts +++ b/src/app/api/ai/roast/route.ts @@ -1,11 +1,52 @@ import { NextResponse } from 'next/server'; import { GoogleGenerativeAI } from '@google/generative-ai'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { resolveAppUser } from '@/lib/resolve-user'; +import { + upstashRateLimitFixedWindow, + getUpstashConfig, + upstashPipeline, +} from '@/lib/upstash-rest'; +import { createMemoryFixedWindowRateLimiter } from '@/lib/rate-limit'; +import crypto from 'crypto'; + +export const dynamic = 'force-dynamic'; // Initialize the Google Generative AI SDK const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ''); +const ROAST_LIMIT = 5; +const ROAST_WINDOW_SECONDS = 60 * 60; // 1 hour +const CACHE_TTL_SECONDS = 5 * 60; // 5 minutes + +// In-memory fallback rate limiter +const memoryLimiter = createMemoryFixedWindowRateLimiter({ + windowMs: ROAST_WINDOW_SECONDS * 1000, + pruneIntervalMs: ROAST_WINDOW_SECONDS * 1000, + maxEntries: 10_000, +}); + +// In-memory fallback response cache +type CacheEntry = { value: string; expiresAt: number }; +const localCache = new Map(); + export async function POST(req: Request) { try { + // 1. Session check + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // 2. Resolve application user + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + const userId = user.id; + + // 3. Parse and validate body const body = await req.json(); const { mode, stats } = body; @@ -16,15 +57,83 @@ export async function POST(req: Request) { ); } + if (mode !== 'roast' && mode !== 'hype') { + return NextResponse.json({ error: 'Invalid mode.' }, { status: 400 }); + } + + // 4. Generate stats hash and check cache + const sortedLanguages = stats.languages ? [...stats.languages].sort() : []; + const statsString = JSON.stringify({ + commits: stats.commits || 0, + languages: sortedLanguages, + mergedPRs: stats.mergedPRs || 0, + failedGoals: stats.failedGoals || 0, + }); + const statsHash = crypto.createHash('sha256').update(statsString).digest('hex'); + const cacheKey = `roast-cache:${userId}:${mode}:${statsHash}`; + + // Read cache (Upstash Redis or in-memory) + let cachedMessage: string | null = null; + if (getUpstashConfig()) { + try { + const results = await upstashPipeline([['GET', cacheKey]]); + cachedMessage = (results[0]?.result as string) || null; + } catch (err) { + console.error('Failed to read roast from Upstash Redis:', err); + } + } else { + const entry = localCache.get(cacheKey); + if (entry && Date.now() <= entry.expiresAt) { + cachedMessage = entry.value; + } else if (entry) { + localCache.delete(cacheKey); + } + } + + if (cachedMessage) { + return NextResponse.json({ message: cachedMessage, cached: true }); + } + + // 5. Rate limiting check + let rateLimitDenied = false; + let retryAfterSeconds = ROAST_WINDOW_SECONDS; + + if (getUpstashConfig()) { + const result = await upstashRateLimitFixedWindow({ + key: `roast-limit:${userId}`, + limit: ROAST_LIMIT, + windowSeconds: ROAST_WINDOW_SECONDS, + }); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = result.retryAfter ?? ROAST_WINDOW_SECONDS; + } + } else { + const result = memoryLimiter.check(`roast-limit:${userId}`, ROAST_LIMIT); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = Math.max(result.reset - Math.floor(Date.now() / 1000), 1); + } + } + + if (rateLimitDenied) { + return NextResponse.json( + { error: 'Rate limit exceeded. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds) }, + } + ); + } + + // 6. Gemini Generation const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); let systemInstruction = ''; if (mode === 'roast') { systemInstruction = `You are a hilariously brutal, sarcastic senior developer reviewing a junior's code stats. Roast their coding habits, commit streaks, or languages used based on the provided stats. Keep it strictly safe for work (SFW), funny, and under 3 sentences. No cursing.`; - } else if (mode === 'hype') { - systemInstruction = `You are the ultimate enthusiastic developer hype-man. Look at the user's coding stats and hype them up! Make them feel like a 10x coding god. Keep it energetic, modern, and under 3 sentences.`; } else { - return NextResponse.json({ error: 'Invalid mode.' }, { status: 400 }); + systemInstruction = `You are the ultimate enthusiastic developer hype-man. Look at the user's coding stats and hype them up! Make them feel like a 10x coding god. Keep it energetic, modern, and under 3 sentences.`; } const prompt = ` @@ -40,9 +149,27 @@ export async function POST(req: Request) { `; const result = await model.generateContent(prompt); - const responseText = result.response.text(); + const responseText = (result.response.text() || '').trim(); + + if (!responseText) { + throw new Error('Empty response received from Gemini.'); + } + + // 7. Write to cache + if (getUpstashConfig()) { + try { + await upstashPipeline([['SET', cacheKey, responseText, 'EX', CACHE_TTL_SECONDS]]); + } catch (err) { + console.error('Failed to write roast to Upstash Redis:', err); + } + } else { + localCache.set(cacheKey, { + value: responseText, + expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000, + }); + } - return NextResponse.json({ message: responseText.trim() }); + return NextResponse.json({ message: responseText }); } catch (error) { console.error('Gemini API Error:', error); return NextResponse.json( diff --git a/src/app/api/project-tutor/route.ts b/src/app/api/project-tutor/route.ts index a9d803b15..38e4338f0 100644 --- a/src/app/api/project-tutor/route.ts +++ b/src/app/api/project-tutor/route.ts @@ -1,8 +1,67 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { + upstashRateLimitFixedWindow, + getUpstashConfig, + upstashPipeline, +} from "@/lib/upstash-rest"; +import { createMemoryFixedWindowRateLimiter } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; +const PROJECT_TUTOR_LIMIT = 10; +const PROJECT_TUTOR_WINDOW_SECONDS = 60 * 60; // 1 hour +const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6 hours + +// In-memory fallback rate limiter +const memoryLimiter = createMemoryFixedWindowRateLimiter({ + windowMs: PROJECT_TUTOR_WINDOW_SECONDS * 1000, + pruneIntervalMs: PROJECT_TUTOR_WINDOW_SECONDS * 1000, + maxEntries: 10_000, +}); + +// In-memory fallback response cache +type CacheEntry = { value: string; expiresAt: number }; +const localCache = new Map(); + +async function getCachedData(key: string): Promise { + if (getUpstashConfig()) { + try { + const results = await upstashPipeline([["GET", key]]); + const val = results[0]?.result as string; + return val ? JSON.parse(val) : null; + } catch (err) { + console.error("Failed to read from Upstash cache:", err); + } + } else { + const entry = localCache.get(key); + if (entry && Date.now() <= entry.expiresAt) { + return JSON.parse(entry.value); + } else if (entry) { + localCache.delete(key); + } + } + return null; +} + +async function setCachedData(key: string, value: any, ttlSeconds: number) { + const str = JSON.stringify(value); + if (getUpstashConfig()) { + try { + await upstashPipeline([["SET", key, str, "EX", ttlSeconds]]); + } catch (err) { + console.error("Failed to write to Upstash cache:", err); + } + } else { + localCache.set(key, { + value: str, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + } +} async function callGroq(prompt: string): Promise { const apiKey = process.env.GROQ_API_KEY; @@ -56,13 +115,21 @@ async function fetchRepoData(owner: string, repo: string) { export async function POST(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!session?.githubId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const userId = user.id; try { const { repoUrl, action, question } = await req.json(); if (!repoUrl) return NextResponse.json({ error: "repo URL required" }, { status: 400 }); + if (repoUrl.length > 200) { + return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); + } + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); if (!match) return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); @@ -71,6 +138,54 @@ export async function POST(req: NextRequest) { const githubIdentifier = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,97}[a-zA-Z0-9])?$/; if (!githubIdentifier.test(owner) || !githubIdentifier.test(repo.replace(/\.git$/, ""))) return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); + + if (action === "chat") { + if (!question || typeof question !== "string" || question.length > 500) { + return NextResponse.json({ error: "Invalid or too long question" }, { status: 400 }); + } + } + + // Cache check for "analyze" and "questions" actions + const cacheKey = `project-tutor-cache:${owner.toLowerCase()}:${repo.toLowerCase()}:${action}`; + if (action === "analyze" || action === "questions") { + const cached = await getCachedData(cacheKey); + if (cached) { + return NextResponse.json({ ...cached, cached: true }); + } + } + + // Rate limit check + let rateLimitDenied = false; + let retryAfterSeconds = PROJECT_TUTOR_WINDOW_SECONDS; + + if (getUpstashConfig()) { + const result = await upstashRateLimitFixedWindow({ + key: `project-tutor:${userId}`, + limit: PROJECT_TUTOR_LIMIT, + windowSeconds: PROJECT_TUTOR_WINDOW_SECONDS, + }); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = result.retryAfter ?? PROJECT_TUTOR_WINDOW_SECONDS; + } + } else { + const result = memoryLimiter.check(`project-tutor:${userId}`, PROJECT_TUTOR_LIMIT); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = Math.max(result.reset - Math.floor(Date.now() / 1000), 1); + } + } + + if (rateLimitDenied) { + return NextResponse.json( + { error: "Rate limit exceeded. Try again later." }, + { + status: 429, + headers: { "Retry-After": String(retryAfterSeconds) }, + } + ); + } + const { repoData, readmeContent, languages } = await fetchRepoData(owner, repo); const techStack = Object.keys(languages).join(", ") || "Unknown"; @@ -103,7 +218,9 @@ ${context} Format your response with clear headings using markdown.`; const analysis = await callGroq(prompt); - return NextResponse.json({ analysis, techStack, description: repoData.description }); + const resData = { analysis, techStack, description: repoData.description }; + await setCachedData(cacheKey, resData, CACHE_TTL_SECONDS); + return NextResponse.json(resData); } if (action === "questions") { @@ -129,19 +246,20 @@ Format as JSON: Return ONLY the JSON, no other text.`; const raw = await callGroq(prompt); + let questions; try { const clean = raw.replace(/```json|```/g, "").trim(); - const questions = JSON.parse(clean); - return NextResponse.json({ questions }); + questions = JSON.parse(clean); } catch { - return NextResponse.json({ - questions: { - easy: ["What is the main purpose of this project?", "What technologies did you use?", "How do you run this project locally?"], - medium: ["How did you structure your codebase?", "What was the most challenging part?", "How did you handle errors?"], - advanced: ["How would you scale this?", "What would you do differently?", "How would you add authentication?"], - } - }); + questions = { + easy: ["What is the main purpose of this project?", "What technologies did you use?", "How do you run this project locally?"], + medium: ["How did you structure your codebase?", "What was the most challenging part?", "How did you handle errors?"], + advanced: ["How would you scale this?", "What would you do differently?", "How would you add authentication?"], + }; } + const resData = { questions }; + await setCachedData(cacheKey, resData, CACHE_TTL_SECONDS); + return NextResponse.json(resData); } if (action === "chat") { diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts new file mode 100644 index 000000000..6203c9066 --- /dev/null +++ b/src/lib/auth-config.ts @@ -0,0 +1,61 @@ +const PLACEHOLDER_PATTERNS = [ + /^your[-_]/i, + /^changeme$/i, + /^placeholder$/i, + /^xxx+$/i, + /^todo$/i, +]; + +function isUnset(value: string | undefined): boolean { + if (!value || value.trim() === "") return true; + return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value.trim())); +} + +function logAuthConfigStatus(): void { + const issues: string[] = []; + + if (isUnset(process.env.GITHUB_ID)) { + issues.push("GITHUB_ID is unset — GitHub sign-in will fail"); + } + if (isUnset(process.env.GITHUB_SECRET)) { + issues.push("GITHUB_SECRET is unset — GitHub sign-in will fail"); + } + if (isUnset(process.env.NEXTAUTH_SECRET)) { + issues.push("NEXTAUTH_SECRET is unset — sessions cannot be signed"); + } + if (isUnset(process.env.NEXTAUTH_URL)) { + issues.push( + "NEXTAUTH_URL is unset — OAuth callbacks may redirect incorrectly" + ); + } else if (process.env.NEXTAUTH_URL?.endsWith("/")) { + issues.push( + "NEXTAUTH_URL has a trailing slash — remove it (e.g. https://example.com)" + ); + } + + if (issues.length > 0) { + console.warn( + "[auth] Self-hosting configuration issues:\n - " + issues.join("\n - ") + ); + } else if (process.env.AUTH_DEBUG === "true") { + console.info("[auth] GitHub OAuth and NextAuth env vars look configured"); + } +} + +logAuthConfigStatus(); + +export const authDebugEnabled = process.env.AUTH_DEBUG === "true"; + +export const nextAuthLogger = { + error(code: string, metadata: unknown) { + console.error("[nextauth]", code, metadata); + }, + warn(code: string) { + console.warn("[nextauth]", code); + }, + debug(code: string, metadata: unknown) { + if (authDebugEnabled) { + console.debug("[nextauth]", code, metadata); + } + }, +}; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 66b24a92a..ce9fa5903 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,6 @@ import { type NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; +import { authDebugEnabled, nextAuthLogger } from "./auth-config"; import { syncGitHubAchievementsForUser } from "./github-achievements"; import { supabaseAdmin } from "./supabase"; @@ -14,6 +15,8 @@ const GITHUB_API = "https://api.github.com"; const TOKEN_VALIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000; export const authOptions: NextAuthOptions = { + debug: authDebugEnabled, + logger: nextAuthLogger, // Playwright runs on plain HTTP (127.0.0.1) and relies on the default // `next-auth.session-token` cookie name. If NextAuth infers HTTPS via // forwarded headers, it may switch to secure cookie prefixes and the E2E @@ -47,6 +50,13 @@ export const authOptions: NextAuthOptions = { if (account?.provider === "github" && profile) { const p = profile as { id: number; login: string; email?: string }; + if (authDebugEnabled) { + console.debug("[auth] GitHub signIn callback", { + login: p.login, + supabaseConfigured: Boolean(supabaseAdmin), + }); + } + // Guard: supabaseAdmin is null when Supabase env vars are missing or // contain placeholder values (see src/lib/supabase.ts). Calling .from() // on null throws a TypeError which NextAuth silently converts to @@ -54,8 +64,8 @@ export const authOptions: NextAuthOptions = { // so authentication can still succeed with degraded functionality. if (!supabaseAdmin) { console.warn( - "signIn: supabaseAdmin is not configured; skipping DB upsert. " + - "Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local." + "[auth] supabaseAdmin is not configured; skipping DB upsert. " + + "Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY." ); return true; }