diff --git a/.cursor/rules/review-gate.mdc b/.cursor/rules/review-gate.mdc index 7cb38b4..a03fe00 100644 --- a/.cursor/rules/review-gate.mdc +++ b/.cursor/rules/review-gate.mdc @@ -1,9 +1,6 @@ --- -description: -globs: -alwaysApply: true +alwaysApply: false --- - ## MANDATORY CHECKPOINT (Must be included in every response): Before ending any response, I MUST: 1. State: "CHECKPOINT: Transitioning to Review Gate V2" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ba70bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: ["main", "release/*"] + pull_request: + branches: ["main", "release/*"] + +jobs: + lint-typecheck-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: "pnpm" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + env: + SKIP_ENV_VALIDATION: true + - name: Typecheck + run: pnpm type-check + env: + SKIP_ENV_VALIDATION: true + - name: Build + run: pnpm build:selfhosted + env: + SKIP_ENV_VALIDATION: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a8a319c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "0 6 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + strategy: + fail-fast: false + matrix: + language: ["javascript-typescript"] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..0cd525d --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +18 + diff --git a/example.png b/example.png new file mode 100644 index 0000000..e7483e5 Binary files /dev/null and b/example.png differ diff --git a/next.config.ts b/next.config.ts index e182511..c5e5088 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,32 +13,12 @@ const nextConfig: NextConfig = { // Configure images images: { - dangerouslyAllowSVG: true, remotePatterns: [new URL("https://vercel.com/*")], }, // Configure headers for security and CORS async headers() { - return [ - { - source: "/api/webhooks/:path*", - headers: [ - { - key: "Access-Control-Allow-Origin", - value: "*", - }, - { - key: "Access-Control-Allow-Methods", - value: "POST, OPTIONS", - }, - { - key: "Access-Control-Allow-Headers", - value: - "Content-Type, X-GitHub-Delivery, X-GitHub-Event, X-GitHub-Signature-256", - }, - ], - }, - ]; + return []; }, }; diff --git a/prisma/migrations/20250721021416_rename_oauth_fields_to_camelcase/migration.sql b/prisma/migrations/20250721021416_rename_oauth_fields_to_camelcase/migration.sql new file mode 100644 index 0000000..e818f3e --- /dev/null +++ b/prisma/migrations/20250721021416_rename_oauth_fields_to_camelcase/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the column `refresh_token` on the `github_installations` table. All the data in the column will be lost. + - You are about to drop the column `refresh_token_expires_at` on the `github_installations` table. All the data in the column will be lost. + - You are about to drop the column `token_expires_at` on the `github_installations` table. All the data in the column will be lost. + - You are about to drop the column `user_access_token` on the `github_installations` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "github_installations" DROP COLUMN "refresh_token", +DROP COLUMN "refresh_token_expires_at", +DROP COLUMN "token_expires_at", +DROP COLUMN "user_access_token", +ADD COLUMN "refreshToken" TEXT, +ADD COLUMN "refreshTokenExpiresAt" TIMESTAMP(3), +ADD COLUMN "tokenExpiresAt" TIMESTAMP(3), +ADD COLUMN "userAccessToken" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f8e557d..239bb6f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,10 +80,10 @@ model GitHubInstallation { LabelPreference LabelPreference? // User OAuth tokens - user_access_token String? - refresh_token String? - token_expires_at DateTime? - refresh_token_expires_at DateTime? + userAccessToken String? + refreshToken String? + tokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? // Indexes for performance @@index([accountId]) @@ -168,8 +168,8 @@ model LabelPreferenceRepository { model RateLimit { id Int @id @default(autoincrement()) - identifier String // IP address or user identifier - endpoint String // API endpoint being rate limited + identifier String // IP address or user identifier + endpoint String // API endpoint being rate limited requests Int @default(1) windowStart DateTime expiresAt DateTime diff --git a/src/app/api/auth/authorize/github/route.ts b/src/app/api/auth/authorize/github/route.ts new file mode 100644 index 0000000..04cb766 --- /dev/null +++ b/src/app/api/auth/authorize/github/route.ts @@ -0,0 +1,96 @@ +import { env } from "@/lib/env"; +import logger from "@/lib/logger"; +import crypto from "crypto"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +// Safe redirect URLs - only allow internal paths or whitelisted domains +const SAFE_REDIRECT_PATTERNS = [ + /^\/github-app\/success$/, // Internal success page + /^\/github-app\/label-setup$/, // Internal label setup page + /^\/$/, // Home page +]; + +function isValidRedirectUrl(redirectTo: string): boolean { + // Check if it's a relative URL (starts with /) + if (redirectTo.startsWith("/")) { + return SAFE_REDIRECT_PATTERNS.some((pattern) => pattern.test(redirectTo)); + } + + // For absolute URLs, only allow same origin + try { + const redirectUrl = new URL(redirectTo); + const baseUrl = new URL(env.GITHUB_APP_CALLBACK_URL); + return redirectUrl.origin === baseUrl.origin; + } catch { + // Invalid URL format + return false; + } +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const installationId = searchParams.get("installation_id"); + const redirectTo = searchParams.get("redirect_to") || "/github-app/success"; + + if (!installationId) { + return NextResponse.json( + { error: "Installation ID is required" }, + { status: 400 }, + ); + } + + // Validate redirectTo to prevent open redirect vulnerabilities + if (!isValidRedirectUrl(redirectTo)) { + logger.warn( + { installationId, redirectTo }, + "Invalid redirect URL attempted in OAuth flow", + ); + return NextResponse.json( + { error: "Invalid redirect URL" }, + { status: 400 }, + ); + } + + try { + // Generate CSRF state and encode installation_id and redirectTo using base64 + const state = crypto.randomBytes(32).toString("hex"); + const stateData = { + state, + installationId, + redirectTo, + }; + const stateWithInstallation = Buffer.from( + JSON.stringify(stateData), + ).toString("base64"); + + // Set CSRF state cookie + const cookieStore = await cookies(); + cookieStore.set("oauth_state", stateWithInstallation, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 10, // 10 minutes + }); + + // Build GitHub OAuth authorization URL + const githubAuthUrl = new URL("https://github.com/login/oauth/authorize"); + githubAuthUrl.searchParams.set("client_id", env.GITHUB_APP_CLIENT_ID); + githubAuthUrl.searchParams.set("redirect_uri", env.GITHUB_APP_CALLBACK_URL); + githubAuthUrl.searchParams.set("state", stateWithInstallation); + githubAuthUrl.searchParams.set("scope", "repo"); // Add required scopes + + logger.info( + { installationId, redirectTo }, + "Redirecting to GitHub OAuth authorization", + ); + + return NextResponse.redirect(githubAuthUrl.toString()); + } catch (error) { + logger.error({ error }, "Failed to create OAuth authorization URL"); + return NextResponse.json( + { error: "Failed to initiate OAuth flow" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index f4c4533..381cebf 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -1,10 +1,10 @@ import { encrypt } from "@/lib/crypto"; import { env } from "@/lib/env"; +import logger from "@/lib/logger"; import { db } from "@/server/db"; -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; import * as crypto from "crypto"; -import logger from "@/lib/logger"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; // Global fallback rate limiting storage declare global { @@ -174,38 +174,170 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const code = searchParams.get("code"); - const installationIdParam = searchParams.get("installation_id"); const state = searchParams.get("state"); + // Log minimal details. Do NOT log raw state value. + logger.info("OAuth callback received", { + url: new URL(request.url).origin, + statePresent: Boolean(state), + codePresent: Boolean(code), + installationId: searchParams.get("installation_id") ? "present" : "missing", + setupAction: searchParams.get("setup_action"), + }); + // CSRF Protection const oauthStateCookie = (await cookies()).get("oauth_state"); - if ( - !state || - !oauthStateCookie || - !crypto.timingSafeEqual( - Buffer.from(state), - Buffer.from(oauthStateCookie.value), - ) - ) { + + // Handle the case where GitHub uses its own state parameter (success URL) + // vs our custom state with installation_id + let stateValidationPassed = false; + let stateData: { + state: string; + installationId: string; + redirectTo: string; + } | null = null; + + if (state) { + try { + // Try to validate with our custom state format first (if we have a cookie) + if (oauthStateCookie) { + // Try to decode as base64 JSON first (new format) + try { + const decodedState = Buffer.from(state, "base64").toString("utf-8"); + const parsedStateData = JSON.parse(decodedState); + + if ( + parsedStateData.state && + parsedStateData.installationId && + parsedStateData.redirectTo + ) { + // This is our new base64-encoded format + stateData = parsedStateData; + stateValidationPassed = crypto.timingSafeEqual( + Buffer.from(state), + Buffer.from(oauthStateCookie.value), + ); + } + } catch { + // Not base64 JSON, try old colon-separated format + if (state.includes(":") && oauthStateCookie) { + // Our old custom state format: {randomHex}:{installationId}:{redirectTo} + stateValidationPassed = crypto.timingSafeEqual( + Buffer.from(state), + Buffer.from(oauthStateCookie.value), + ); + + if (stateValidationPassed) { + const stateParts = state.split(":"); + if (stateParts.length >= 3) { + stateData = { + state: stateParts[0] || "", + installationId: stateParts[1] || "", + redirectTo: stateParts[2] || "/github-app/success", + }; + } + } + } + } + } + + // If still not validated, check for GitHub's automatic OAuth flow + if (!stateValidationPassed) { + // GitHub's state format: just the success URL (may be URL-encoded) + // Handle both single and double encoding + let decodedState = state; + try { + // Try single decode first + decodedState = decodeURIComponent(state); + } catch { + try { + // If that fails, try double decode + decodedState = decodeURIComponent(decodeURIComponent(state)); + } catch { + // If both fail, use original state + decodedState = state; + } + } + + // Check if the decoded state contains our expected redirect path + if (decodedState.includes("/github-app/success")) { + // This is likely GitHub's automatic OAuth flow during installation + // We'll accept this state and try to get installation_id from URL params + stateValidationPassed = true; + logger.info("GitHub-initiated OAuth flow detected, accepting state", { + hasCookie: !!oauthStateCookie, + }); + } + } + } catch (error) { + logger.error( + { error, state, cookieValue: oauthStateCookie?.value }, + "State validation error", + ); + } + } + + if (!stateValidationPassed) { logger.error( - { state, oauthStateCookie: oauthStateCookie?.value }, - "CSRF state mismatch or missing", + { statePresent: Boolean(state) }, + "CSRF state validation failed", ); return NextResponse.json( { error: "Invalid or missing CSRF state" }, { status: 422 }, ); } + // Clear the state cookie after successful validation (await cookies()).delete("oauth_state"); - if (!code || !installationIdParam) { - logger.error( - { code: !!code, installationId: !!installationIdParam }, - "Missing code or installation_id", - ); + // Extract installation_id and redirect_to from state + let installationIdParam: string | null = null; + let redirectTo = "/github-app/success"; + + if (stateData) { + // We have parsed state data from our custom format + installationIdParam = stateData.installationId; + redirectTo = stateData.redirectTo; + } else if (state) { + // Fallback to old parsing logic for backward compatibility + const stateParts = state.split(":"); + if (stateParts.length >= 3) { + // Manual OAuth flow - installation_id is in state + installationIdParam = stateParts[1] || null; + redirectTo = stateParts[2] || "/github-app/success"; + } else if (stateParts.length === 1) { + // GitHub-initiated OAuth flow - no installation_id in state + // We need to find the installation from the current session or recent installations + // For now, we'll redirect to success page and let the user reinstall if needed + logger.info( + "GitHub-initiated OAuth flow detected, no installation_id in state", + ); + redirectTo = "/github-app/success"; + } else { + logger.error({ statePresent: Boolean(state) }, "Invalid state format"); + return NextResponse.json( + { error: "Invalid state format" }, + { status: 400 }, + ); + } + } + + // If no installation_id from state, try to get it from URL params (fallback) + if (!installationIdParam) { + installationIdParam = searchParams.get("installation_id"); + } + + if (!code) { + logger.error("Missing OAuth code"); + return NextResponse.json({ error: "Missing OAuth code" }, { status: 400 }); + } + + // If we still don't have an installation_id, we can't proceed + if (!installationIdParam) { + logger.error("No installation_id found in OAuth callback"); return NextResponse.json( - { error: "Missing code or installation_id" }, + { error: "Installation ID not found. Please reinstall the GitHub App." }, { status: 400 }, ); } @@ -233,7 +365,7 @@ export async function GET(request: NextRequest) { "Content-Type": "application/json", }, body: JSON.stringify({ - client_id: env.NEXT_PUBLIC_GITHUB_APP_ID, + client_id: env.GITHUB_APP_CLIENT_ID, client_secret: env.GITHUB_APP_CLIENT_SECRET, code, redirect_uri: env.GITHUB_APP_CALLBACK_URL, @@ -248,8 +380,24 @@ export async function GET(request: NextRequest) { { error: data.error, description: data.error_description }, "Error exchanging code for token", ); + + // Handle specific OAuth errors + if (data.error === "bad_verification_code") { + return NextResponse.json( + { + error: "OAuth code expired", + message: + "The authorization code has expired. Please try installing the app again.", + }, + { status: 400 }, + ); + } + return NextResponse.json( - { error: data.error_description }, + { + error: data.error, + message: data.error_description || "OAuth authorization failed", + }, { status: 400 }, ); } @@ -280,33 +428,56 @@ export async function GET(request: NextRequest) { ); // Verify installation exists and belongs to the user (or is valid for update) - const existingInstallation = await db.gitHubInstallation.findUnique({ + // If it doesn't exist, create it (this handles the case where OAuth happens before webhook) + let existingInstallation = await db.gitHubInstallation.findUnique({ where: { id: installationId }, }); if (!existingInstallation) { - logger.error( + logger.info( { installationId }, - "GitHub Installation not found for update", - ); - return NextResponse.json( - { error: "GitHub Installation not found" }, - { status: 404 }, + "Installation not found in database, creating it from OAuth callback", ); + + // Create a minimal installation record - the webhook will update it with full details later + existingInstallation = await db.gitHubInstallation.upsert({ + where: { id: installationId }, + create: { + id: installationId, + accountId: 0, // Will be updated by webhook + accountLogin: "unknown", // Will be updated by webhook + accountType: "User", // Will be updated by webhook + targetType: "User", // Will be updated by webhook + permissions: "{}", // Will be updated by webhook + events: "[]", // Will be updated by webhook + repositorySelection: "all", // Will be updated by webhook + }, + update: {}, // No update needed if it already exists + }); } await db.gitHubInstallation.update({ where: { id: installationId }, data: { - user_access_token: encrypt(access_token), - refresh_token: encrypt(refresh_token), - token_expires_at: tokenExpiresAt, - refresh_token_expires_at: refreshTokenExpiresAt, + userAccessToken: encrypt(access_token), + refreshToken: encrypt(refresh_token), + tokenExpiresAt: tokenExpiresAt, + refreshTokenExpiresAt: refreshTokenExpiresAt, }, }); // Redirect to a success page - return NextResponse.redirect(new URL("/github-app/success", request.url)); + const baseUrl = new URL(env.GITHUB_APP_CALLBACK_URL).origin; + const successUrl = new URL(redirectTo, baseUrl); + successUrl.searchParams.set("installation_id", installationId.toString()); + successUrl.searchParams.set("setup_action", "install"); + + logger.info("Redirecting to success page", { + installationId, + redirectUrl: successUrl.toString(), + }); + + return NextResponse.redirect(successUrl); } catch (error) { logger.error({ error }, "OAuth callback error"); return NextResponse.json( diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts index abe7927..8727165 100644 --- a/src/app/api/cron/cleanup/route.ts +++ b/src/app/api/cron/cleanup/route.ts @@ -1,9 +1,9 @@ import { env } from "@/lib/env"; -import { db } from "@/server/db"; -import { NextRequest, NextResponse } from "next/server"; -import * as crypto from "crypto"; import logger from "@/lib/logger"; +import { db } from "@/server/db"; import { Prisma } from "@prisma/client"; +import * as crypto from "crypto"; +import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest): Promise { const authHeader = request.headers.get("authorization"); @@ -30,15 +30,15 @@ export async function GET(request: NextRequest): Promise { // 1. Clean up expired refresh tokens const expiredTokens = await prisma.gitHubInstallation.updateMany({ where: { - refresh_token_expires_at: { + refreshTokenExpiresAt: { lt: now, }, }, data: { - user_access_token: null, - refresh_token: null, - token_expires_at: null, - refresh_token_expires_at: null, + userAccessToken: null, + refreshToken: null, + tokenExpiresAt: null, + refreshTokenExpiresAt: null, }, }); @@ -75,7 +75,8 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json( { error: "Internal Server Error", - details: error instanceof Error ? error.message : "Unknown error", + message: + "An error occurred while executing the cleanup job. Please check the server logs for details.", }, { status: 500 }, ); diff --git a/src/app/api/cron/retry/route.ts b/src/app/api/cron/retry/route.ts index 237ffa2..313f235 100644 --- a/src/app/api/cron/retry/route.ts +++ b/src/app/api/cron/retry/route.ts @@ -15,6 +15,7 @@ import { env } from "@/lib/env"; import { cleanupOldTasks, retryAllFlaggedTasks } from "@/lib/jules"; +import logger from "@/lib/logger"; import { db } from "@/server/db"; import { NextRequest, NextResponse } from "next/server"; @@ -30,14 +31,14 @@ function verifyCronAuth(req: NextRequest): boolean { // In development, allow without auth if (env.NODE_ENV === "development") { - console.warn( + logger.warn( "Cron endpoint accessed without authentication in development mode", ); return true; } // In production without CRON_SECRET, deny access - console.error("Cron endpoint accessed without proper authentication"); + logger.error("Cron endpoint accessed without proper authentication"); return false; } @@ -64,7 +65,7 @@ async function logCronExecution( }, }); } catch (logError) { - console.error("Failed to log cron execution:", logError); + logger.error({ error: logError }, "Failed to log cron execution"); } } @@ -80,14 +81,19 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.log("Starting cron job: retry flagged Jules tasks"); + logger.info("Starting cron job: retry flagged Jules tasks"); try { // Retry all flagged tasks const retryStats = await retryAllFlaggedTasks(); // Also perform housekeeping - cleanup old completed tasks - const cleanupCount = await cleanupOldTasks(7); // Keep tasks for 7 days + const configuredDays = Number(env.TASK_CLEANUP_DAYS); + const cleanupDays = + Number.isFinite(configuredDays) && configuredDays > 0 + ? configuredDays + : 7; + const cleanupCount = await cleanupOldTasks(cleanupDays); const executionTime = Date.now() - startTime; const stats = { @@ -96,7 +102,7 @@ export async function POST(req: NextRequest) { executionTimeMs: executionTime, }; - console.log("Cron job completed successfully:", stats); + logger.info({ stats }, "Cron job completed successfully"); // Log successful execution await logCronExecution("retry_tasks", true, stats); @@ -112,10 +118,10 @@ export async function POST(req: NextRequest) { error instanceof Error ? error.message : "Unknown error"; const executionTime = Date.now() - startTime; - console.error("Cron job failed:", { - error: errorMessage, - executionTimeMs: executionTime, - }); + logger.error( + { error: errorMessage, executionTimeMs: executionTime }, + "Cron job failed", + ); // Log failed execution await logCronExecution( @@ -141,6 +147,14 @@ export async function POST(req: NextRequest) { * Health check for cron job endpoint */ export async function GET() { + // Explicitly disallow GET to enforce POST-only access as per checklist + return NextResponse.json({ error: "Method Not Allowed" }, { status: 405 }); +} + +/** + * Health and stats via HEAD (minimal) + */ +export async function HEAD() { try { // Check database connectivity await db.$queryRaw`SELECT 1`; @@ -151,7 +165,7 @@ export async function GET() { }); // Get recent cron executions - const recentCronLogs = await db.webhookLog.findMany({ + await db.webhookLog.findMany({ where: { eventType: "cron_retry_tasks", createdAt: { @@ -162,35 +176,12 @@ export async function GET() { take: 5, }); - const lastSuccessfulRun = recentCronLogs.find( - (log: { success: boolean }) => log.success, - ); - const lastFailedRun = recentCronLogs.find( - (log: { success: boolean }) => !log.success, - ); - - return NextResponse.json({ - status: "healthy", - service: "Jules task retry cron job", - database: "connected", - currentQueueSize: queueStats, - cronSecretConfigured: !!env.CRON_SECRET, - lastSuccessfulRun: lastSuccessfulRun?.createdAt || null, - lastFailedRun: lastFailedRun?.createdAt || null, - recentExecutions: recentCronLogs.length, - timestamp: new Date().toISOString(), + return new NextResponse(null, { + status: 200, + headers: { "X-Queue-Size": String(queueStats) }, }); - } catch (error) { - return NextResponse.json( - { - status: "unhealthy", - service: "Jules task retry cron job", - database: "disconnected", - error: error instanceof Error ? error.message : "Unknown error", - timestamp: new Date().toISOString(), - }, - { status: 500 }, - ); + } catch { + return new NextResponse(null, { status: 503 }); } } @@ -204,7 +195,7 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.log("Manual cron job trigger requested"); + logger.info("Manual cron job trigger requested"); try { const body = await req.json().catch(() => ({})); diff --git a/src/app/api/github-app/install/route.ts b/src/app/api/github-app/install/route.ts index 092bce3..c2f847f 100644 --- a/src/app/api/github-app/install/route.ts +++ b/src/app/api/github-app/install/route.ts @@ -3,6 +3,7 @@ import { getInstallationError, validateGitHubAppConfig, } from "@/lib/github-app-utils"; +import logger from "@/lib/logger"; import { NextRequest, NextResponse } from "next/server"; /** @@ -13,9 +14,9 @@ export async function GET(req: NextRequest) { // Validate GitHub App configuration const configValidation = validateGitHubAppConfig(); if (!configValidation.valid) { - console.error( - "GitHub App configuration invalid:", - configValidation.errors, + logger.error( + { errors: configValidation.errors }, + "GitHub App configuration invalid", ); return NextResponse.json( @@ -40,11 +41,10 @@ export async function GET(req: NextRequest) { if (!result.success) { const errorInfo = getInstallationError(result.errorCode || "UNKNOWN"); - console.error("Failed to build installation URL:", { - error: result.error, - errorCode: result.errorCode, - url: req.url, - }); + logger.error( + { error: result.error, errorCode: result.errorCode, url: req.url }, + "Failed to build installation URL", + ); return NextResponse.json( { @@ -58,22 +58,25 @@ export async function GET(req: NextRequest) { } // Log successful redirect for monitoring - console.log("Redirecting to GitHub App installation:", { - url: result.url, - timestamp: new Date().toISOString(), - }); + logger.info( + { url: result.url, timestamp: new Date().toISOString() }, + "Redirecting to GitHub App installation", + ); // Redirect to GitHub App installation - result.url is guaranteed to exist when success is true return NextResponse.redirect(result.url!); } catch (error) { const errorInfo = getInstallationError("UNKNOWN"); - console.error("Unexpected error in GitHub App installation:", { - error: error instanceof Error ? error.message : "Unknown error", - stack: error instanceof Error ? error.stack : undefined, - url: req.url, - timestamp: new Date().toISOString(), - }); + logger.error( + { + error: error instanceof Error ? error.message : "Unknown error", + stack: error instanceof Error ? error.stack : undefined, + url: req.url, + timestamp: new Date().toISOString(), + }, + "Unexpected error in GitHub App installation", + ); return NextResponse.json( { diff --git a/src/app/api/github-app/installations/[installationId]/repositories/route.ts b/src/app/api/github-app/installations/[installationId]/repositories/route.ts index fd938eb..ea59b99 100644 --- a/src/app/api/github-app/installations/[installationId]/repositories/route.ts +++ b/src/app/api/github-app/installations/[installationId]/repositories/route.ts @@ -1,3 +1,4 @@ +import logger from "@/lib/logger"; import { db } from "@/server/db"; import { NextRequest, NextResponse } from "next/server"; @@ -64,7 +65,7 @@ export async function GET(request: NextRequest, context: RouteContext) { count: formattedRepositories.length, }); } catch (error) { - console.error("Failed to fetch installation repositories:", error); + logger.error({ error }, "Failed to fetch installation repositories"); return NextResponse.json( { error: "Internal server error" }, { status: 500 }, diff --git a/src/app/api/github-app/label-setup/route.ts b/src/app/api/github-app/label-setup/route.ts index d8cc02a..cbd904f 100644 --- a/src/app/api/github-app/label-setup/route.ts +++ b/src/app/api/github-app/label-setup/route.ts @@ -1,4 +1,5 @@ import { createJulesLabelsForRepository } from "@/lib/github-labels"; +import logger from "@/lib/logger"; import { db } from "@/server/db"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; @@ -109,7 +110,7 @@ export async function POST(request: NextRequest) { }); // Create labels in selected repositories - console.log( + logger.info( `Creating Jules labels in ${repositoriesToProcess.length} repositories for installation ${installationId}`, ); @@ -126,7 +127,7 @@ export async function POST(request: NextRequest) { (result) => result.status === "rejected", ).length; - console.log( + logger.info( `Label creation completed: ${successful} successful, ${failed} failed`, ); @@ -158,7 +159,7 @@ export async function POST(request: NextRequest) { }, }); } catch (error) { - console.error("Failed to setup labels:", error); + logger.error({ error }, "Failed to setup labels"); if (error instanceof z.ZodError) { return NextResponse.json( diff --git a/src/app/api/github-app/star-check/route.ts b/src/app/api/github-app/star-check/route.ts index 77d8691..f9b0557 100644 --- a/src/app/api/github-app/star-check/route.ts +++ b/src/app/api/github-app/star-check/route.ts @@ -1,5 +1,8 @@ +import { decrypt } from "@/lib/crypto"; import { env } from "@/lib/env"; import { githubClient } from "@/lib/github"; +import logger from "@/lib/logger"; +import { db } from "@/server/db"; import { NextRequest, NextResponse } from "next/server"; export async function GET(req: NextRequest) { @@ -14,7 +17,7 @@ export async function GET(req: NextRequest) { const repo = env.REPO_NAME; if (!owner || !repo) { - console.error("REPO_OWNER or REPO_NAME environment variables not set."); + logger.error("REPO_OWNER or REPO_NAME environment variables not set."); return NextResponse.json( { error: "Star requirement configuration incomplete", @@ -32,29 +35,89 @@ export async function GET(req: NextRequest) { ); } + // Validate installationId is a valid numeric string + const parsedInstallationId = parseInt(installationId, 10); + if (isNaN(parsedInstallationId) || parsedInstallationId <= 0) { + return NextResponse.json( + { error: "Invalid installation ID format" }, + { status: 400 }, + ); + } + try { - // Get the installation info to get the username - const installationInfo = await githubClient - .getGitHubAppClient() - .getInstallationInfo(parseInt(installationId)); + // Get the installation info from our database instead of GitHub API + const installation = await db.gitHubInstallation.findUnique({ + where: { id: parsedInstallationId }, + select: { + accountLogin: true, + accountType: true, + userAccessToken: true, + }, + }); + + if (!installation) { + return NextResponse.json( + { error: "Installation not found" }, + { status: 404 }, + ); + } + + if (!installation.userAccessToken) { + // User access token is missing, redirect to OAuth flow + // Check if we're already in an OAuth flow (GitHub might have initiated it) + const baseUrl = new URL(env.GITHUB_APP_CALLBACK_URL).origin; + const oauthUrl = new URL("/api/auth/authorize/github", baseUrl); + oauthUrl.searchParams.set("installation_id", installationId); + oauthUrl.searchParams.set("redirect_to", "/github-app/success"); + + return NextResponse.json( + { + error: "oauth_required", + message: + "User authorization required. Please complete the OAuth flow.", + oauth_url: oauthUrl.toString(), + }, + { status: 401 }, + ); + } - if (!installationInfo.account) { + // Use the account login from our database + const username = installation.accountLogin; + + // Decrypt the user access token with proper error handling + let decryptedToken: string | null = null; + try { + decryptedToken = decrypt(installation.userAccessToken); + } catch (decryptError) { + logger.error( + { error: decryptError }, + "Failed to decrypt user access token", + ); return NextResponse.json( - { error: "Unable to determine user account from installation" }, - { status: 400 }, + { + error: "token_decryption_failed", + message: + "Failed to decrypt user access token. Please reinstall the app.", + }, + { status: 500 }, ); } - // Handle both User and Organization accounts - const username = - "login" in installationInfo.account - ? installationInfo.account.login - : installationInfo.account.name; + if (!decryptedToken) { + logger.error("Failed to decrypt user access token"); + return NextResponse.json( + { + error: "token_decryption_failed", + message: + "Failed to decrypt user access token. Please reinstall the app.", + }, + { status: 500 }, + ); + } - // Get an installation-scoped Octokit client - const octokit = await githubClient - .getGitHubAppClient() - .getInstallationOctokit(parseInt(installationId)); + // Create an Octokit client using the user access token + const octokit = + await githubClient.getUserOwnedGitHubAppClient(decryptedToken); // Check if the user has starred the repository (passive check) const isStarred = await githubClient.checkIfUserStarredRepository( @@ -75,9 +138,9 @@ export async function GET(req: NextRequest) { errorMessage.includes("installation") || errorMessage.includes("Installation") ) { - console.error( - "Installation not found (app may have been uninstalled/reinstalled):", - error, + logger.error( + { error }, + "Installation not found (may be uninstalled/reinstalled)", ); return NextResponse.json( { @@ -97,7 +160,7 @@ export async function GET(req: NextRequest) { // Handle permission errors specifically if ((error as { status?: number })?.status === 403) { - console.error("GitHub App lacks required permissions:", error); + logger.error({ error }, "GitHub App lacks required permissions"); return NextResponse.json( { error: "Permission denied", @@ -108,7 +171,7 @@ export async function GET(req: NextRequest) { ); } - console.error("Failed to check star status:", error); + logger.error({ error }, "Failed to check star status"); return NextResponse.json( { error: "Failed to check star status" }, { status: 500 }, diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index f9cc748..a780682 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,5 +1,6 @@ import { env } from "@/lib/env"; import { githubAppClient } from "@/lib/github-app"; +import logger from "@/lib/logger"; import { db } from "@/server/db"; import { NextResponse } from "next/server"; @@ -12,7 +13,7 @@ async function checkDatabase(): Promise { await db.$queryRaw`SELECT 1`; return "ok"; } catch (error) { - console.error("Database health check failed:", error); + logger.error(error, "Database health check failed"); return "error"; } } @@ -25,7 +26,7 @@ async function checkGitHubApp(): Promise { await githubAppClient.getAppInfo(); return "ok"; } catch (error) { - console.error("GitHub App health check failed:", error); + logger.error(error, "GitHub App health check failed"); return "error"; } } @@ -35,6 +36,18 @@ function checkWebhook(): Status { } export async function GET() { + // Minimal mode for public environments: only status code and generic body + if (env.NODE_ENV === "production") { + const dbStatus = await checkDatabase(); + const appStatus = await checkGitHubApp(); + const hasError = [dbStatus, appStatus].some((s) => s === "error"); + const httpStatus = hasError ? 503 : 200; + return NextResponse.json( + { status: httpStatus === 200 ? "ok" : "error" }, + { status: httpStatus }, + ); + } + const checks = { database: await checkDatabase(), githubApp: await checkGitHubApp(), diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index ec50e3b..f2c3c55 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -1,3 +1,4 @@ +import logger from "@/lib/logger"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { type NextRequest } from "next/server"; @@ -14,8 +15,9 @@ const handler = (req: NextRequest) => onError: env.NODE_ENV === "development" ? ({ path, error }) => { - console.error( - `❌ tRPC failed on ${path ?? ""}: ${error.message}`, + logger.error( + error, + `tRPC request failed on path ${path ?? ""}`, ); } : undefined, diff --git a/src/app/api/webhooks/github-app/route.ts b/src/app/api/webhooks/github-app/route.ts index c76cef9..06f9f23 100644 --- a/src/app/api/webhooks/github-app/route.ts +++ b/src/app/api/webhooks/github-app/route.ts @@ -1,5 +1,6 @@ import { env } from "@/lib/env"; import { createJulesLabelsForRepository } from "@/lib/github-labels"; +import logger from "@/lib/logger"; import { processJulesLabelEvent } from "@/lib/webhook-processor"; import { db } from "@/server/db"; import { GitHubLabelEventSchema } from "@/types"; @@ -87,10 +88,16 @@ interface GitHubWebhookEvent { */ function verifyGitHubAppSignature(payload: string, signature: string): boolean { if (!env.GITHUB_APP_WEBHOOK_SECRET) { - logger.warn( - "GITHUB_APP_WEBHOOK_SECRET not configured - webhook verification disabled", + if (env.NODE_ENV === "development") { + logger.warn( + "GITHUB_APP_WEBHOOK_SECRET not configured - allowing unsigned webhooks in development only", + ); + return true; + } + logger.error( + "GITHUB_APP_WEBHOOK_SECRET not configured in production - denying webhook", ); - return true; // Allow in development if not configured + return false; } if (!signature.startsWith("sha256=")) { @@ -112,6 +119,159 @@ function verifyGitHubAppSignature(payload: string, signature: string): boolean { } } +/** + * Minimal rate limiting for webhook endpoint to prevent DB flooding + */ +async function checkRateLimit( + identifierRaw: string, + maxRequests: number = 30, + windowMs: number = 60 * 1000, +) { + // Normalize/shorten identifier to avoid oversized keys + const identifier = identifierRaw.slice(0, 64); + const now = new Date(); + const endpoint = "/api/webhooks/github-app"; + + try { + // Throttle cleanup (only 1% of requests trigger it) to reduce contention + if (Math.random() < 0.01) { + void db.rateLimit.deleteMany({ where: { expiresAt: { lt: now } } }); + } + + // Atomic upsert with conditional increment to avoid race conditions + // 1. Try to increment if window active and under limit + const updated = await db.$executeRawUnsafe( + `UPDATE rate_limits + SET requests = requests + 1 + WHERE identifier = $1 AND endpoint = $2 AND "expiresAt" > $3 AND requests < $4`, + identifier, + endpoint, + now, + maxRequests, + ); + + if (updated && updated > 0) { + // Fetch remaining in a lightweight way + const row = await db.rateLimit.findUnique({ + where: { identifier_endpoint: { identifier, endpoint } }, + select: { requests: true }, + }); + const remaining = Math.max( + 0, + maxRequests - (row?.requests ?? maxRequests), + ); + return { allowed: true, remaining } as const; + } + + // 2. Either new window or first request: try insert + try { + // Use upsert to avoid unique constraint races + const record = await db.rateLimit.upsert({ + where: { identifier_endpoint: { identifier, endpoint } }, + update: {}, + create: { + identifier, + endpoint, + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + // If upsert hit existing row (update no-op), decide based on expiry/requests + if (record.expiresAt <= now) { + const reset = await db.rateLimit.update({ + where: { id: record.id }, + data: { + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + return { + allowed: true, + remaining: maxRequests - reset.requests, + } as const; + } + if (record.requests >= maxRequests) { + return { allowed: false, remaining: 0 } as const; + } + const updatedRecord = await db.rateLimit.update({ + where: { id: record.id }, + data: { requests: { increment: 1 } }, + select: { requests: true }, + }); + return { + allowed: true, + remaining: Math.max(0, maxRequests - updatedRecord.requests), + } as const; + } catch { + // 3. If upsert failed (rare), reset window if expired, else check limit + const record = await db.rateLimit.findUnique({ + where: { identifier_endpoint: { identifier, endpoint } }, + }); + if (!record) { + return { allowed: true, remaining: maxRequests - 1 } as const; + } + if (record.expiresAt <= now) { + await db.rateLimit.update({ + where: { id: record.id }, + data: { + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + return { allowed: true, remaining: maxRequests - 1 } as const; + } + if (record.requests >= maxRequests) { + return { allowed: false, remaining: 0 } as const; + } + const newCount = record.requests + 1; + await db.rateLimit.update({ + where: { id: record.id }, + data: { requests: newCount }, + }); + return { + allowed: true, + remaining: Math.max(0, maxRequests - newCount), + } as const; + } + } catch { + // Fallback to a very restrictive in-memory limiter + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g: any = global as unknown as { + __webhookFallbackLimits?: Map< + string, + { count: number; windowStart: number } + >; + }; + if (!g.__webhookFallbackLimits) g.__webhookFallbackLimits = new Map(); + const key = `${identifier}:webhook`; + const nowMs = Date.now(); + const fallbackWindowMs = 60 * 1000; + const fallbackMax = 10; // extra strict fallback + const current = g.__webhookFallbackLimits.get(key) || { + count: 0, + windowStart: nowMs, + }; + if (nowMs - current.windowStart > fallbackWindowMs) { + current.count = 0; + current.windowStart = nowMs; + } + if (current.count >= fallbackMax) { + logger.warn({ identifier }, "Webhook fallback rate limit exceeded"); + return { allowed: false, remaining: 0 } as const; + } + current.count++; + g.__webhookFallbackLimits.set(key, current); + logger.warn( + { identifier, count: current.count }, + "Using webhook fallback rate limiter", + ); + return { allowed: true, remaining: fallbackMax - current.count } as const; + } +} + /** * Log webhook event to database */ @@ -241,10 +401,10 @@ async function handleInstallationEvent( suspendedAt: new Date(), suspendedBy: "uninstalled", updatedAt: new Date(), - user_access_token: null, - refresh_token: null, - token_expires_at: null, - refresh_token_expires_at: null, + userAccessToken: null, + refreshToken: null, + tokenExpiresAt: null, + refreshTokenExpiresAt: null, }, }); @@ -415,6 +575,29 @@ export async function POST(req: NextRequest) { let payload: unknown = null; try { + // Basic rate limiting per source IP + const realIpHeader = req.headers.get("x-real-ip"); + let ipSource = + realIpHeader || req.headers.get("x-forwarded-for") || "unknown"; + // Parse X-Forwarded-For first entry if multiple + if (!realIpHeader && ipSource.includes(",")) { + ipSource = ipSource.split(",")[0]?.trim() || ipSource; + } + // Normalize and bound the identifier length + const normalizedIp = ipSource.toLowerCase().slice(0, 64); + // Optionally append user agent (truncated) to reduce spoofing + const userAgent = (req.headers.get("user-agent") || "") + .toLowerCase() + .slice(0, 32); + const identifier = userAgent + ? `${normalizedIp}|${userAgent}` + : normalizedIp; + const rate = await checkRateLimit(identifier); + if (!rate.allowed) { + await logWebhookEvent(eventType, payload, false, "Rate limit exceeded"); + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + // Verify content type const contentType = req.headers.get("content-type"); if (contentType !== "application/json") { @@ -435,19 +618,22 @@ export async function POST(req: NextRequest) { // Verify signature const signature = req.headers.get("x-hub-signature-256"); if (!signature) { - await logWebhookEvent( - eventType, - payload, - false, - "Missing signature header", - ); - return NextResponse.json( - { error: "Missing X-Hub-Signature-256 header" }, - { status: 401 }, - ); + // Allow unsigned webhooks only in development when secret is not configured + if (!(env.NODE_ENV === "development" && !env.GITHUB_APP_WEBHOOK_SECRET)) { + await logWebhookEvent( + eventType, + payload, + false, + "Missing signature header", + ); + return NextResponse.json( + { error: "Missing X-Hub-Signature-256 header" }, + { status: 401 }, + ); + } } - if (!verifyGitHubAppSignature(payloadText, signature)) { + if (signature && !verifyGitHubAppSignature(payloadText, signature)) { await logWebhookEvent(eventType, payload, false, "Invalid signature"); return NextResponse.json( { error: "Invalid webhook signature" }, @@ -682,8 +868,11 @@ export async function GET() { service: "GitHub App webhook handler", database: "connected", timestamp: new Date().toISOString(), - environment: env.NODE_ENV, - webhookSecretConfigured: !!env.GITHUB_APP_WEBHOOK_SECRET, + environment: env.NODE_ENV === "production" ? undefined : env.NODE_ENV, + webhookSecretConfigured: + env.NODE_ENV === "production" + ? undefined + : !!env.GITHUB_APP_WEBHOOK_SECRET, }); } catch (error) { return NextResponse.json( diff --git a/src/components/label-setup/label-setup-handler.tsx b/src/components/label-setup/label-setup-handler.tsx index 162408d..d331b91 100644 --- a/src/components/label-setup/label-setup-handler.tsx +++ b/src/components/label-setup/label-setup-handler.tsx @@ -312,7 +312,7 @@ export function LabelSetupHandler() { jules-queue - Used for queue management + Used for queue management (automatically handled) diff --git a/src/components/success/error-state.tsx b/src/components/success/error-state.tsx index 8c18577..0e67926 100644 --- a/src/components/success/error-state.tsx +++ b/src/components/success/error-state.tsx @@ -1,5 +1,5 @@ -import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { SiGithub } from "@icons-pack/react-simple-icons"; import { AlertCircle, Home } from "lucide-react"; import Link from "next/link"; diff --git a/src/components/success/installation-status-handler.tsx b/src/components/success/installation-status-handler.tsx index b7f86aa..b880c89 100644 --- a/src/components/success/installation-status-handler.tsx +++ b/src/components/success/installation-status-handler.tsx @@ -8,6 +8,45 @@ import { LoadingState } from "./loading-state"; import { SuccessState } from "./success-state"; import { UnknownStatus } from "./unknown-status"; +// URL validation function to prevent malicious redirects +function isValidOAuthUrl(url: string): boolean { + try { + const parsedUrl = new URL(url); + + // Only allow HTTPS URLs + if (parsedUrl.protocol !== "https:") { + return false; + } + + // Only allow same origin or trusted GitHub domains + const allowedDomains = [ + "github.com", + "githubusercontent.com", + window.location.hostname, // Same origin + ]; + + if ( + !allowedDomains.some( + (domain) => + parsedUrl.hostname === domain || + parsedUrl.hostname.endsWith(`.${domain}`), + ) + ) { + return false; + } + + // Ensure the path is for OAuth authorization + if (!parsedUrl.pathname.includes("/api/auth/authorize/github")) { + return false; + } + + return true; + } catch { + // Invalid URL format + return false; + } +} + export function InstallationStatusHandler() { const searchParams = useSearchParams(); const router = useRouter(); @@ -41,6 +80,24 @@ export function InstallationStatusHandler() { if (!response.ok) { // Handle API errors gracefully + if (response.status === 401 && data.error === "oauth_required") { + console.log("OAuth flow required, redirecting user"); + + // Validate OAuth URL before redirecting + if (data.oauth_url && isValidOAuthUrl(data.oauth_url)) { + window.location.href = data.oauth_url; + } else { + console.error("Invalid OAuth URL received:", data.oauth_url); + setInstallationStatus({ + success: false, + error: "invalid_oauth_url", + errorDescription: "Invalid OAuth URL received from server", + }); + setIsLoading(false); + } + return; + } + if (response.status === 403) { console.error( "GitHub App missing 'Starring' permission:", diff --git a/src/lib/github-app-utils.ts b/src/lib/github-app-utils.ts index 470ac7d..4e37908 100644 --- a/src/lib/github-app-utils.ts +++ b/src/lib/github-app-utils.ts @@ -1,4 +1,5 @@ import { env } from "@/lib/env"; +import logger from "@/lib/logger"; /** * GitHub App installation utilities @@ -106,7 +107,7 @@ export function buildInstallationUrl(baseUrl: string): InstallationResult { url: installUrl.toString(), }; } catch (error) { - console.error("Error building installation URL:", error); + logger.error({ error }, "Error building installation URL"); return { success: false, diff --git a/src/lib/github-labels.ts b/src/lib/github-labels.ts index b7aa162..b2b02d6 100644 --- a/src/lib/github-labels.ts +++ b/src/lib/github-labels.ts @@ -1,4 +1,5 @@ import { githubAppClient } from "@/lib/github-app"; +import logger from "@/lib/logger"; interface Repository { id: number; @@ -54,7 +55,7 @@ async function createJulesLabelsInRepository( color: label.color, description: label.description, }); - console.log(`Created label '${label.name}' in ${owner}/${repo}`); + logger.info(`Created label '${label.name}' in ${owner}/${repo}`); } catch (error: unknown) { // If label already exists, that's fine if ( @@ -72,16 +73,21 @@ async function createJulesLabelsInRepository( Array.isArray(error.response.data.errors) && error.response.data.errors[0]?.code === "already_exists" ) { - console.log( + logger.info( `Label '${label.name}' already exists in ${owner}/${repo}`, ); } else { // Log other errors but don't fail the installation const errorMessage = error instanceof Error ? error.message : String(error); - console.warn( - `Failed to create label '${label.name}' in ${owner}/${repo}:`, - errorMessage, + logger.warn( + { + error: errorMessage, + owner, + repo, + label: label.name, + }, + `Failed to create label in repository`, ); } } @@ -89,8 +95,13 @@ async function createJulesLabelsInRepository( } catch (error: unknown) { // Log descriptive error as requested, but don't fail the installation const errorMessage = error instanceof Error ? error.message : String(error); - console.error( - `Failed to create Jules labels in ${owner}/${repo}: ${errorMessage}. This might be due to insufficient permissions - the app needs 'Issues: Write' permission to create labels.`, + logger.error( + { + owner, + repo, + error: errorMessage, + }, + "Failed to create Jules labels - ensure 'Issues: Write' permission", ); } } @@ -113,7 +124,7 @@ async function processRepositoriesInChunks( chunkSize: number = 10, delayMs: number = 1000, ): Promise { - console.log( + logger.info( `Processing ${repositories.length} repositories in chunks of ${chunkSize} with ${delayMs}ms delay`, ); @@ -132,14 +143,14 @@ async function processRepositoriesInChunks( // Add delay between chunks to respect rate limits (except for the last chunk) if (i + chunkSize < repositories.length) { - console.log( + logger.info( `Processed chunk ${Math.floor(i / chunkSize) + 1}/${Math.ceil(repositories.length / chunkSize)}, waiting ${delayMs}ms...`, ); await sleep(delayMs); } } - console.log(`Completed processing all ${repositories.length} repositories`); + logger.info(`Completed processing all ${repositories.length} repositories`); } /** @@ -150,7 +161,7 @@ export async function createJulesLabelsForRepositories( installationId: number, ): Promise { if (repositories.length === 0) { - console.log("No repositories to process for label creation"); + logger.info("No repositories to process for label creation"); return; } diff --git a/src/lib/installation-service.ts b/src/lib/installation-service.ts index c719266..c027fb7 100644 --- a/src/lib/installation-service.ts +++ b/src/lib/installation-service.ts @@ -1,5 +1,6 @@ -import { db } from "@/server/db"; import { githubAppClient } from "@/lib/github-app"; +import logger from "@/lib/logger"; +import { db } from "@/server/db"; /** * Service for managing GitHub App installations @@ -113,7 +114,7 @@ export class InstallationService { */ async syncInstallation(installationId: number) { try { - console.log(`Syncing installation ${installationId} with GitHub`); + logger.info(`Syncing installation ${installationId} with GitHub`); // Check if installation exists in GitHub const installations = await githubAppClient.getInstallations(); @@ -138,7 +139,7 @@ export class InstallationService { data: { removedAt: new Date() }, }); - console.log( + logger.info( `Installation ${installationId} marked as suspended (not found in GitHub or missing account)`, ); return null; @@ -220,12 +221,12 @@ export class InstallationService { }); } - console.log( + logger.info( `Synced installation ${installationId}: ${githubRepositories.length} repositories`, ); return await this.getInstallation(installationId); } catch (error) { - console.error(`Failed to sync installation ${installationId}:`, error); + logger.error({ error }, `Failed to sync installation ${installationId}`); throw error; } } @@ -246,7 +247,10 @@ export class InstallationService { data: synced, }); } catch (error) { - console.error(`Failed to sync installation ${installation.id}:`, error); + logger.error( + { error }, + `Failed to sync installation ${installation.id}`, + ); results.push({ installationId: installation.id, success: false, @@ -369,7 +373,7 @@ export class InstallationService { }, }); - console.log( + logger.info( `Cleaned up ${deletedCount.count} suspended installations older than ${olderThanDays} days`, ); return deletedCount.count; diff --git a/src/lib/jules.ts b/src/lib/jules.ts index eced8c6..082d247 100644 --- a/src/lib/jules.ts +++ b/src/lib/jules.ts @@ -1,4 +1,5 @@ import { githubClient } from "@/lib/github"; +import logger from "@/lib/logger"; import { getUserAccessToken } from "@/lib/token-manager"; import { db } from "@/server/db"; import logger from "@/lib/logger"; @@ -20,7 +21,8 @@ const JULES_BOT_USERNAMES = ["google-labs-jules[bot]", "google-labs-jules"]; */ const TASK_LIMIT_PATTERNS = [ "You are currently at your concurrent task limit", - "You are currently at your limit of 5 running tasks", + "You are currently at your limit", + "Jules has failed to create a task", ]; /** @@ -395,12 +397,13 @@ export async function handleTaskLimit( repo, analysis.comment.id, "eyes", + installationId, ); logger.info( `Added refresh emoji reaction to Jules comment for task limit`, ); } catch (reactionError) { - console.warn(`Failed to add refresh reaction: ${reactionError}`); + logger.warn(`Failed to add refresh reaction: ${reactionError}`); } } @@ -484,7 +487,7 @@ export async function handleWorking( `Added thumbs up emoji reaction to Jules comment for working status`, ); } catch (reactionError) { - console.warn(`Failed to add thumbs up reaction: ${reactionError}`); + logger.warn(`Failed to add thumbs up reaction: ${reactionError}`); } } diff --git a/src/lib/token-manager.ts b/src/lib/token-manager.ts index 15c2f8f..9caf6a9 100644 --- a/src/lib/token-manager.ts +++ b/src/lib/token-manager.ts @@ -1,16 +1,32 @@ import { decrypt, encrypt } from "@/lib/crypto"; import { env } from "@/lib/env"; -import { db } from "@/server/db"; import logger from "@/lib/logger"; +import { db } from "@/server/db"; -async function refreshUserToken(refreshToken: string): Promise<{ +// Discriminated union type for GitHub token responses +type GitHubTokenSuccessResponse = { access_token: string; refresh_token: string; expires_in: number; refresh_token_expires_in: number; - error?: string; +}; + +type GitHubTokenErrorResponse = { + error: string; error_description?: string; -}> { + error_uri?: string; +}; + +type GitHubTokenResponse = + | GitHubTokenSuccessResponse + | GitHubTokenErrorResponse; + +async function refreshUserToken( + refreshToken: string, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + try { const response = await fetch( "https://github.com/login/oauth/access_token", @@ -26,18 +42,53 @@ async function refreshUserToken(refreshToken: string): Promise<{ grant_type: "refresh_token", refresh_token: refreshToken, }), + signal: controller.signal, }, ); + clearTimeout(timeoutId); + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); - // Always return the data - let the caller handle GitHub API errors - return data; + // Validate the response structure + if (data.error) { + // This is an error response + return { + error: data.error, + error_description: data.error_description, + error_uri: data.error_uri, + }; + } + + // Validate success response has required fields + if ( + !data.access_token || + !data.refresh_token || + typeof data.expires_in !== "number" || + typeof data.refresh_token_expires_in !== "number" + ) { + throw new Error("Invalid token response: missing required fields"); + } + + // This is a success response + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + refresh_token_expires_in: data.refresh_token_expires_in, + }; } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === "AbortError") { + logger.error({ error }, "Token refresh request timed out"); + throw new Error("Token refresh request timed out"); + } + logger.error({ error }, "Failed to refresh user token"); throw error; } @@ -55,8 +106,8 @@ export async function getUserAccessToken( if ( !installation || - !installation.user_access_token || - !installation.refresh_token + !installation.userAccessToken || + !installation.refreshToken ) { logger.warn( `[TokenManager] No token found for installation: ${installationId}`, @@ -64,7 +115,7 @@ export async function getUserAccessToken( return null; } - const decryptedRefreshToken = decrypt(installation.refresh_token); + const decryptedRefreshToken = decrypt(installation.refreshToken); if (!decryptedRefreshToken) { logger.error( `[TokenManager] Failed to decrypt refresh token for installation: ${installationId}.`, @@ -72,18 +123,17 @@ export async function getUserAccessToken( return null; } - if ( - installation.token_expires_at && - installation.token_expires_at < new Date() - ) { + if (installation.tokenExpiresAt && installation.tokenExpiresAt < new Date()) { logger.info( `[TokenManager] Token expired for installation: ${installationId}. Refreshing...`, ); try { const refreshed = await refreshUserToken(decryptedRefreshToken); - if (refreshed.error) { + + // Check if this is an error response + if ("error" in refreshed) { logger.error( - `[TokenManager] Error refreshing token for installation ${installationId}: ${refreshed.error_description}`, + `[TokenManager] Error refreshing token for installation ${installationId}: ${refreshed.error_description || refreshed.error}`, ); if (refreshed.error === "bad_refresh_token") { logger.info( @@ -92,16 +142,29 @@ export async function getUserAccessToken( await db.gitHubInstallation.update({ where: { id: installationId }, data: { - user_access_token: null, - refresh_token: null, - token_expires_at: null, - refresh_token_expires_at: null, + userAccessToken: null, + refreshToken: null, + tokenExpiresAt: null, + refreshTokenExpiresAt: null, }, }); } return null; } + // This is a success response - validate before destructuring + if ( + !refreshed.access_token || + !refreshed.refresh_token || + typeof refreshed.expires_in !== "number" || + typeof refreshed.refresh_token_expires_in !== "number" + ) { + logger.error( + `[TokenManager] Invalid token response structure for installation ${installationId}`, + ); + return null; + } + const { access_token, refresh_token, @@ -117,10 +180,10 @@ export async function getUserAccessToken( await db.gitHubInstallation.update({ where: { id: installationId }, data: { - user_access_token: encrypt(access_token), - refresh_token: encrypt(refresh_token), - token_expires_at: tokenExpiresAt, - refresh_token_expires_at: refreshTokenExpiresAt, + userAccessToken: encrypt(access_token), + refreshToken: encrypt(refresh_token), + tokenExpiresAt: tokenExpiresAt, + refreshTokenExpiresAt: refreshTokenExpiresAt, }, }); logger.info( @@ -136,7 +199,7 @@ export async function getUserAccessToken( } } - const decryptedAccessToken = decrypt(installation.user_access_token); + const decryptedAccessToken = decrypt(installation.userAccessToken); if (!decryptedAccessToken) { logger.error( `[TokenManager] Failed to decrypt access token for installation: ${installationId}.`, diff --git a/src/lib/webhook-processor.ts b/src/lib/webhook-processor.ts index d0d3f0a..7806f63 100644 --- a/src/lib/webhook-processor.ts +++ b/src/lib/webhook-processor.ts @@ -5,6 +5,7 @@ import { processWorkflowDecision, upsertJulesTask, } from "@/lib/jules"; +import logger from "@/lib/logger"; import { db } from "@/server/db"; import type { GitHubLabelEvent, ProcessingResult } from "@/types"; @@ -19,7 +20,7 @@ async function scheduleCommentCheck( taskId: number, delayMs: number = 60000, // 60 seconds ): Promise { - console.log( + logger.info( `Scheduling comment check for ${owner}/${repo}#${issueNumber} in ${delayMs}ms`, ); @@ -31,9 +32,9 @@ async function scheduleCommentCheck( try { await executeCommentCheck(owner, repo, issueNumber, taskId); } catch (error) { - console.error( - `Comment check failed for ${owner}/${repo}#${issueNumber}:`, - error, + logger.error( + { error }, + `Comment check failed for ${owner}/${repo}#${issueNumber}`, ); } }, delayMs); @@ -48,7 +49,7 @@ async function executeCommentCheck( issueNumber: number, taskId: number, ): Promise { - console.log( + logger.info( `Executing enhanced comment check for ${owner}/${repo}#${issueNumber}`, ); @@ -59,7 +60,7 @@ async function executeCommentCheck( }); if (!task) { - console.log(`Task ${taskId} no longer exists, skipping comment check`); + logger.info(`Task ${taskId} no longer exists, skipping comment check`); return; } @@ -73,7 +74,7 @@ async function executeCommentCheck( task.installationId || undefined, ); - console.log( + logger.info( `Comment analysis result for ${owner}/${repo}#${issueNumber}:`, { action: commentResult.action, @@ -110,9 +111,9 @@ async function executeCommentCheck( }, }); } catch (error) { - console.error( - `Error during comment check for ${owner}/${repo}#${issueNumber}:`, - error, + logger.error( + { error }, + `Error during comment check for ${owner}/${repo}#${issueNumber}`, ); // Log the error to the database @@ -132,7 +133,7 @@ async function executeCommentCheck( }, }); } catch (logError) { - console.error("Failed to log comment check error:", logError); + logger.error({ error: logError }, "Failed to log comment check error"); } } } @@ -186,7 +187,7 @@ export async function processJulesLabelEvent( installationId, }); - console.log( + logger.info( `Created/updated task ${task.id} for ${owner}/${repo}#${issue.number}`, ); @@ -195,7 +196,7 @@ export async function processJulesLabelEvent( (l) => l.name.toLowerCase() === "human", ); if (hasHumanLabel) { - console.log( + logger.info( `Issue ${owner}/${repo}#${issue.number} has 'Human' label, skipping automatic processing`, ); return { @@ -232,7 +233,7 @@ export async function processJulesLabelEvent( ); if (hasJulesQueueLabel) { - console.log( + logger.info( `Jules label removed from ${owner}/${repo}#${issue.number} but jules-queue label present - keeping task flagged for retry`, ); return { @@ -252,7 +253,7 @@ export async function processJulesLabelEvent( }, }); - console.log( + logger.info( `Jules label manually removed from ${owner}/${repo}#${issue.number}, updated task ${existingTask.id}`, ); @@ -271,7 +272,7 @@ export async function processJulesLabelEvent( // Handle 'jules-queue' label events (mostly for logging/monitoring) if (labelName === "jules-queue") { - console.log( + logger.info( `Jules-queue label ${action} on ${owner}/${repo}#${issue.number}`, ); @@ -288,15 +289,18 @@ export async function processJulesLabelEvent( } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error("Error processing Jules label event:", { - error: errorMessage, - event: { - action, - label: label.name, - issue: issue.number, - repository: repository.full_name, + logger.error( + { + error: errorMessage, + event: { + action, + label: label.name, + issue: issue.number, + repository: repository.full_name, + }, }, - }); + "Error processing Jules label event", + ); return { action: "error", @@ -327,7 +331,7 @@ export async function triggerCommentCheck( const { repoOwner, repoName, githubIssueNumber } = task; const issueNumber = Number(githubIssueNumber); - console.log( + logger.info( `Manually triggering comment check for task ${taskId}: ${repoOwner}/${repoName}#${issueNumber}`, ); @@ -398,7 +402,7 @@ export async function getProcessingStats() { : 100, }; } catch (error) { - console.error("Failed to get processing stats:", error); + logger.error({ error }, "Failed to get processing stats"); throw error; } } diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index 61d6dd2..9065450 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -1,9 +1,10 @@ +import { installationService } from "@/lib/installation-service"; import { getFlaggedTasks, getTaskStats, retryAllFlaggedTasks, } from "@/lib/jules"; -import { installationService } from "@/lib/installation-service"; +import logger from "@/lib/logger"; import { getProcessingStats } from "@/lib/webhook-processor"; import { adminProcedure, createTRPCRouter } from "@/server/api/trpc"; import { z } from "zod"; @@ -58,7 +59,7 @@ export const adminRouter = createTRPCRouter({ stats, }; } catch (error) { - console.error("Failed to retry all tasks:", error); + logger.error({ error }, "Failed to retry all tasks"); throw new Error( `Retry failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -83,7 +84,7 @@ export const adminRouter = createTRPCRouter({ : `Task ${input.taskId} retry skipped or failed`, }; } catch (error) { - console.error(`Failed to retry task ${input.taskId}:`, error); + logger.error({ error }, `Failed to retry task ${input.taskId}`); throw new Error( `Retry failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -193,7 +194,7 @@ export const adminRouter = createTRPCRouter({ timestamp: new Date().toISOString(), }; } catch (error) { - console.error("Failed to get admin health stats:", error); + logger.error({ error }, "Failed to get admin health stats"); throw new Error( `Health check failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -220,7 +221,7 @@ export const adminRouter = createTRPCRouter({ message: `Cleaned up ${deletedCount} tasks older than ${input.olderThanDays} days`, }; } catch (error) { - console.error("Failed to cleanup old tasks:", error); + logger.error({ error }, "Failed to cleanup old tasks"); throw new Error( `Cleanup failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -297,7 +298,7 @@ export const adminRouter = createTRPCRouter({ timestamp: new Date().toISOString(), }; } catch (error) { - console.error("Failed to get admin metrics:", error); + logger.error({ error }, "Failed to get admin metrics"); throw new Error( `Metrics failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -399,9 +400,9 @@ export const adminRouter = createTRPCRouter({ data: result, }; } catch (error) { - console.error( - `Failed to sync installation ${input.installationId}:`, - error, + logger.error( + { error }, + `Failed to sync installation ${input.installationId}`, ); throw new Error( `Sync failed: ${error instanceof Error ? error.message : "Unknown error"}`, @@ -423,7 +424,7 @@ export const adminRouter = createTRPCRouter({ stats: { successful, failed, total: results.length }, }; } catch (error) { - console.error("Failed to sync all installations:", error); + logger.error({ error }, "Failed to sync all installations"); throw new Error( `Sync all failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); @@ -460,7 +461,7 @@ export const adminRouter = createTRPCRouter({ message: `Cleaned up ${deletedCount} suspended installations older than ${input.olderThanDays} days`, }; } catch (error) { - console.error("Failed to cleanup suspended installations:", error); + logger.error({ error }, "Failed to cleanup suspended installations"); throw new Error( `Cleanup failed: ${error instanceof Error ? error.message : "Unknown error"}`, );