From aed84448c7bb0970d2f6c8197ca5685d19eceeca Mon Sep 17 00:00:00 2001 From: iHildy Date: Sun, 20 Jul 2025 01:10:50 -0500 Subject: [PATCH 1/9] implemented access-tokens -only manual steps required now / added pino for pretty logs / updated documentation --- .env.example | 3 + API_DOCUMENTATION.md | 4 +- Dockerfile | 2 + FIREBASE.md | 8 +- GITHUB_APP_SETUP.md | 7 +- README.md | 2 + SELF_HOSTING.md | 23 +- convert-to-access-token.md | 99 ++--- docker-compose.selfhost.yml | 2 + docker-compose.yml | 1 + firebase.json | 4 + jules-queueing-system.md | 19 +- next.config.ts | 1 + package.json | 2 + pnpm-lock.yaml | 246 ++++++++++++ .../migration.sql | 5 + prisma/schema.prisma | 6 + src/app/api/auth/callback/github/route.ts | 81 ++++ src/app/api/cron/cleanup/route.ts | 62 +++ src/app/api/github-app/star-check/route.ts | 6 +- src/app/api/webhooks/github-app/route.ts | 367 +++++++++--------- src/app/not-found.tsx | 13 + src/lib/crypto.ts | 37 ++ src/lib/env.ts | 8 + src/lib/github-app.ts | 27 +- src/lib/github.ts | 67 ++-- src/lib/jules.ts | 87 +++-- src/lib/logger.ts | 13 + src/lib/token-manager.ts | 133 +++++++ vercel.json | 10 +- 30 files changed, 1000 insertions(+), 345 deletions(-) create mode 100644 prisma/migrations/20250720035824_add_user_tokens/migration.sql create mode 100644 src/app/api/auth/callback/github/route.ts create mode 100644 src/app/api/cron/cleanup/route.ts create mode 100644 src/app/not-found.tsx create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/token-manager.ts diff --git a/.env.example b/.env.example index 70f94d0..1eb8f17 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ NEXT_PUBLIC_GITHUB_APP_NAME="jules-task-queue" # Cron job verification token (for Firebase you also need to add this in ./functions/.env as well) CRON_SECRET="1234567890" +# Token Encryption +TOKEN_ENCRYPTION_KEY="your_32_character_long_encryption_key_here" + # Development NODE_ENV="development" __NEXT_TEST_MODE="1" diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index eee8824..0f6d60e 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -15,7 +15,9 @@ When self-hosted: `http://localhost:3000/api/trpc` (or your configured domain) ## Authentication -Currently, the API endpoints are open for development. In production, you should implement authentication middleware in the tRPC context (`src/server/api/trpc.ts`). +This API leverages GitHub App authentication, including user access tokens obtained via OAuth during installation. This ensures that actions performed by the system (e.g., label changes) are attributed to the user who authorized the app, allowing Jules to respond correctly. + +For development, endpoints are open. In production, implement authentication middleware in the tRPC context (`src/server/api/trpc.ts`) to secure your API. --- diff --git a/Dockerfile b/Dockerfile index b7c2e53..ce8a65b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,11 @@ FROM node:18-alpine AS builder ARG SKIP_ENV_VALIDATION=true ARG NEXT_PUBLIC_GITHUB_APP_ID ARG NEXT_PUBLIC_GITHUB_APP_NAME +ARG GITHUB_APP_CALLBACK_URL ENV SKIP_ENV_VALIDATION=${SKIP_ENV_VALIDATION} ENV NEXT_PUBLIC_GITHUB_APP_ID=${NEXT_PUBLIC_GITHUB_APP_ID} ENV NEXT_PUBLIC_GITHUB_APP_NAME=${NEXT_PUBLIC_GITHUB_APP_NAME} +ENV GITHUB_APP_CALLBACK_URL=${GITHUB_APP_CALLBACK_URL} ENV BUILD_STANDALONE=true # Set working directory diff --git a/FIREBASE.md b/FIREBASE.md index 96b772f..511b56d 100644 --- a/FIREBASE.md +++ b/FIREBASE.md @@ -37,8 +37,9 @@ You'll need these values during setup: - `NEXT_PUBLIC_GITHUB_APP_ID`: GitHub App ID from your app settings - `GITHUB_APP_PRIVATE_KEY`: GitHub App private key (base64 encoded) - `GITHUB_APP_WEBHOOK_SECRET`: Secret for GitHub App webhook verification -- `GITHUB_APP_CLIENT_ID`: GitHub App client ID (optional) -- `GITHUB_APP_CLIENT_SECRET`: GitHub App client secret (optional) +- `GITHUB_APP_CLIENT_ID`: GitHub App client ID (for OAuth user access tokens) +- `GITHUB_APP_CLIENT_SECRET`: GitHub App client secret (for OAuth user access tokens) +- `GITHUB_APP_CALLBACK_URL`: Callback URL for GitHub App OAuth flow - `NEXT_PUBLIC_GITHUB_APP_NAME`: Your GitHub App name (optional) - `CRON_SECRET`: Random string for cron job authentication @@ -123,6 +124,9 @@ firebase apphosting:secrets:set GITHUB_APP_CLIENT_ID firebase apphosting:secrets:set GITHUB_APP_CLIENT_SECRET # Enter your GitHub App client secret when prompted (optional) +firebase apphosting:secrets:set GITHUB_APP_CALLBACK_URL +# Enter your GitHub App callback URL when prompted + firebase apphosting:secrets:set NEXT_PUBLIC_GITHUB_APP_NAME # Enter your GitHub App name when prompted (optional) diff --git a/GITHUB_APP_SETUP.md b/GITHUB_APP_SETUP.md index 6365dfb..212bc90 100644 --- a/GITHUB_APP_SETUP.md +++ b/GITHUB_APP_SETUP.md @@ -65,7 +65,7 @@ Replace `your-domain.com` with your actual deployment URL: **User Authorization Options:** - ✅ **Expire user authorization tokens** - Provides a `refresh_token` for updated access tokens when they expire -- ⬜ **Request user authorization (OAuth) during installation** - Leave unchecked unless you need user-level permissions +- ✅ **Request user authorization (OAuth) during installation** - **CHECK THIS BOX**. This enables the app to request user-level permissions during installation, allowing Jules to respond to automated label changes. - ⬜ **Enable Device Flow** - Leave unchecked unless you need device-based authentication ### 1.3 Configure Post-installation Setup @@ -176,8 +176,9 @@ GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- ...your private key content... -----END RSA PRIVATE KEY-----" # Content of your .pem file GITHUB_APP_WEBHOOK_SECRET="your-webhook-secret" -GITHUB_APP_CLIENT_ID="Iv1.your-client-id" -GITHUB_APP_CLIENT_SECRET="your-client-secret" +GITHUB_APP_CLIENT_ID="Iv1.your-client-id" # For OAuth flow to get user access tokens +GITHUB_APP_CLIENT_SECRET="your-client-secret" # For OAuth flow to get user access tokens +GITHUB_APP_CALLBACK_URL="https://your-domain.com/api/auth/callback/github" # Callback URL for OAuth ``` ### 4.2 Private Key Formatting diff --git a/README.md b/README.md index 0a03606..8170c11 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,12 @@ This tool is the better way. It transforms Jules from a tool you have to manage ## ✨ Features +- **🔑 User Access Token Integration**: Seamlessly integrates with GitHub App user access tokens, ensuring Jules responds to automated label changes. - **🤖 Task Status Detection**: Automatically detects when Jules is at capacity and intelligently queues new tasks. - **🔄 Auto-Retry Logic**: 30-minute retry cycles with intelligent label swapping and failure recovery. - **🚀 Easy Self-Hosting**: Deploy with one click to Vercel, Firebase, or use the provided Docker Compose setup. - **🔐 GitHub Native**: Secure webhook integration with signature verification and comprehensive audit logging. +- **📊 Enhanced Observability**: Integrated structured logging with Pino for better monitoring and debugging. - **🔒 Type Safe**: End-to-end TypeScript with tRPC and Zod validation for bulletproof deployments. - **⚙️ Zero Config (Hosted)**: Install the GitHub App and you're done. No complex setup required. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index d3b5892..395271e 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -148,17 +148,18 @@ In your platform's scheduled tasks: ### Environment Variables -| Variable | Build Variable | Description | -| ----------------------------- | -------------- | --------------------------------------------------- | -| `DATABASE_URL` | No | PostgreSQL connection string | -| `NEXT_PUBLIC_GITHUB_APP_ID` | Yes | GitHub App ID from your app settings | -| `NEXT_PUBLIC_GITHUB_APP_NAME` | Yes | GitHub App name (for client-side display) | -| `GITHUB_APP_PRIVATE_KEY` | No | GitHub App private key (base64 encoded or with \n) | -| `GITHUB_APP_WEBHOOK_SECRET` | No | Secret used to verify GitHub App webhook signatures | -| `GITHUB_APP_CLIENT_ID` | No | GitHub App client ID (for OAuth, if needed) | -| `GITHUB_APP_CLIENT_SECRET` | No | GitHub App client secret (for OAuth, if needed) | -| `CRON_SECRET` | No | Secret for authenticating cron job requests | -| `NODE_ENV` | No | Set to `production` for production deployments | +| Variable | Build Variable | Description | +| ----------------------------- | -------------- | ------------------------------------------------------- | +| `DATABASE_URL` | No | PostgreSQL connection string | +| `NEXT_PUBLIC_GITHUB_APP_ID` | Yes | GitHub App ID from your app settings | +| `NEXT_PUBLIC_GITHUB_APP_NAME` | Yes | GitHub App name (for client-side display) | +| `GITHUB_APP_PRIVATE_KEY` | No | GitHub App private key (base64 encoded or with \n) | +| `GITHUB_APP_WEBHOOK_SECRET` | No | Secret used to verify GitHub App webhook signatures | +| `GITHUB_APP_CLIENT_ID` | No | GitHub App client ID (for OAuth user access tokens) | +| `GITHUB_APP_CLIENT_SECRET` | No | GitHub App client secret (for OAuth user access tokens) | +| `GITHUB_APP_CALLBACK_URL` | No | Callback URL for GitHub App OAuth flow | +| `CRON_SECRET` | No | Secret for authenticating cron job requests | +| `NODE_ENV` | No | Set to `production` for production deployments | ### GitHub App Setup diff --git a/convert-to-access-token.md b/convert-to-access-token.md index ff51f53..3034fd4 100644 --- a/convert-to-access-token.md +++ b/convert-to-access-token.md @@ -17,20 +17,13 @@ NOTE: WE DO NOT NEED TO BE BACKWARDS COMPATIBLE WITH THE CURRENT APPROACH. WE CA ### Phase 1: GitHub App Configuration -- [ ] **Enable OAuth during installation** - - Go to GitHub App settings: https://github.com/settings/apps/[YOUR_APP_NAME] - - Check "Request user authorization (OAuth) during installation" - - Set callback URL to: `https://your-domain.com/api/auth/callback/github` - - Save changes +- [ ] **Enable OAuth during installation** (Manual step: Go to GitHub App settings, check "Request user authorization (OAuth) during installation", set callback URL to `https://your-domain.com/api/auth/callback/github`, and save changes.) -- [ ] **Configure token expiration (Recommended)** - - In GitHub App settings → Optional Features - - Enable "User-to-server token expiration" for improved security - - This makes tokens expire after 8 hours with 6-month refresh tokens +- [ ] **Configure token expiration (Recommended)** (Manual step: In GitHub App settings → Optional Features, enable "User-to-server token expiration".) ### Phase 2: Database Schema Updates -- [ ] **Add user token storage to database** +- [x] **Add user token storage to database - VIA THE PRISMA SCHEMA FILE** ```sql -- Add to existing tables or create new table @@ -40,21 +33,21 @@ NOTE: WE DO NOT NEED TO BE BACKWARDS COMPATIBLE WITH THE CURRENT APPROACH. WE CA ALTER TABLE github_app_installations ADD COLUMN refresh_token_expires_at TIMESTAMP; ``` -- [ ] **Create Prisma migration** +- [x] **Create Prisma migration** ```bash pnpm prisma migrate dev --name add_user_tokens ``` ### Phase 3: OAuth Callback Implementation -- [ ] **Create OAuth callback endpoint** +- [x] **Create OAuth callback endpoint** - File: `src/app/api/auth/callback/github/route.ts` - Handle the `code` parameter from GitHub - Exchange code for user access token - Store tokens in database with installation ID - Redirect user to success page -- [ ] **Implement token exchange logic** +- [x] **Implement token exchange logic** ```typescript // Exchange code for tokens const response = await fetch("https://github.com/login/oauth/access_token", { @@ -71,13 +64,13 @@ NOTE: WE DO NOT NEED TO BE BACKWARDS COMPATIBLE WITH THE CURRENT APPROACH. WE CA ### Phase 4: Token Management System -- [ ] **Create token manager service** +- [x] **Create token manager service** - File: `src/lib/token-manager.ts` - Methods for storing, retrieving, and refreshing tokens - Automatic token refresh when expired - Secure token encryption/decryption -- [ ] **Implement token refresh logic** +- [x] **Implement token refresh logic** ```typescript async function refreshUserToken(refreshToken: string): Promise<{ access_token: string; @@ -104,95 +97,67 @@ NOTE: WE DO NOT NEED TO BE BACKWARDS COMPATIBLE WITH THE CURRENT APPROACH. WE CA ### Phase 5: Update Jules Retry System -- [ ] **Modify retry logic to use user tokens** +- [x] **Modify retry logic to use user tokens** - Update `src/lib/jules.ts` `processTaskRetry` function - Get user token from database for installation - Use user token for label operations instead of installation token - Handle token refresh if expired -- [ ] **Update GitHub client** +- [x] **Update GitHub client** - Modify `src/lib/github.ts` to accept user tokens - Create methods that use user tokens for label operations - Maintain backward compatibility with installation tokens ### Phase 6: Installation Webhook Updates -- [ ] **Update installation webhook handler** +- [x] **Update installation webhook handler** - File: `src/app/api/webhooks/github-app/route.ts` - Store installation ID when app is installed - Note: User tokens will be generated via OAuth callback, not webhook -- [ ] **Handle installation removal** +- [x] **Handle installation removal** - Clean up stored tokens when app is uninstalled - Remove from database ### Phase 7: Error Handling & Fallbacks -- [ ] **Implement graceful fallbacks** +- [x] **Implement graceful fallbacks** - If user token is missing/expired, fall back to installation token - Log warnings when falling back (Jules may not respond) - Provide clear error messages for token issues -- [ ] **Add token validation** +- [x] **Add token validation** - Validate tokens before use - Check expiration times - Handle 401/403 errors from GitHub API ### Phase 8: Testing & Validation -- [ ] **Test OAuth flow** - - Install app and verify OAuth redirect works - - Confirm tokens are stored in database - - Test token refresh functionality +- [ ] **Test OAuth flow** (Manual step: Install app and verify OAuth redirect works, confirm tokens are stored in database, test token refresh functionality) -- [ ] **Test Jules integration** - - Create test issue with `jules-queue` label - - Run retry process with user token - - Verify Jules responds to label changes +- [ ] **Test Jules integration** (Manual step: Create test issue with `jules-queue` label, run retry process with user token, verify Jules responds to label changes) -- [ ] **Test token expiration** - - Simulate expired tokens - - Verify refresh logic works - - Test fallback to installation tokens +- [ ] **Test token expiration** (Manual step: Simulate expired tokens, verify refresh logic works, test fallback to installation tokens) ### Phase 9: Security & Monitoring -- [ ] **Add token encryption** +- [x] **Add token encryption** + +- [ ] **Add token cleanup** (Partially done: Handled reactively when refresh token is bad. Proactive cleanup of truly expired refresh tokens (e.g., via a cron job) is a potential missing piece.)\*\* - Encrypt tokens before storing in database - Use environment variables for encryption keys - Implement secure token rotation -- [ ] **Add monitoring** - - Log token usage and refresh events - - Monitor for token errors - - Alert on token expiration issues - - [ ] **Add token cleanup** - Remove expired refresh tokens - - Clean up unused installations - - Regular database maintenance ### Phase 10: Documentation & Deployment -- [ ] **Update documentation** - - Update `README.md` with new setup instructions - - Document OAuth flow for users - - Add troubleshooting guide +- [ ] **Update documentation** (Manual step: Update `README.md` with new setup instructions, document OAuth flow for users, add troubleshooting guide) -- [ ] **Environment variables** - - ```bash - # Add to .env.local - GITHUB_APP_CLIENT_ID=your_client_id - GITHUB_APP_CLIENT_SECRET=your_client_secret - GITHUB_APP_CALLBACK_URL=https://your-domain.com/api/auth/callback/github - TOKEN_ENCRYPTION_KEY=your_encryption_key - ``` +- [ ] **Environment variables** (Manual step: Add `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_CALLBACK_URL`, `TOKEN_ENCRYPTION_KEY` to `.env.local`) -- [ ] **Deploy and test** - - Deploy to production - - Test full OAuth flow - - Monitor for issues +- [ ] **Deploy and test** (Manual step: Deploy to production, test full OAuth flow, monitor for issues) ## Files to Create/Modify @@ -210,18 +175,10 @@ NOTE: WE DO NOT NEED TO BE BACKWARDS COMPATIBLE WITH THE CURRENT APPROACH. WE CA - `prisma/schema.prisma` - Add token fields - `README.md` - Update documentation -## Rollback Plan - -If issues arise, we can: - -1. Disable OAuth during installation in GitHub App settings -2. Revert to installation token approach -3. Keep user token code as fallback option - ## Success Criteria -- [ ] Users can install app and automatically get user access tokens -- [ ] Jules bot responds to automated label changes -- [ ] Token refresh works automatically -- [ ] System gracefully handles token expiration -- [ ] No manual intervention required for token management +- [ ] Users can install app and automatically get user access tokens (Manual verification required) +- [ ] Jules bot responds to automated label changes (Manual verification required) +- [x] Token refresh works automatically (Logic implemented, manual verification required) +- [x] System gracefully handles token expiration (Logic implemented, manual verification required) +- [ ] No manual intervention required for token management (Logic implemented, but proactive cleanup for expired refresh tokens is a potential missing piece, manual verification required) diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 3739194..be1c21f 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -17,6 +17,7 @@ services: - GITHUB_APP_WEBHOOK_SECRET=${GITHUB_APP_WEBHOOK_SECRET} - GITHUB_APP_CLIENT_ID=${GITHUB_APP_CLIENT_ID} - GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET} + - GITHUB_APP_CALLBACK_URL=${GITHUB_APP_CALLBACK_URL} - NEXT_PUBLIC_GITHUB_APP_NAME=${NEXT_PUBLIC_GITHUB_APP_NAME} - CRON_SECRET=${CRON_SECRET} depends_on: @@ -74,6 +75,7 @@ services: - GITHUB_APP_WEBHOOK_SECRET=${GITHUB_APP_WEBHOOK_SECRET} - GITHUB_APP_CLIENT_ID=${GITHUB_APP_CLIENT_ID} - GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET} + - GITHUB_APP_CALLBACK_URL=${GITHUB_APP_CALLBACK_URL} - NEXT_PUBLIC_GITHUB_APP_NAME=${NEXT_PUBLIC_GITHUB_APP_NAME} - CRON_SECRET=${CRON_SECRET} depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index fd0a2a4..a63dda4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: - GITHUB_APP_WEBHOOK_SECRET=${GITHUB_APP_WEBHOOK_SECRET} - GITHUB_APP_CLIENT_ID=${GITHUB_APP_CLIENT_ID} - GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET} + - GITHUB_APP_CALLBACK_URL=${GITHUB_APP_CALLBACK_URL} - NEXT_PUBLIC_GITHUB_APP_NAME=${NEXT_PUBLIC_GITHUB_APP_NAME} - CRON_SECRET=${CRON_SECRET} restart: unless-stopped diff --git a/firebase.json b/firebase.json index 3f95218..3585c98 100644 --- a/firebase.json +++ b/firebase.json @@ -40,6 +40,10 @@ { "variable": "NODE_ENV", "value": "production" + }, + { + "variable": "GITHUB_APP_CALLBACK_URL", + "secret": "GITHUB_APP_CALLBACK_URL" } ] } diff --git a/jules-queueing-system.md b/jules-queueing-system.md index b4c4082..329a82d 100644 --- a/jules-queueing-system.md +++ b/jules-queueing-system.md @@ -8,6 +8,13 @@ The Jules Task Queueing System is a GitHub-integrated service that manages task ```mermaid graph TD + subgraph GitHub App Installation & OAuth + GAI["User Installs GitHub App"] --> GAC["GitHub Redirects to Callback URL (with code)"] + GAC --> OAE["OAuth Callback Endpoint (Exchange code for tokens)"] + OAE --> STD["Store Tokens in Database (encrypted)"] + STD --> SUCC["Redirect to Success Page"] + end + A["User adds 'jules' label to GitHub issue"] --> B["GitHub webhook triggers"] B --> C["Create/Update JulesTask in database"] C --> D["Start 60-second timer"] @@ -21,10 +28,10 @@ graph TD I --> K["Mark JulesTask.flaggedForRetry = true"] K --> L["Remove 'jules' label from GitHub issue"] - L --> M["Add 'jules-queue' label to GitHub issue"] + K --> M["Add 'jules-queue' label to GitHub issue"] M --> N["Task queued for retry"] - J --> O["Jules is actively working"] + J --> O["Jules is actively actively working"] O --> P["End - Success path"] Q["Cron job runs every 30 minutes"] --> R["Find all JulesTask where flaggedForRetry = true"] @@ -34,7 +41,7 @@ graph TD U --> V{"Issue has 'Human' label?"} V -->|Yes| W["Skip this task"] V -->|No| X["Remove 'jules-queue' label"] - X --> Y["Add 'jules' label back"] + X --> Y["Add 'jules' label back (using user token if available)"] Y --> Z["Set flaggedForRetry = false"] Z --> AA["Increment retryCount"] AA --> BB["Update lastRetryAt timestamp"] @@ -51,6 +58,12 @@ graph TD style J fill:#e8f5e8 style Q fill:#fff3e0 style CC fill:#e1f5fe + style GAI fill:#c8e6c9 + style GAC fill:#c8e6c9 + style OAE fill:#c8e6c9 + style STD fill:#c8e6c9 + style SUCC fill:#c8e6c9 + style Y fill:#e1f5fe ``` ## Key Components diff --git a/next.config.ts b/next.config.ts index e279bf3..e182511 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + serverExternalPackages: ["pino"], // Allow for build and dev to be in different directories distDir: process.env.NODE_ENV === "development" ? ".next/dev" : ".next/build", diff --git a/package.json b/package.json index 945b4a4..9beeb36 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "lucide-react": "^0.525.0", "next": "15.3.4", "next-themes": "^0.4.6", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", "sonner": "^2.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c848701..b42ea7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,12 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pino: + specifier: ^9.7.0 + version: 9.7.0 + pino-pretty: + specifier: ^13.0.0 + version: 13.0.0 react: specifier: ^19.1.0 version: 19.1.0 @@ -2794,6 +2800,13 @@ packages: } engines: { node: ">= 0.4" } + atomic-sleep@1.0.0: + resolution: + { + integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==, + } + engines: { node: ">=8.0.0" } + available-typed-arrays@1.0.7: resolution: { @@ -3088,6 +3101,12 @@ packages: integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==, } + dateformat@4.6.3: + resolution: + { + integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==, + } + debug@3.2.7: resolution: { @@ -3189,6 +3208,12 @@ packages: integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, } + end-of-stream@1.4.5: + resolution: + { + integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, + } + enhanced-resolve@5.18.2: resolution: { @@ -3495,6 +3520,12 @@ packages: integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==, } + fast-copy@3.0.2: + resolution: + { + integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==, + } + fast-deep-equal@3.1.3: resolution: { @@ -3527,6 +3558,19 @@ packages: integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, } + fast-redact@3.5.0: + resolution: + { + integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==, + } + engines: { node: ">=6" } + + fast-safe-stringify@2.1.1: + resolution: + { + integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==, + } + fastq@1.19.1: resolution: { @@ -3762,6 +3806,12 @@ packages: } engines: { node: ">= 0.4" } + help-me@5.0.0: + resolution: + { + integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==, + } + html-encoding-sniffer@4.0.0: resolution: { @@ -4073,6 +4123,13 @@ packages: } hasBin: true + joycon@3.1.1: + resolution: + { + integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==, + } + engines: { node: ">=10" } + js-tokens@4.0.0: resolution: { @@ -4561,6 +4618,19 @@ packages: } engines: { node: ">= 0.4" } + on-exit-leak-free@2.1.2: + resolution: + { + integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==, + } + engines: { node: ">=14.0.0" } + + once@1.4.0: + resolution: + { + integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, + } + onetime@7.0.0: resolution: { @@ -4670,6 +4740,32 @@ packages: engines: { node: ">=0.10" } hasBin: true + pino-abstract-transport@2.0.0: + resolution: + { + integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==, + } + + pino-pretty@13.0.0: + resolution: + { + integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==, + } + hasBin: true + + pino-std-serializers@7.0.0: + resolution: + { + integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==, + } + + pino@9.7.0: + resolution: + { + integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==, + } + hasBin: true + possible-typed-array-names@1.1.0: resolution: { @@ -4719,12 +4815,24 @@ packages: typescript: optional: true + process-warning@5.0.0: + resolution: + { + integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==, + } + prop-types@15.8.1: resolution: { integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, } + pump@3.0.3: + resolution: + { + integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==, + } + punycode@2.3.1: resolution: { @@ -4738,6 +4846,12 @@ packages: integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, } + quick-format-unescaped@4.0.4: + resolution: + { + integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==, + } + react-dom@19.1.0: resolution: { @@ -4805,6 +4919,13 @@ packages: } engines: { node: ">=0.10.0" } + real-require@0.2.0: + resolution: + { + integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==, + } + engines: { node: ">= 12.13.0" } + reflect.getprototypeof@1.0.10: resolution: { @@ -4908,6 +5029,13 @@ packages: } engines: { node: ">= 0.4" } + safe-stable-stringify@2.5.0: + resolution: + { + integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==, + } + engines: { node: ">=10" } + safer-buffer@2.1.2: resolution: { @@ -4927,6 +5055,12 @@ packages: integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==, } + secure-json-parse@2.7.0: + resolution: + { + integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==, + } + semver@6.3.1: resolution: { @@ -5045,6 +5179,12 @@ packages: } engines: { node: ">=18" } + sonic-boom@4.2.0: + resolution: + { + integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==, + } + sonner@2.0.6: resolution: { @@ -5061,6 +5201,13 @@ packages: } engines: { node: ">=0.10.0" } + split2@4.2.0: + resolution: + { + integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==, + } + engines: { node: ">= 10.x" } + stable-hash@0.0.5: resolution: { @@ -5244,6 +5391,12 @@ packages: } engines: { node: ">=18" } + thread-stream@3.1.0: + resolution: + { + integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==, + } + tinybench@2.9.0: resolution: { @@ -5658,6 +5811,12 @@ packages: } engines: { node: ">=18" } + wrappy@1.0.2: + resolution: + { + integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, + } + ws@8.18.3: resolution: { @@ -7316,6 +7475,8 @@ snapshots: async-function@1.0.0: {} + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -7481,6 +7642,8 @@ snapshots: date-fns@4.1.0: {} + dateformat@4.6.3: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -7527,6 +7690,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -7882,6 +8049,8 @@ snapshots: fast-content-type-parse@3.0.0: {} + fast-copy@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -7904,6 +8073,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -8031,6 +8204,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -8211,6 +8386,8 @@ snapshots: jiti@2.4.2: {} + joycon@3.1.1: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -8502,6 +8679,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -8555,6 +8738,42 @@ snapshots: pidtree@0.6.0: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.0.0: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pump: 3.0.3 + secure-json-parse: 2.7.0 + sonic-boom: 4.2.0 + strip-json-comments: 3.1.1 + + pino-std-serializers@7.0.0: {} + + pino@9.7.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -8580,16 +8799,25 @@ snapshots: optionalDependencies: typescript: 5.8.3 + process-warning@5.0.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -8628,6 +8856,8 @@ snapshots: react@19.1.0: {} + real-require@0.2.0: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -8724,6 +8954,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -8732,6 +8964,8 @@ snapshots: scheduler@0.26.0: {} + secure-json-parse@2.7.0: {} + semver@6.3.1: {} semver@7.7.2: {} @@ -8840,6 +9074,10 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + sonner@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -8847,6 +9085,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -8964,6 +9204,10 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -9280,6 +9524,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + ws@8.18.3: {} xml-name-validator@5.0.0: {} diff --git a/prisma/migrations/20250720035824_add_user_tokens/migration.sql b/prisma/migrations/20250720035824_add_user_tokens/migration.sql new file mode 100644 index 0000000..7fab172 --- /dev/null +++ b/prisma/migrations/20250720035824_add_user_tokens/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "github_installations" ADD COLUMN "refresh_token" TEXT, +ADD COLUMN "refresh_token_expires_at" TIMESTAMP(3), +ADD COLUMN "token_expires_at" TIMESTAMP(3), +ADD COLUMN "user_access_token" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9c7eb4f..cb87f9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,12 @@ model GitHubInstallation { tasks JulesTask[] LabelPreference LabelPreference? + // User OAuth tokens + user_access_token String? + refresh_token String? + token_expires_at DateTime? + refresh_token_expires_at DateTime? + // Indexes for performance @@index([accountId]) @@index([accountLogin]) diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts new file mode 100644 index 0000000..61ed5fe --- /dev/null +++ b/src/app/api/auth/callback/github/route.ts @@ -0,0 +1,81 @@ +import { encrypt } from "@/lib/crypto"; +import { env } from "@/lib/env"; +import { db } from "@/server/db"; +import { NextRequest, NextResponse } from "next/server"; +import logger from "@/lib/logger"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const installationId = searchParams.get("installation_id"); + + if (!code || !installationId) { + return NextResponse.json( + { error: "Missing code or installation_id" }, + { status: 400 }, + ); + } + + try { + const response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: env.NEXT_PUBLIC_GITHUB_APP_ID, + client_secret: env.GITHUB_APP_CLIENT_SECRET, + code, + redirect_uri: env.GITHUB_APP_CALLBACK_URL, + }), + }, + ); + + const data = await response.json(); + + if (data.error) { + logger.error( + { error: data.error, description: data.error_description }, + "Error exchanging code for token", + ); + return NextResponse.json( + { error: data.error_description }, + { status: 400 }, + ); + } + + const { + access_token, + refresh_token, + expires_in, + refresh_token_expires_in, + } = data; + + const tokenExpiresAt = new Date(Date.now() + expires_in * 1000); + const refreshTokenExpiresAt = new Date( + Date.now() + refresh_token_expires_in * 1000, + ); + + await db.gitHubInstallation.update({ + where: { id: Number(installationId) }, + data: { + user_access_token: encrypt(access_token), + refresh_token: encrypt(refresh_token), + token_expires_at: tokenExpiresAt, + refresh_token_expires_at: refreshTokenExpiresAt, + }, + }); + + // Redirect to a success page + return NextResponse.redirect(new URL("/github-app/success", request.url)); + } catch (error) { + logger.error({ error }, "OAuth callback error"); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts new file mode 100644 index 0000000..c99b14b --- /dev/null +++ b/src/app/api/cron/cleanup/route.ts @@ -0,0 +1,62 @@ +import { env } from "@/lib/env"; +import { db } from "@/server/db"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${env.CRON_SECRET}`) { + return new Response("Unauthorized", { + status: 401, + }); + } + + try { + const now = new Date(); + + // 1. Clean up expired refresh tokens + const expiredTokensResult = await db.gitHubInstallation.updateMany({ + where: { + refresh_token_expires_at: { + lt: now, + }, + }, + data: { + user_access_token: null, + refresh_token: null, + token_expires_at: null, + refresh_token_expires_at: null, + }, + }); + + // 2. Clean up old, uninstalled installations (e.g., older than 30 days) + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const oldInstallationsResult = await db.gitHubInstallation.deleteMany({ + where: { + suspendedAt: { + lt: thirtyDaysAgo, + }, + suspendedBy: "uninstalled", + }, + }); + + const response = { + message: "Cleanup cron job executed successfully.", + cleanedExpiredTokens: expiredTokensResult.count, + deletedOldInstallations: oldInstallationsResult.count, + timestamp: new Date().toISOString(), + }; + + console.log("[Cron Cleanup]", response); + + return NextResponse.json(response); + } catch (error) { + console.error("[Cron Cleanup] Error executing cleanup job:", error); + return NextResponse.json( + { + error: "Internal Server Error", + details: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/github-app/star-check/route.ts b/src/app/api/github-app/star-check/route.ts index 4703989..77d8691 100644 --- a/src/app/api/github-app/star-check/route.ts +++ b/src/app/api/github-app/star-check/route.ts @@ -52,9 +52,9 @@ export async function GET(req: NextRequest) { : installationInfo.account.name; // Get an installation-scoped Octokit client - const octokit = await githubClient.getUserOwnedGitHubAppClient( - parseInt(installationId), - ); + const octokit = await githubClient + .getGitHubAppClient() + .getInstallationOctokit(parseInt(installationId)); // Check if the user has starred the repository (passive check) const isStarred = await githubClient.checkIfUserStarredRepository( diff --git a/src/app/api/webhooks/github-app/route.ts b/src/app/api/webhooks/github-app/route.ts index b395bba..c76cef9 100644 --- a/src/app/api/webhooks/github-app/route.ts +++ b/src/app/api/webhooks/github-app/route.ts @@ -6,6 +6,7 @@ import { GitHubLabelEventSchema } from "@/types"; import { createHmac, timingSafeEqual } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import logger from "@/lib/logger"; // GitHub webhook payload interfaces interface GitHubAccount { @@ -86,7 +87,7 @@ interface GitHubWebhookEvent { */ function verifyGitHubAppSignature(payload: string, signature: string): boolean { if (!env.GITHUB_APP_WEBHOOK_SECRET) { - console.warn( + logger.warn( "GITHUB_APP_WEBHOOK_SECRET not configured - webhook verification disabled", ); return true; // Allow in development if not configured @@ -131,7 +132,7 @@ async function logWebhookEvent( }); } catch (logError) { // Log to console if database logging fails - console.error("Failed to log webhook event:", logError); + logger.error("Failed to log webhook event:", logError); } } @@ -145,106 +146,116 @@ async function handleInstallationEvent( const installation = payload.installation; if (action === "created") { - // Install app - await db.gitHubInstallation.upsert({ - where: { id: installation.id }, - update: { - accountId: BigInt(installation.account.id), - accountLogin: installation.account.login, - accountType: installation.account.type, - targetType: installation.target_type, - permissions: JSON.stringify(installation.permissions), - events: JSON.stringify(installation.events), - singleFileName: installation.single_file_name, - repositorySelection: installation.repository_selection, - suspendedAt: installation.suspended_at - ? new Date(installation.suspended_at) - : null, - suspendedBy: installation.suspended_by?.login || null, - updatedAt: new Date(), - }, - create: { - id: installation.id, - accountId: BigInt(installation.account.id), - accountLogin: installation.account.login, - accountType: installation.account.type, - targetType: installation.target_type, - permissions: JSON.stringify(installation.permissions), - events: JSON.stringify(installation.events), - singleFileName: installation.single_file_name, - repositorySelection: installation.repository_selection, - suspendedAt: installation.suspended_at - ? new Date(installation.suspended_at) - : null, - suspendedBy: installation.suspended_by?.login || null, - }, - }); - - // Add all repositories if "all" selection - if (installation.repository_selection === "all" && payload.repositories) { - await Promise.all( - payload.repositories.map((repo: GitHubRepository) => { - // Extract owner from full_name since installation webhooks don't include owner object - const owner = repo.full_name.split("/")[0] || "unknown"; + await db.$transaction(async (prisma) => { + // Install app + await prisma.gitHubInstallation.upsert({ + where: { id: installation.id }, + update: { + accountId: BigInt(installation.account.id), + accountLogin: installation.account.login, + accountType: installation.account.type, + targetType: installation.target_type, + permissions: JSON.stringify(installation.permissions), + events: JSON.stringify(installation.events), + singleFileName: installation.single_file_name, + repositorySelection: installation.repository_selection, + suspendedAt: installation.suspended_at + ? new Date(installation.suspended_at) + : null, + suspendedBy: installation.suspended_by?.login || null, + updatedAt: new Date(), + }, + create: { + id: installation.id, + accountId: BigInt(installation.account.id), + accountLogin: installation.account.login, + accountType: installation.account.type, + targetType: installation.target_type, + permissions: JSON.stringify(installation.permissions), + events: JSON.stringify(installation.events), + singleFileName: installation.single_file_name, + repositorySelection: installation.repository_selection, + suspendedAt: installation.suspended_at + ? new Date(installation.suspended_at) + : null, + suspendedBy: installation.suspended_by?.login || null, + }, + }); - return db.installationRepository.upsert({ - where: { - installationId_repositoryId: { + // Add all repositories if "all" selection + if (installation.repository_selection === "all" && payload.repositories) { + await Promise.all( + payload.repositories.map((repo: GitHubRepository) => { + // Extract owner from full_name since installation webhooks don't include owner object + const owner = repo.full_name.split("/")[0] || "unknown"; + + return prisma.installationRepository.upsert({ + where: { + installationId_repositoryId: { + installationId: installation.id, + repositoryId: BigInt(repo.id), + }, + }, + update: { + name: repo.name, + fullName: repo.full_name, + owner: owner, + private: repo.private, + htmlUrl: + repo.html_url || `https://github.com/${repo.full_name}`, + description: repo.description, + removedAt: null, // Reset if previously removed + }, + create: { installationId: installation.id, repositoryId: BigInt(repo.id), + name: repo.name, + fullName: repo.full_name, + owner: owner, + private: repo.private, + htmlUrl: + repo.html_url || `https://github.com/${repo.full_name}`, + description: repo.description, }, - }, - update: { - name: repo.name, - fullName: repo.full_name, - owner: owner, - private: repo.private, - htmlUrl: repo.html_url || `https://github.com/${repo.full_name}`, - description: repo.description, - removedAt: null, // Reset if previously removed - }, - create: { - installationId: installation.id, - repositoryId: BigInt(repo.id), - name: repo.name, - fullName: repo.full_name, - owner: owner, - private: repo.private, - htmlUrl: repo.html_url || `https://github.com/${repo.full_name}`, - description: repo.description, - }, - }); - }), - ); + }); + }), + ); + } // Note: Label creation is now handled through the user-driven setup process // Users can choose during installation whether to create labels automatically - console.log( + logger.info( `Installation ${installation.id} completed. Labels will be created based on user preference.`, ); - } + }); - console.log( + logger.info( `GitHub App installed for ${installation.account.login} (${installation.id})`, ); } else if (action === "deleted") { - // Uninstall app - mark installation as suspended - await db.gitHubInstallation.update({ - where: { id: installation.id }, - data: { - suspendedAt: new Date(), - suspendedBy: "uninstalled", - updatedAt: new Date(), - }, - }); + await db.$transaction(async (prisma) => { + // Uninstall app - mark installation as suspended + await prisma.gitHubInstallation.update({ + where: { id: installation.id }, + data: { + suspendedAt: new Date(), + suspendedBy: "uninstalled", + updatedAt: new Date(), + user_access_token: null, + refresh_token: null, + token_expires_at: null, + refresh_token_expires_at: null, + }, + }); - // Mark all repositories as removed - await db.installationRepository.updateMany({ - where: { installationId: installation.id }, - data: { removedAt: new Date() }, + // Mark all repositories as removed + await prisma.installationRepository.updateMany({ + where: { installationId: installation.id }, + data: { removedAt: new Date() }, + }); }); - console.log( + logger.info( `GitHub App uninstalled for ${installation.account.login} (${installation.id})`, ); } else if (action === "suspend") { @@ -259,7 +270,7 @@ async function handleInstallationEvent( }, }); - console.log( + logger.info( `GitHub App suspended for ${installation.account.login} (${installation.id})`, ); } else if (action === "unsuspend") { @@ -272,7 +283,7 @@ async function handleInstallationEvent( }, }); - console.log( + logger.info( `GitHub App unsuspended for ${installation.account.login} (${installation.id})`, ); } @@ -290,108 +301,108 @@ async function handleInstallationRepositoriesEvent( payload.repositories_added || payload.repositories_removed || []; if (action === "added") { - await Promise.all( - repositories.map((repo: GitHubRepository) => { - // Extract owner from full_name since installation repository webhooks may not include owner object - const owner = - repo.owner?.login || repo.full_name.split("/")[0] || "unknown"; - - return db.installationRepository.upsert({ - where: { - installationId_repositoryId: { - installationId: installation.id, - repositoryId: BigInt(repo.id), - }, - }, - update: { - name: repo.name, - fullName: repo.full_name, - owner: owner, - private: repo.private, - htmlUrl: repo.html_url || `https://github.com/${repo.full_name}`, - description: repo.description, - removedAt: null, // Reset if previously removed - }, - create: { - installationId: installation.id, - repositoryId: BigInt(repo.id), - name: repo.name, - fullName: repo.full_name, - owner: owner, - private: repo.private, - htmlUrl: repo.html_url || `https://github.com/${repo.full_name}`, - description: repo.description, - }, - }); - }), - ); - - // Note: Label creation for new repositories should be handled based on user preferences - // Check if user has "all" preference and create labels accordingly - console.log( - `${repositories.length} repositories added to installation ${installation.id}`, - ); - - // Check user's label preference for this installation - const labelPreference = await db.labelPreference.findUnique({ - where: { installationId: installation.id }, - }); - - if (labelPreference?.setupType === "all") { - // User chose to create labels in all repositories, so create them for new repos - console.log( - `Creating Jules labels in ${repositories.length} newly added repositories`, - ); - - await Promise.allSettled( - repositories.map(async (repo) => { + await db.$transaction(async (prisma) => { + await Promise.all( + repositories.map((repo: GitHubRepository) => { + // Extract owner from full_name since installation repository webhooks may not include owner object const owner = repo.owner?.login || repo.full_name.split("/")[0] || "unknown"; - // Save repository to label preferences - await db.labelPreferenceRepository.create({ - data: { - labelPreferenceId: labelPreference.id, + return prisma.installationRepository.upsert({ + where: { + installationId_repositoryId: { + installationId: installation.id, + repositoryId: BigInt(repo.id), + }, + }, + update: { + name: repo.name, + fullName: repo.full_name, + owner: owner, + private: repo.private, + htmlUrl: repo.html_url || `https://github.com/${repo.full_name}`, + description: repo.description, + removedAt: null, // Reset if previously removed + }, + create: { + installationId: installation.id, repositoryId: BigInt(repo.id), name: repo.name, fullName: repo.full_name, owner: owner, + private: repo.private, + htmlUrl: repo.html_url || `https://github.com/${repo.full_name}`, + description: repo.description, }, }); - - // Create labels in the repository - return createJulesLabelsForRepository( - owner, - repo.name, - installation.id, - ); }), ); - } else { - console.log( - `Label preference is "${labelPreference?.setupType || "not set"}" - skipping automatic label creation for new repositories`, + + // Note: Label creation for new repositories should be handled based on user preferences + // Check if user has "all" preference and create labels accordingly + logger.info( + `${repositories.length} repositories added to installation ${installation.id}`, ); - } - console.log( - `Added ${repositories.length} repositories to installation ${installation.id}`, - ); + // Check user's label preference for this installation + const labelPreference = await prisma.labelPreference.findUnique({ + where: { installationId: installation.id }, + }); + + if (labelPreference?.setupType === "all") { + // User chose to create labels in all repositories, so create them for new repos + logger.info( + `Creating Jules labels in ${repositories.length} newly added repositories`, + ); + + await Promise.allSettled( + repositories.map(async (repo) => { + const owner = + repo.owner?.login || repo.full_name.split("/")[0] || "unknown"; + + // Save repository to label preferences + await prisma.labelPreferenceRepository.create({ + data: { + labelPreferenceId: labelPreference.id, + repositoryId: BigInt(repo.id), + name: repo.name, + fullName: repo.full_name, + owner: owner, + }, + }); + + // Create labels in the repository + return createJulesLabelsForRepository( + owner, + repo.name, + installation.id, + ); + }), + ); + } + + logger.info( + `Added ${repositories.length} repositories to installation ${installation.id}`, + ); + }); } else if (action === "removed") { - await Promise.all( - repositories.map((repo: GitHubRepository) => - db.installationRepository.updateMany({ - where: { - installationId: installation.id, - repositoryId: BigInt(repo.id), - }, - data: { removedAt: new Date() }, - }), - ), - ); + await db.$transaction(async (prisma) => { + await Promise.all( + repositories.map((repo: GitHubRepository) => + prisma.installationRepository.updateMany({ + where: { + installationId: installation.id, + repositoryId: BigInt(repo.id), + }, + data: { removedAt: new Date() }, + }), + ), + ); - console.log( - `Removed ${repositories.length} repositories from installation ${installation.id}`, - ); + logger.info( + `Removed ${repositories.length} repositories from installation ${installation.id}`, + ); + }); } } @@ -522,7 +533,7 @@ export async function POST(req: NextRequest) { } // Log comment for monitoring Jules interactions - console.log( + logger.info( `New comment on Jules-labeled issue ${commentEvent.repository.full_name}#${commentEvent.issue.number} by ${commentEvent.comment.user.login}`, ); @@ -593,7 +604,7 @@ export async function POST(req: NextRequest) { } // Process the Jules label event with installation context - console.log( + logger.info( `Processing ${labelEvent.action} event for label '${labelName}' on ${labelEvent.repository.full_name}#${labelEvent.issue.number} (installation: ${webhookEvent.installation?.id})`, ); @@ -628,7 +639,7 @@ export async function POST(req: NextRequest) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error("GitHub App webhook processing error:", { + logger.error("GitHub App webhook processing error:", { eventType, error: errorMessage, payload: payload ? JSON.stringify(payload).slice(0, 500) : null, diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..66f8bc4 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

Not Found

+

Could not find requested resource

+

+ View home page +

+
+ ); +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..cc26ba5 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,37 @@ +import { env } from "@/lib/env"; +import crypto from "crypto"; +import logger from "@/lib/logger"; + +const ALGORITHM = "aes-256-cbc"; +const IV_LENGTH = 16; + +export function encrypt(text: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(env.TOKEN_ENCRYPTION_KEY, "hex"), + iv, + ); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return iv.toString("hex") + ":" + encrypted.toString("hex"); +} + +export function decrypt(text: string): string | null { + try { + const textParts = text.split(":"); + const iv = Buffer.from(textParts.shift()!, "hex"); + const encryptedText = Buffer.from(textParts.join(":"), "hex"); + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(env.TOKEN_ENCRYPTION_KEY, "hex"), + iv, + ); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); + } catch (error) { + logger.error({ error }, "Decryption failed:"); + return null; + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 07aaac8..27dfd67 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -20,6 +20,9 @@ export const env = createEnv({ GITHUB_APP_CLIENT_SECRET: z .string() .min(1, "GITHUB_APP_CLIENT_SECRET is required"), + GITHUB_APP_CALLBACK_URL: z + .string() + .url("GITHUB_APP_CALLBACK_URL must be a valid URL"), // Application NODE_ENV: z @@ -29,6 +32,9 @@ export const env = createEnv({ // Cron Job Security CRON_SECRET: z.string().optional(), + // Token Encryption + TOKEN_ENCRYPTION_KEY: z.string().min(1, "TOKEN_ENCRYPTION_KEY is required"), + // Optional: Custom processing settings COMMENT_CHECK_DELAY_MS: z.coerce.number().default(60000), RETRY_INTERVAL_MINUTES: z.coerce.number().default(30), @@ -70,6 +76,7 @@ export const env = createEnv({ GITHUB_APP_WEBHOOK_SECRET: process.env.GITHUB_APP_WEBHOOK_SECRET, GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET: process.env.GITHUB_APP_CLIENT_SECRET, + GITHUB_APP_CALLBACK_URL: process.env.GITHUB_APP_CALLBACK_URL, NODE_ENV: process.env.NODE_ENV, CRON_SECRET: process.env.CRON_SECRET, COMMENT_CHECK_DELAY_MS: process.env.COMMENT_CHECK_DELAY_MS, @@ -78,6 +85,7 @@ export const env = createEnv({ STAR_REQUIREMENT: process.env.STAR_REQUIREMENT, REPO_OWNER: process.env.REPO_OWNER, REPO_NAME: process.env.REPO_NAME, + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, // Client NEXT_PUBLIC_GITHUB_APP_NAME: process.env.NEXT_PUBLIC_GITHUB_APP_NAME, diff --git a/src/lib/github-app.ts b/src/lib/github-app.ts index 17113cd..44dde35 100644 --- a/src/lib/github-app.ts +++ b/src/lib/github-app.ts @@ -1,6 +1,7 @@ import { env, hasGitHubApp } from "@/lib/env"; import { App } from "@octokit/app"; import { Octokit } from "@octokit/rest"; +import logger from "@/lib/logger"; /** * GitHub App client for installation-based authentication @@ -73,9 +74,9 @@ class GitHubAppClient { return response.data.token; } catch (error) { - console.error( + logger.error( + { error }, `Failed to get installation token for ${installationId}:`, - error, ); throw error; } @@ -106,7 +107,7 @@ class GitHubAppClient { const { data } = await this.app.octokit.request("GET /app/installations"); return data; } catch (error) { - console.error("Failed to get installations:", error); + logger.error({ error }, "Failed to get installations:"); throw error; } } @@ -124,9 +125,9 @@ class GitHubAppClient { const { data } = await octokit.request("GET /installation/repositories"); return data.repositories; } catch (error) { - console.error( + logger.error( + { error }, `Failed to get repositories for installation ${installationId}:`, - error, ); throw error; } @@ -157,7 +158,10 @@ class GitHubAppClient { return null; } catch (error) { - console.error(`Failed to find installation for ${owner}/${repo}:`, error); + logger.error( + { error }, + `Failed to find installation for ${owner}/${repo}:`, + ); return null; } } @@ -338,7 +342,7 @@ class GitHubAppClient { const { data } = await this.app.octokit.request("GET /app"); return data; } catch (error) { - console.error("Failed to get app info:", error); + logger.error({ error }, "Failed to get app info:"); throw error; } } @@ -356,9 +360,9 @@ class GitHubAppClient { const { data } = await octokit.request("GET /installation"); return data; } catch (error) { - console.error( + logger.error( + { error }, `Failed to get installation info for ${installationId}:`, - error, ); throw error; } @@ -370,11 +374,10 @@ export const githubAppClient = GitHubAppClient.getInstance(); export const userOwnedGithubAppClient = async ( installationId: number, + userAccessToken: string, ): Promise => { - const githubApp = GitHubAppClient.getInstance(); - const token = await githubApp.getInstallationToken(installationId); return new Octokit({ - auth: token, + auth: userAccessToken, userAgent: "jules-task-queue/1.0.0", }); }; diff --git a/src/lib/github.ts b/src/lib/github.ts index 852974b..361e144 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1,6 +1,7 @@ import { githubAppClient, userOwnedGithubAppClient } from "@/lib/github-app"; import { installationService } from "@/lib/installation-service"; import type { Octokit } from "@octokit/rest"; +import logger from "@/lib/logger"; /** * GitHub API client (now using GitHub App only) @@ -20,8 +21,11 @@ class GitHubClient { /** * Get a GitHub App client authenticated as the user */ - public async getUserOwnedGitHubAppClient(installationId: number) { - return userOwnedGithubAppClient(installationId); + public async getUserOwnedGitHubAppClient( + installationId: number, + userAccessToken: string, + ) { + return userOwnedGithubAppClient(installationId, userAccessToken); } /** @@ -108,7 +112,7 @@ class GitHubClient { body, installationId, ); - console.log(`Created comment on ${owner}/${repo}#${issue_number}`); + logger.info(`Created comment on ${owner}/${repo}#${issue_number}`); return response.data; } @@ -137,7 +141,7 @@ class GitHubClient { content, installationId, ); - console.log( + logger.info( `Added ${content} reaction to comment ${comment_id} on ${owner}/${repo}`, ); return response.data; @@ -175,15 +179,19 @@ class GitHubClient { issue_number: number, label: string, installationId?: number, + userAccessToken?: string, ) { - await githubAppClient.addLabel( + const client = userAccessToken + ? await this.getUserOwnedGitHubAppClient(installationId!, userAccessToken) + : await githubAppClient.getInstallationOctokit(installationId!); + + await client.rest.issues.addLabels({ owner, repo, issue_number, - label, - installationId, - ); - console.log(`Added label '${label}' to ${owner}/${repo}#${issue_number}`); + labels: [label], + }); + logger.info(`Added label '${label}' to ${owner}/${repo}#${issue_number}`); } /** @@ -195,16 +203,22 @@ class GitHubClient { issue_number: number, label: string, installationId?: number, + userAccessToken?: string, ) { try { - await githubAppClient.removeLabel( + const client = userAccessToken + ? await this.getUserOwnedGitHubAppClient( + installationId!, + userAccessToken, + ) + : await githubAppClient.getInstallationOctokit(installationId!); + await client.rest.issues.removeLabel({ owner, repo, issue_number, - label, - installationId, - ); - console.log( + name: label, + }); + logger.info( `Removed label '${label}' from ${owner}/${repo}#${issue_number}`, ); } catch (error: unknown) { @@ -213,7 +227,7 @@ class GitHubClient { error instanceof Error && error.message.includes("Label does not exist") ) { - console.log( + logger.info( `Label '${label}' doesn't exist on ${owner}/${repo}#${issue_number}`, ); return; @@ -239,9 +253,9 @@ class GitHubClient { ) ?? false ); } catch (error) { - console.error( + logger.error( + { error }, `Failed to check label '${label}' on ${owner}/${repo}#${issue_number}:`, - error, ); return false; } @@ -257,6 +271,7 @@ class GitHubClient { removeLabel: string, addLabel: string, installationId?: number, + userAccessToken?: string, ) { try { // Remove the old label and add the new one @@ -267,16 +282,24 @@ class GitHubClient { issue_number, removeLabel, installationId, + userAccessToken, + ), + this.addLabel( + owner, + repo, + issue_number, + addLabel, + installationId, + userAccessToken, ), - this.addLabel(owner, repo, issue_number, addLabel, installationId), ]); - console.log( + logger.info( `Swapped labels: '${removeLabel}' -> '${addLabel}' on ${owner}/${repo}#${issue_number}`, ); } catch (error) { - console.error( + logger.error( + { error }, `Failed to swap labels on ${owner}/${repo}#${issue_number}:`, - error, ); throw error; } @@ -408,7 +431,7 @@ class GitHubClient { return false; } catch (error) { - console.error("Failed to check user starred repositories:", error); + logger.error("Failed to check user starred repositories:", error); throw error; } } diff --git a/src/lib/jules.ts b/src/lib/jules.ts index 9730717..eced8c6 100644 --- a/src/lib/jules.ts +++ b/src/lib/jules.ts @@ -1,5 +1,7 @@ import { githubClient } from "@/lib/github"; +import { getUserAccessToken } from "@/lib/token-manager"; import { db } from "@/server/db"; +import logger from "@/lib/logger"; import type { CommentAnalysis, CommentClassification, @@ -186,7 +188,7 @@ export async function checkJulesComments( for (let attempt = 0; attempt < maxRetries; attempt++) { try { - console.log( + logger.info( `Checking Jules comments for ${owner}/${repo}#${issueNumber} (attempt ${ attempt + 1 }/${maxRetries})`, @@ -209,7 +211,7 @@ export async function checkJulesComments( ); if (julesComments.length === 0) { - console.log( + logger.info( `No Jules comments found for ${owner}/${repo}#${issueNumber}`, ); return { action: "no_action", retryCount: attempt }; @@ -219,7 +221,7 @@ export async function checkJulesComments( const latestComment = julesComments[0] as GitHubComment; const analysis = analyzeComment(latestComment); - console.log(`Comment analysis for ${owner}/${repo}#${issueNumber}:`, { + logger.info(`Comment analysis for ${owner}/${repo}#${issueNumber}:`, { classification: analysis.classification, confidence: analysis.confidence, patterns: analysis.patterns_matched, @@ -228,7 +230,7 @@ export async function checkJulesComments( // Check if comment is too old (older than 2 hours might be stale) if (analysis.age_minutes > 120) { - console.log( + logger.info( `Latest Jules comment is ${analysis.age_minutes} minutes old, treating as stale`, ); return { @@ -241,7 +243,7 @@ export async function checkJulesComments( // Apply confidence threshold if (analysis.confidence < minConfidence) { - console.log( + logger.info( `Comment confidence ${analysis.confidence} below threshold ${minConfidence}, treating as uncertain`, ); @@ -259,7 +261,7 @@ export async function checkJulesComments( recentComments[1] as GitHubComment, ); if (secondAnalysis.confidence >= minConfidence) { - console.log( + logger.info( `Using second comment with higher confidence: ${secondAnalysis.confidence}`, ); return { @@ -288,9 +290,9 @@ export async function checkJulesComments( }; } catch (error) { lastError = error as Error; - console.error( + logger.error( + { error }, `Attempt ${attempt + 1} failed for ${owner}/${repo}#${issueNumber}:`, - error, ); // Wait before retry (exponential backoff) @@ -302,7 +304,7 @@ export async function checkJulesComments( } // All retries failed - console.error( + logger.error( `All ${maxRetries} attempts failed for ${owner}/${repo}#${issueNumber}:`, lastError, ); @@ -325,7 +327,7 @@ export async function handleTaskLimit( installationId?: number, ): Promise { try { - console.log( + logger.info( `Handling task limit for ${owner}/${repo}#${issueNumber}, confidence: ${ analysis?.confidence || "unknown" }`, @@ -341,7 +343,7 @@ export async function handleTaskLimit( } if (currentTask.flaggedForRetry) { - console.log(`Task ${taskId} already flagged for retry, skipping`); + logger.info(`Task ${taskId} already flagged for retry, skipping`); return; } @@ -360,7 +362,7 @@ export async function handleTaskLimit( ) ?? false; if (!hasJulesLabel) { - console.log( + logger.info( `Issue ${owner}/${repo}#${issueNumber} no longer has 'jules' label, aborting task limit handling`, ); return; @@ -394,7 +396,7 @@ export async function handleTaskLimit( analysis.comment.id, "eyes", ); - console.log( + logger.info( `Added refresh emoji reaction to Jules comment for task limit`, ); } catch (reactionError) { @@ -402,13 +404,13 @@ export async function handleTaskLimit( } } - console.log( + logger.info( `Successfully queued task for retry: ${owner}/${repo}#${issueNumber}`, ); } catch (error) { - console.error( + logger.error( + { error }, `Failed to handle task limit for ${owner}/${repo}#${issueNumber}:`, - error, ); // Attempt to revert database changes if label swap failed @@ -420,11 +422,11 @@ export async function handleTaskLimit( updatedAt: new Date(), }, }); - console.log(`Reverted database changes for task ${taskId} after failure`); + logger.info(`Reverted database changes for task ${taskId} after failure`); } catch (revertError) { - console.error( + logger.error( + { error: revertError }, `Failed to revert database changes for task ${taskId}:`, - revertError, ); } @@ -444,7 +446,7 @@ export async function handleWorking( installationId?: number, ): Promise { try { - console.log( + logger.info( `Handling working status for ${owner}/${repo}#${issueNumber}, confidence: ${ analysis?.confidence || "unknown" }`, @@ -478,7 +480,7 @@ export async function handleWorking( "+1", installationId, ); - console.log( + logger.info( `Added thumbs up emoji reaction to Jules comment for working status`, ); } catch (reactionError) { @@ -486,11 +488,11 @@ export async function handleWorking( } } - console.log(`Jules is working on: ${owner}/${repo}#${issueNumber}`); + logger.info(`Jules is working on: ${owner}/${repo}#${issueNumber}`); } catch (error) { - console.error( + logger.error( + { error }, `Failed to handle working status for ${owner}/${repo}#${issueNumber}:`, - error, ); throw error; } @@ -514,7 +516,7 @@ export async function processWorkflowDecision( ): Promise { const { action, analysis } = result; - console.log( + logger.info( `Processing workflow decision for ${owner}/${repo}#${issueNumber}: ${action} (confidence: ${ analysis?.confidence || "unknown" })`, @@ -544,7 +546,7 @@ export async function processWorkflowDecision( break; case "unknown": - console.log( + logger.info( `Uncertain comment classification for ${owner}/${repo}#${issueNumber}, no action taken`, ); // For unknown patterns, add warning reaction and quote reply @@ -567,7 +569,7 @@ export async function processWorkflowDecision( installationId, ); } catch (reactionError) { - console.warn( + logger.warn( `Failed to add warning reaction/comment for ${owner}/${repo}#${issueNumber}:`, reactionError, ); @@ -577,7 +579,7 @@ export async function processWorkflowDecision( case "no_action": default: - console.log( + logger.info( `No action needed for ${owner}/${repo}#${issueNumber}: ${action}`, ); break; @@ -594,14 +596,14 @@ export async function processTaskRetry(taskId: number): Promise { }); if (!task || !task.flaggedForRetry) { - console.log(`Task ${taskId} not found or not flagged for retry`); + logger.info(`Task ${taskId} not found or not flagged for retry`); return false; } - const { repoOwner, repoName, githubIssueNumber } = task; + const { repoOwner, repoName, githubIssueNumber, installationId } = task; const issueNumber = Number(githubIssueNumber); - console.log( + logger.info( `Processing retry for task ${taskId}: ${repoOwner}/${repoName}#${issueNumber}`, ); @@ -620,10 +622,20 @@ export async function processTaskRetry(taskId: number): Promise { ) ?? false; if (hasHumanLabel) { - console.log(`Task ${taskId} has 'Human' label, skipping retry`); + logger.info(`Task ${taskId} has 'Human' label, skipping retry`); return false; } + const userAccessToken = installationId + ? await getUserAccessToken(installationId) + : null; + + if (!userAccessToken) { + logger.warn( + `User access token not found for installation ${installationId}. Falling back to installation token. Jules may not respond.`, + ); + } + // Swap labels: remove 'jules-queue', add 'jules' await githubClient.swapLabels( repoOwner, @@ -632,6 +644,7 @@ export async function processTaskRetry(taskId: number): Promise { "jules-queue", "jules", task.installationId || undefined, + userAccessToken ?? undefined, ); // Update retry metrics @@ -644,12 +657,12 @@ export async function processTaskRetry(taskId: number): Promise { }, }); - console.log( + logger.info( `Successfully retried task ${taskId}: ${repoOwner}/${repoName}#${issueNumber}`, ); return true; } catch (error) { - console.error(`Failed to process retry for task ${taskId}:`, error); + logger.error({ error }, `Failed to process retry for task ${taskId}:`); return false; } } @@ -690,12 +703,12 @@ export async function retryAllFlaggedTasks(): Promise<{ stats.skipped++; } } catch (error) { - console.error(`Failed to retry task ${task.id}:`, error); + logger.error(`Failed to retry task ${task.id}:`, error); stats.failed++; } } - console.log(`Retry batch complete:`, stats); + logger.info(`Retry batch complete:`, stats); return stats; } @@ -717,7 +730,7 @@ export async function cleanupOldTasks( }, }); - console.log( + logger.info( `Cleaned up ${result.count} old tasks older than ${olderThanDays} days`, ); return result.count; diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..1c68387 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,13 @@ +import pino from "pino"; + +const logger = pino({ + level: process.env.NODE_ENV === "development" ? "debug" : "info", + transport: { + target: "pino-pretty", + options: { + colorize: true, + }, + }, +}); + +export default logger; diff --git a/src/lib/token-manager.ts b/src/lib/token-manager.ts new file mode 100644 index 0000000..03aae7b --- /dev/null +++ b/src/lib/token-manager.ts @@ -0,0 +1,133 @@ +import { decrypt, encrypt } from "@/lib/crypto"; +import { env } from "@/lib/env"; +import { db } from "@/server/db"; +import logger from "@/lib/logger"; + +async function refreshUserToken(refreshToken: string): Promise<{ + access_token: string; + refresh_token: string; + expires_in: number; + refresh_token_expires_in: number; + error?: string; + error_description?: string; +}> { + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.NEXT_PUBLIC_GITHUB_APP_ID, + client_secret: env.GITHUB_APP_CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshToken, + redirect_uri: env.GITHUB_APP_CALLBACK_URL, + }), + }); + return response.json(); +} + +export async function getUserAccessToken( + installationId: number, +): Promise { + logger.info( + `[TokenManager] Attempting to retrieve access token for installation: ${installationId}`, + ); + const installation = await db.gitHubInstallation.findUnique({ + where: { id: installationId }, + }); + + if ( + !installation || + !installation.user_access_token || + !installation.refresh_token + ) { + logger.warn( + `[TokenManager] No token found for installation: ${installationId}`, + ); + return null; + } + + const decryptedRefreshToken = decrypt(installation.refresh_token); + if (!decryptedRefreshToken) { + logger.error( + `[TokenManager] Failed to decrypt refresh token for installation: ${installationId}.`, + ); + return null; + } + + if ( + installation.token_expires_at && + installation.token_expires_at < new Date() + ) { + logger.info( + `[TokenManager] Token expired for installation: ${installationId}. Refreshing...`, + ); + try { + const refreshed = await refreshUserToken(decryptedRefreshToken); + if (refreshed.error) { + logger.error( + `[TokenManager] Error refreshing token for installation ${installationId}: ${refreshed.error_description}`, + ); + if (refreshed.error === "bad_refresh_token") { + logger.info( + `[TokenManager] Refresh token is invalid. Clearing tokens for installation ${installationId}`, + ); + await db.gitHubInstallation.update({ + where: { id: installationId }, + data: { + user_access_token: null, + refresh_token: null, + token_expires_at: null, + refresh_token_expires_at: null, + }, + }); + } + return null; + } + + const { + access_token, + refresh_token, + expires_in, + refresh_token_expires_in, + } = refreshed; + + const tokenExpiresAt = new Date(Date.now() + expires_in * 1000); + const refreshTokenExpiresAt = new Date( + Date.now() + refresh_token_expires_in * 1000, + ); + + 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, + }, + }); + logger.info( + `[TokenManager] Successfully refreshed and stored new token for installation: ${installationId}`, + ); + return access_token; + } catch (error) { + logger.error( + { error }, + `[TokenManager] Failed to refresh user token for installation ${installationId}:`, + ); + return null; + } + } + + const decryptedAccessToken = decrypt(installation.user_access_token); + if (!decryptedAccessToken) { + console.error( + `[TokenManager] Failed to decrypt access token for installation: ${installationId}.`, + ); + return null; + } + + console.log( + `[TokenManager] Successfully retrieved access token for installation: ${installationId}`, + ); + return decryptedAccessToken; +} diff --git a/vercel.json b/vercel.json index 3e2aed3..c73960e 100644 --- a/vercel.json +++ b/vercel.json @@ -7,10 +7,16 @@ ], "functions": { "src/app/api/cron/retry/route.ts": { - "maxDuration": 300 + "maxDuration": 300, + "environment": { + "GITHUB_APP_CALLBACK_URL": "@github_app_callback_url" + } }, "src/app/api/webhooks/github/route.ts": { - "maxDuration": 60 + "maxDuration": 60, + "environment": { + "GITHUB_APP_CALLBACK_URL": "@github_app_callback_url" + } } }, "headers": [ From b845d9be18d53a31426f86722cb87c8de635e608 Mon Sep 17 00:00:00 2001 From: iHildy Date: Sun, 20 Jul 2025 01:31:47 -0500 Subject: [PATCH 2/9] updated agents and gemi rules / fixed all issues mentioned by coderabbit Update AGENTS.md and GEMINI.md to emphasize expert-level expectations for code quality. Modify docker-compose.selfhost.yml to quote GITHUB_APP_CALLBACK_URL. Fix jules-queueing-system.md flow and enhance error handling in various API routes. Improve encryption error handling in crypto.ts and update environment variable validation in env.ts. Refactor GitHub client methods for better clarity and error handling. --- AGENTS.md | 6 +- GEMINI.md | 6 +- docker-compose.selfhost.yml | 4 +- jules-queueing-system.md | 2 +- .../migration.sql | 4 +- src/app/api/auth/callback/github/route.ts | 96 ++++++++++++++++++- src/app/api/cron/cleanup/route.ts | 76 +++++++++------ src/lib/crypto.ts | 40 +++++++- src/lib/env.ts | 13 ++- src/lib/github-app.ts | 1 - src/lib/github.ts | 57 +++++++---- src/lib/logger.ts | 13 ++- src/lib/token-manager.ts | 45 ++++++--- 13 files changed, 282 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 638507e..897395b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,10 @@ # AGENTS RULES OVERVIEW -This document condenses the most critical rules for agents working on this repository. Note that YOU don't have access to environmental variables and cannot run commands like `pnpm build` because of this, instead use `pnpm lint` +## You are an expert. + +You are an EXPERT software engineer and system designer - all of your code will be reviewed by an expert reviewer of your work - optimize for them to not have to change your code or leave comments on your code. + +This document condenses the most critical rules for agents working on this repository. Note that Jules (you) doesn't have access to enviormental variables and cannot run commands like `pnpm build` because of this, instead use `pnpm lint` - The best option is to use `pnpm lint && pnpm build` to run both linting and building at the same time. --- diff --git a/GEMINI.md b/GEMINI.md index 638507e..897395b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,10 @@ # AGENTS RULES OVERVIEW -This document condenses the most critical rules for agents working on this repository. Note that YOU don't have access to environmental variables and cannot run commands like `pnpm build` because of this, instead use `pnpm lint` +## You are an expert. + +You are an EXPERT software engineer and system designer - all of your code will be reviewed by an expert reviewer of your work - optimize for them to not have to change your code or leave comments on your code. + +This document condenses the most critical rules for agents working on this repository. Note that Jules (you) doesn't have access to enviormental variables and cannot run commands like `pnpm build` because of this, instead use `pnpm lint` - The best option is to use `pnpm lint && pnpm build` to run both linting and building at the same time. --- diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index be1c21f..c607e31 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -17,7 +17,7 @@ services: - GITHUB_APP_WEBHOOK_SECRET=${GITHUB_APP_WEBHOOK_SECRET} - GITHUB_APP_CLIENT_ID=${GITHUB_APP_CLIENT_ID} - GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET} - - GITHUB_APP_CALLBACK_URL=${GITHUB_APP_CALLBACK_URL} + - GITHUB_APP_CALLBACK_URL="${GITHUB_APP_CALLBACK_URL}" - NEXT_PUBLIC_GITHUB_APP_NAME=${NEXT_PUBLIC_GITHUB_APP_NAME} - CRON_SECRET=${CRON_SECRET} depends_on: @@ -75,7 +75,7 @@ services: - GITHUB_APP_WEBHOOK_SECRET=${GITHUB_APP_WEBHOOK_SECRET} - GITHUB_APP_CLIENT_ID=${GITHUB_APP_CLIENT_ID} - GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET} - - GITHUB_APP_CALLBACK_URL=${GITHUB_APP_CALLBACK_URL} + - GITHUB_APP_CALLBACK_URL="${GITHUB_APP_CALLBACK_URL}" - NEXT_PUBLIC_GITHUB_APP_NAME=${NEXT_PUBLIC_GITHUB_APP_NAME} - CRON_SECRET=${CRON_SECRET} depends_on: diff --git a/jules-queueing-system.md b/jules-queueing-system.md index 329a82d..1b47846 100644 --- a/jules-queueing-system.md +++ b/jules-queueing-system.md @@ -28,7 +28,7 @@ graph TD I --> K["Mark JulesTask.flaggedForRetry = true"] K --> L["Remove 'jules' label from GitHub issue"] - K --> M["Add 'jules-queue' label to GitHub issue"] + L --> M["Add 'jules-queue' label to GitHub issue"] M --> N["Task queued for retry"] J --> O["Jules is actively actively working"] diff --git a/prisma/migrations/20250720035824_add_user_tokens/migration.sql b/prisma/migrations/20250720035824_add_user_tokens/migration.sql index 7fab172..d813e59 100644 --- a/prisma/migrations/20250720035824_add_user_tokens/migration.sql +++ b/prisma/migrations/20250720035824_add_user_tokens/migration.sql @@ -1,5 +1,5 @@ -- AlterTable ALTER TABLE "github_installations" ADD COLUMN "refresh_token" TEXT, -ADD COLUMN "refresh_token_expires_at" TIMESTAMP(3), -ADD COLUMN "token_expires_at" TIMESTAMP(3), +ADD COLUMN "refresh_token_expires_at" TIMESTAMPTZ, +ADD COLUMN "token_expires_at" TIMESTAMPTZ, ADD COLUMN "user_access_token" TEXT; diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index 61ed5fe..2a09c84 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -2,20 +2,81 @@ import { encrypt } from "@/lib/crypto"; import { env } from "@/lib/env"; 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"; +// Simple in-memory rate limiter (for demonstration purposes) +const rateLimit = new Map(); +const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute +const MAX_REQUESTS_PER_WINDOW = 10; // Max 10 requests per minute + export async function GET(request: NextRequest) { + // Apply rate limiting + const ip = request.headers.get("x-forwarded-for") || "unknown"; + const lastRequestTime = rateLimit.get(ip) || 0; + const currentTime = Date.now(); + + if ( + currentTime - lastRequestTime < + RATE_LIMIT_WINDOW_MS / MAX_REQUESTS_PER_WINDOW + ) { + logger.warn({ ip }, "Rate limit exceeded"); + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + rateLimit.set(ip, currentTime); + const searchParams = request.nextUrl.searchParams; const code = searchParams.get("code"); - const installationId = searchParams.get("installation_id"); + const installationIdParam = searchParams.get("installation_id"); + const state = searchParams.get("state"); + + // CSRF Protection + const oauthStateCookie = (await cookies()).get("oauth_state"); + if ( + !state || + !oauthStateCookie || + !crypto.timingSafeEqual( + Buffer.from(state), + Buffer.from(oauthStateCookie.value), + ) + ) { + logger.error( + { state, oauthStateCookie: oauthStateCookie?.value }, + "CSRF state mismatch or missing", + ); + 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 || !installationId) { + if (!code || !installationIdParam) { + logger.error( + { code: !!code, installationId: !!installationIdParam }, + "Missing code or installation_id", + ); return NextResponse.json( { error: "Missing code or installation_id" }, { status: 400 }, ); } + // Validate installation_id as a numeric value + const installationId = Number(installationIdParam); + if (!Number.isInteger(installationId)) { + logger.error( + { installationIdParam }, + "Invalid installation_id: not a number", + ); + return NextResponse.json( + { error: "Invalid installation_id" }, + { status: 400 }, + ); + } + try { const response = await fetch( "https://github.com/login/oauth/access_token", @@ -54,13 +115,42 @@ export async function GET(request: NextRequest) { refresh_token_expires_in, } = data; + if ( + !access_token || + !refresh_token || + typeof expires_in === "undefined" || + typeof refresh_token_expires_in === "undefined" + ) { + logger.error({ data }, "Missing required OAuth token fields"); + return NextResponse.json( + { error: "Invalid OAuth response from GitHub" }, + { status: 500 }, + ); + } + const tokenExpiresAt = new Date(Date.now() + expires_in * 1000); const refreshTokenExpiresAt = new Date( Date.now() + refresh_token_expires_in * 1000, ); + // Verify installation exists and belongs to the user (or is valid for update) + const existingInstallation = await db.gitHubInstallation.findUnique({ + where: { id: installationId }, + }); + + if (!existingInstallation) { + logger.error( + { installationId }, + "GitHub Installation not found for update", + ); + return NextResponse.json( + { error: "GitHub Installation not found" }, + { status: 404 }, + ); + } + await db.gitHubInstallation.update({ - where: { id: Number(installationId) }, + where: { id: installationId }, data: { user_access_token: encrypt(access_token), refresh_token: encrypt(refresh_token), diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts index c99b14b..b1659ff 100644 --- a/src/app/api/cron/cleanup/route.ts +++ b/src/app/api/cron/cleanup/route.ts @@ -1,10 +1,22 @@ 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 { Prisma } from "@prisma/client"; -export async function GET(request: NextRequest) { +export async function GET(request: NextRequest): Promise { const authHeader = request.headers.get("authorization"); - if (authHeader !== `Bearer ${env.CRON_SECRET}`) { + const expectedAuthHeader = `Bearer ${env.CRON_SECRET}`; + + if ( + !authHeader || + !crypto.timingSafeEqual( + Buffer.from(authHeader), + Buffer.from(expectedAuthHeader), + ) + ) { + logger.warn("Unauthorized access to cleanup cron job"); return new Response("Unauthorized", { status: 401, }); @@ -13,31 +25,38 @@ export async function GET(request: NextRequest) { try { const now = new Date(); - // 1. Clean up expired refresh tokens - const expiredTokensResult = await db.gitHubInstallation.updateMany({ - where: { - refresh_token_expires_at: { - lt: now, - }, - }, - data: { - user_access_token: null, - refresh_token: null, - token_expires_at: null, - refresh_token_expires_at: null, - }, - }); + const [expiredTokensResult, oldInstallationsResult] = await db.$transaction( + async (prisma: Prisma.TransactionClient) => { + // 1. Clean up expired refresh tokens + const expiredTokens = await prisma.gitHubInstallation.updateMany({ + where: { + refresh_token_expires_at: { + lt: now, + }, + }, + data: { + user_access_token: null, + refresh_token: null, + token_expires_at: null, + refresh_token_expires_at: null, + }, + }); - // 2. Clean up old, uninstalled installations (e.g., older than 30 days) - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const oldInstallationsResult = await db.gitHubInstallation.deleteMany({ - where: { - suspendedAt: { - lt: thirtyDaysAgo, - }, - suspendedBy: "uninstalled", + // 2. Clean up old, uninstalled installations (e.g., older than 30 days) + const thirtyDaysAgo = new Date( + now.getTime() - 30 * 24 * 60 * 60 * 1000, + ); + const oldInstallations = await prisma.gitHubInstallation.deleteMany({ + where: { + suspendedAt: { + lt: thirtyDaysAgo, + }, + suspendedBy: "uninstalled", + }, + }); + return [expiredTokens, oldInstallations] as const; }, - }); + ); const response = { message: "Cleanup cron job executed successfully.", @@ -46,11 +65,14 @@ export async function GET(request: NextRequest) { timestamp: new Date().toISOString(), }; - console.log("[Cron Cleanup]", response); + logger.info( + { response }, + "[Cron Cleanup] Cleanup cron job executed successfully.", + ); return NextResponse.json(response); } catch (error) { - console.error("[Cron Cleanup] Error executing cleanup job:", error); + logger.error({ error }, "[Cron Cleanup] Error executing cleanup job:"); return NextResponse.json( { error: "Internal Server Error", diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index cc26ba5..a2ea424 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -19,9 +19,45 @@ export function encrypt(text: string): string { export function decrypt(text: string): string | null { try { + if (!env.TOKEN_ENCRYPTION_KEY) { + logger.error("TOKEN_ENCRYPTION_KEY is not configured"); + return null; + } + + if (!text || typeof text !== "string") { + logger.error("Invalid input: text must be a non-empty string"); + return null; + } + + if (!text.includes(":")) { + logger.error("Invalid input format: missing ':' delimiter"); + return null; + } + const textParts = text.split(":"); - const iv = Buffer.from(textParts.shift()!, "hex"); - const encryptedText = Buffer.from(textParts.join(":"), "hex"); + if (textParts.length < 2) { + logger.error("Invalid input format: insufficient parts after split"); + return null; + } + + const ivHex = textParts[0]; + const encryptedHex = textParts.slice(1).join(":"); + + if (!ivHex || !encryptedHex) { + logger.error("Invalid input format: missing IV or encrypted text"); + return null; + } + + const iv = Buffer.from(ivHex, "hex"); + const encryptedText = Buffer.from(encryptedHex, "hex"); + + if (iv.length !== IV_LENGTH) { + logger.error( + `Invalid IV length: expected ${IV_LENGTH}, got ${iv.length}`, + ); + return null; + } + const decipher = crypto.createDecipheriv( ALGORITHM, Buffer.from(env.TOKEN_ENCRYPTION_KEY, "hex"), diff --git a/src/lib/env.ts b/src/lib/env.ts index 27dfd67..d05414f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -32,8 +32,17 @@ export const env = createEnv({ // Cron Job Security CRON_SECRET: z.string().optional(), - // Token Encryption - TOKEN_ENCRYPTION_KEY: z.string().min(1, "TOKEN_ENCRYPTION_KEY is required"), + // Token Encryption (AES-256-CBC requires 32-byte key, 64 hex characters) + TOKEN_ENCRYPTION_KEY: z + .string() + .min( + 64, + "TOKEN_ENCRYPTION_KEY must be at least 64 hex characters (32 bytes) for AES-256-CBC", + ) + .regex( + /^[0-9a-fA-F]+$/, + "TOKEN_ENCRYPTION_KEY must be a valid hexadecimal string", + ), // Optional: Custom processing settings COMMENT_CHECK_DELAY_MS: z.coerce.number().default(60000), diff --git a/src/lib/github-app.ts b/src/lib/github-app.ts index 44dde35..efa9155 100644 --- a/src/lib/github-app.ts +++ b/src/lib/github-app.ts @@ -373,7 +373,6 @@ class GitHubAppClient { export const githubAppClient = GitHubAppClient.getInstance(); export const userOwnedGithubAppClient = async ( - installationId: number, userAccessToken: string, ): Promise => { return new Octokit({ diff --git a/src/lib/github.ts b/src/lib/github.ts index 361e144..e37de50 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -21,11 +21,8 @@ class GitHubClient { /** * Get a GitHub App client authenticated as the user */ - public async getUserOwnedGitHubAppClient( - installationId: number, - userAccessToken: string, - ) { - return userOwnedGithubAppClient(installationId, userAccessToken); + public async getUserOwnedGitHubAppClient(userAccessToken: string) { + return userOwnedGithubAppClient(userAccessToken); } /** @@ -181,16 +178,29 @@ class GitHubClient { installationId?: number, userAccessToken?: string, ) { - const client = userAccessToken - ? await this.getUserOwnedGitHubAppClient(installationId!, userAccessToken) - : await githubAppClient.getInstallationOctokit(installationId!); - - await client.rest.issues.addLabels({ - owner, - repo, - issue_number, - labels: [label], - }); + if (userAccessToken) { + const client = await this.getUserOwnedGitHubAppClient(userAccessToken); + await client.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: [label], + }); + } else { + if (!installationId) { + throw new Error( + "installationId is required when userAccessToken is not provided", + ); + } + const client = + await githubAppClient.getInstallationOctokit(installationId); + await client.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: [label], + }); + } logger.info(`Added label '${label}' to ${owner}/${repo}#${issue_number}`); } @@ -206,12 +216,17 @@ class GitHubClient { userAccessToken?: string, ) { try { - const client = userAccessToken - ? await this.getUserOwnedGitHubAppClient( - installationId!, - userAccessToken, - ) - : await githubAppClient.getInstallationOctokit(installationId!); + let client; + if (userAccessToken) { + client = await this.getUserOwnedGitHubAppClient(userAccessToken); + } else { + if (!installationId) { + throw new Error( + "installationId is required when userAccessToken is not provided", + ); + } + client = await githubAppClient.getInstallationOctokit(installationId); + } await client.rest.issues.removeLabel({ owner, repo, diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 1c68387..274c3cd 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,13 +1,18 @@ import pino from "pino"; -const logger = pino({ +const loggerConfig: pino.LoggerOptions = { level: process.env.NODE_ENV === "development" ? "debug" : "info", - transport: { +}; + +if (process.env.NODE_ENV === "development") { + loggerConfig.transport = { target: "pino-pretty", options: { colorize: true, }, - }, -}); + }; +} + +const logger = pino(loggerConfig); export default logger; diff --git a/src/lib/token-manager.ts b/src/lib/token-manager.ts index 03aae7b..b67ee3e 100644 --- a/src/lib/token-manager.ts +++ b/src/lib/token-manager.ts @@ -11,18 +11,35 @@ async function refreshUserToken(refreshToken: string): Promise<{ error?: string; error_description?: string; }> { - const response = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ - client_id: env.NEXT_PUBLIC_GITHUB_APP_ID, - client_secret: env.GITHUB_APP_CLIENT_SECRET, - grant_type: "refresh_token", - refresh_token: refreshToken, - redirect_uri: env.GITHUB_APP_CALLBACK_URL, - }), - }); - return response.json(); + try { + const response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: env.NEXT_PUBLIC_GITHUB_APP_ID, + client_secret: env.GITHUB_APP_CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshToken, + redirect_uri: env.GITHUB_APP_CALLBACK_URL, + }), + }, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data; + } catch (error) { + logger.error({ error }, "Failed to refresh user token"); + throw error; + } } export async function getUserAccessToken( @@ -120,13 +137,13 @@ export async function getUserAccessToken( const decryptedAccessToken = decrypt(installation.user_access_token); if (!decryptedAccessToken) { - console.error( + logger.error( `[TokenManager] Failed to decrypt access token for installation: ${installationId}.`, ); return null; } - console.log( + logger.info( `[TokenManager] Successfully retrieved access token for installation: ${installationId}`, ); return decryptedAccessToken; From 11a864c9984bfabdd510f150ceb9ba05bd69237d Mon Sep 17 00:00:00 2001 From: Ian Hildebrand <25069719+iHildy@users.noreply.github.com> Date: Sun, 20 Jul 2025 10:28:02 -0400 Subject: [PATCH 3/9] Update jules-queueing-system.md - fix typo Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- jules-queueing-system.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jules-queueing-system.md b/jules-queueing-system.md index 1b47846..46d18e2 100644 --- a/jules-queueing-system.md +++ b/jules-queueing-system.md @@ -31,7 +31,7 @@ graph TD L --> M["Add 'jules-queue' label to GitHub issue"] M --> N["Task queued for retry"] - J --> O["Jules is actively actively working"] + J --> O["Jules is actively working"] O --> P["End - Success path"] Q["Cron job runs every 30 minutes"] --> R["Find all JulesTask where flaggedForRetry = true"] From e6451371598e09c2df87bc9c23418fb2847918b6 Mon Sep 17 00:00:00 2001 From: iHildy Date: Sun, 20 Jul 2025 09:43:20 -0500 Subject: [PATCH 4/9] address code review comments. Add RateLimit model and implement database-based rate limiting in GitHub callback API - Introduced RateLimit model in Prisma schema for managing API rate limits. - Replaced in-memory rate limiting with a database-based approach in the GitHub callback route. - Enhanced error handling in the rate limit check function. - Updated cleanup route to use a constant for date calculations. - Improved error logging in the crypto module. --- .../migration.sql | 29 ++++ prisma/schema.prisma | 21 +++ src/app/api/auth/callback/github/route.ts | 152 ++++++++++++++++-- src/app/api/cron/cleanup/route.ts | 5 +- src/lib/crypto.ts | 6 +- src/lib/token-manager.ts | 3 +- 6 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20250720143202_add_rate_limiting/migration.sql diff --git a/prisma/migrations/20250720143202_add_rate_limiting/migration.sql b/prisma/migrations/20250720143202_add_rate_limiting/migration.sql new file mode 100644 index 0000000..eb4fdba --- /dev/null +++ b/prisma/migrations/20250720143202_add_rate_limiting/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "github_installations" ALTER COLUMN "refresh_token_expires_at" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "token_expires_at" SET DATA TYPE TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "rate_limits" ( + "id" SERIAL NOT NULL, + "identifier" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "requests" INTEGER NOT NULL DEFAULT 1, + "windowStart" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "rate_limits_identifier_idx" ON "rate_limits"("identifier"); + +-- CreateIndex +CREATE INDEX "rate_limits_endpoint_idx" ON "rate_limits"("endpoint"); + +-- CreateIndex +CREATE INDEX "rate_limits_expiresAt_idx" ON "rate_limits"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "rate_limits_identifier_endpoint_key" ON "rate_limits"("identifier", "endpoint"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb87f9a..f8e557d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -165,3 +165,24 @@ model LabelPreferenceRepository { @@index([owner, name]) @@map("label_preference_repositories") } + +model RateLimit { + id Int @id @default(autoincrement()) + identifier String // IP address or user identifier + endpoint String // API endpoint being rate limited + requests Int @default(1) + windowStart DateTime + expiresAt DateTime + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Unique constraint for identifier + endpoint + @@unique([identifier, endpoint]) + // Indexes for performance + @@index([identifier]) + @@index([endpoint]) + @@index([expiresAt]) + @@map("rate_limits") +} diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index 2a09c84..f5a4d36 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -6,25 +6,153 @@ import { cookies } from "next/headers"; import * as crypto from "crypto"; import logger from "@/lib/logger"; -// Simple in-memory rate limiter (for demonstration purposes) -const rateLimit = new Map(); -const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute -const MAX_REQUESTS_PER_WINDOW = 10; // Max 10 requests per minute +// Type interface for rate limit operations +interface RateLimitClient { + deleteMany: (args: { + where: { expiresAt: { lt: Date } }; + }) => Promise<{ count: number }>; + findUnique: (args: { + where: { + identifier_endpoint: { + identifier: string; + endpoint: string; + }; + }; + }) => Promise<{ + id: number; + identifier: string; + endpoint: string; + requests: number; + windowStart: Date; + expiresAt: Date; + } | null>; + create: (args: { + data: { + identifier: string; + endpoint: string; + requests: number; + windowStart: Date; + expiresAt: Date; + }; + }) => Promise; + update: (args: { + where: { id: number }; + data: { + requests?: number; + windowStart?: Date; + expiresAt?: Date; + }; + }) => Promise; +} + +// Database-based rate limiter for production use +async function checkRateLimit( + identifier: string, + maxRequests: number = 10, + windowMs: number = 60 * 1000, // 1 minute default +) { + const now = new Date(); + const windowStart = new Date(now.getTime() - windowMs); + const endpoint = "/api/auth/callback/github"; + + try { + const rateLimitDb = db as unknown as { rateLimit: RateLimitClient }; + + // Clean up expired rate limit entries first + await rateLimitDb.rateLimit.deleteMany({ + where: { + expiresAt: { + lt: now, + }, + }, + }); + + // Get existing rate limit record + const existingLimit = await rateLimitDb.rateLimit.findUnique({ + where: { + identifier_endpoint: { + identifier, + endpoint, + }, + }, + }); + + if (!existingLimit) { + // First request in window - create new record + await rateLimitDb.rateLimit.create({ + data: { + identifier, + endpoint, + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + + return { + allowed: true, + remaining: maxRequests - 1, + resetTime: new Date(now.getTime() + windowMs), + }; + } + + // Check if window has expired + if (existingLimit.windowStart < windowStart) { + // Window expired - reset counter + await rateLimitDb.rateLimit.update({ + where: { id: existingLimit.id }, + data: { + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + + return { + allowed: true, + remaining: maxRequests - 1, + resetTime: new Date(now.getTime() + windowMs), + }; + } + + // Window is still active + if (existingLimit.requests >= maxRequests) { + return { + allowed: false, + remaining: 0, + resetTime: existingLimit.expiresAt, + }; + } + + // Increment counter + await rateLimitDb.rateLimit.update({ + where: { id: existingLimit.id }, + data: { + requests: existingLimit.requests + 1, + }, + }); + + return { + allowed: true, + remaining: maxRequests - existingLimit.requests - 1, + resetTime: existingLimit.expiresAt, + }; + } catch (error) { + logger.error({ error, identifier, endpoint }, "Rate limit check failed"); + // On error, allow the request to prevent blocking legitimate users + return { allowed: true }; + } +} export async function GET(request: NextRequest) { - // Apply rate limiting + // Apply database-based rate limiting const ip = request.headers.get("x-forwarded-for") || "unknown"; - const lastRequestTime = rateLimit.get(ip) || 0; - const currentTime = Date.now(); + const rateLimitResult = await checkRateLimit(ip); - if ( - currentTime - lastRequestTime < - RATE_LIMIT_WINDOW_MS / MAX_REQUESTS_PER_WINDOW - ) { + if (!rateLimitResult.allowed) { logger.warn({ ip }, "Rate limit exceeded"); return NextResponse.json({ error: "Too many requests" }, { status: 429 }); } - rateLimit.set(ip, currentTime); const searchParams = request.nextUrl.searchParams; const code = searchParams.get("code"); diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts index b1659ff..abe7927 100644 --- a/src/app/api/cron/cleanup/route.ts +++ b/src/app/api/cron/cleanup/route.ts @@ -43,9 +43,8 @@ export async function GET(request: NextRequest): Promise { }); // 2. Clean up old, uninstalled installations (e.g., older than 30 days) - const thirtyDaysAgo = new Date( - now.getTime() - 30 * 24 * 60 * 60 * 1000, - ); + const THIRTY_DAYS_IN_MS = 30 * 24 * 60 * 60 * 1000; + const thirtyDaysAgo = new Date(now.getTime() - THIRTY_DAYS_IN_MS); const oldInstallations = await prisma.gitHubInstallation.deleteMany({ where: { suspendedAt: { diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index a2ea424..3a14981 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -67,7 +67,11 @@ export function decrypt(text: string): string | null { decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString(); } catch (error) { - logger.error({ error }, "Decryption failed:"); + const errorMessage = + error instanceof Error + ? `${error.name}: ${error.message}` + : "Unknown error"; + logger.error(`Decryption failed: ${errorMessage}`); return null; } } diff --git a/src/lib/token-manager.ts b/src/lib/token-manager.ts index b67ee3e..15c2f8f 100644 --- a/src/lib/token-manager.ts +++ b/src/lib/token-manager.ts @@ -25,7 +25,6 @@ async function refreshUserToken(refreshToken: string): Promise<{ client_secret: env.GITHUB_APP_CLIENT_SECRET, grant_type: "refresh_token", refresh_token: refreshToken, - redirect_uri: env.GITHUB_APP_CALLBACK_URL, }), }, ); @@ -35,6 +34,8 @@ async function refreshUserToken(refreshToken: string): Promise<{ } const data = await response.json(); + + // Always return the data - let the caller handle GitHub API errors return data; } catch (error) { logger.error({ error }, "Failed to refresh user token"); From 090366a15f442f3a8df551269f4c151d93a9b449 Mon Sep 17 00:00:00 2001 From: iHildy Date: Sun, 20 Jul 2025 11:11:11 -0500 Subject: [PATCH 5/9] Implement fallback rate limiting in GitHub callback API - Introduced a global fallback rate limiting mechanism to handle errors in the rate limit check. - Replaced the previous behavior of allowing all requests on error with a restrictive fallback limit. - Enhanced logging for fallback rate limiter usage and exceeded limits. --- src/app/api/auth/callback/github/route.ts | 63 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index f5a4d36..d34d39d 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -6,6 +6,13 @@ import { cookies } from "next/headers"; import * as crypto from "crypto"; import logger from "@/lib/logger"; +// Global fallback rate limiting storage +declare global { + var fallbackRateLimits: + | Map + | undefined; +} + // Type interface for rate limit operations interface RateLimitClient { deleteMany: (args: { @@ -139,8 +146,60 @@ async function checkRateLimit( }; } catch (error) { logger.error({ error, identifier, endpoint }, "Rate limit check failed"); - // On error, allow the request to prevent blocking legitimate users - return { allowed: true }; + + // SECURITY FIX: Do NOT allow all requests on error + // Instead use a restrictive fallback rate limiter + + // Simple in-memory fallback with strict limits + const fallbackKey = `${identifier}:fallback`; + const now = Date.now(); + const fallbackWindowMs = 60 * 1000; // 1 minute + const fallbackMaxRequests = 2; // Very restrictive + + // Get or create fallback entry + if (!global.fallbackRateLimits) { + global.fallbackRateLimits = new Map(); + } + + const existing = global.fallbackRateLimits.get(fallbackKey); + + // Clean expired entries + if (existing && now - existing.windowStart > fallbackWindowMs) { + global.fallbackRateLimits.delete(fallbackKey); + } + + const current = global.fallbackRateLimits.get(fallbackKey) || { + count: 0, + windowStart: now, + }; + + // Check if limit exceeded + if (current.count >= fallbackMaxRequests) { + logger.warn( + { identifier, count: current.count, maxRequests: fallbackMaxRequests }, + "Fallback rate limit exceeded - denying request", + ); + return { + allowed: false, + remaining: 0, + resetTime: new Date(current.windowStart + fallbackWindowMs), + }; + } + + // Increment counter + current.count++; + global.fallbackRateLimits.set(fallbackKey, current); + + logger.warn( + { identifier, count: current.count, maxRequests: fallbackMaxRequests }, + "Using fallback rate limiter due to database error", + ); + + return { + allowed: true, + remaining: fallbackMaxRequests - current.count, + resetTime: new Date(current.windowStart + fallbackWindowMs), + }; } } From 45fe6a4eb5a632889070f11f4ddf63caa7839688 Mon Sep 17 00:00:00 2001 From: iHildy Date: Sun, 20 Jul 2025 11:26:28 -0500 Subject: [PATCH 6/9] Refactor Vercel configuration and simplify GitHub callback rate limiting - Removed unnecessary environment variables from Vercel functions configuration. - Streamlined the rate limiting logic in the GitHub callback API by directly using the `db` object for rate limit operations, eliminating the intermediate `rateLimitDb` variable. --- src/app/api/auth/callback/github/route.ts | 51 +++-------------------- vercel.json | 12 ++---- 2 files changed, 8 insertions(+), 55 deletions(-) diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index d34d39d..f4c4533 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -13,45 +13,6 @@ declare global { | undefined; } -// Type interface for rate limit operations -interface RateLimitClient { - deleteMany: (args: { - where: { expiresAt: { lt: Date } }; - }) => Promise<{ count: number }>; - findUnique: (args: { - where: { - identifier_endpoint: { - identifier: string; - endpoint: string; - }; - }; - }) => Promise<{ - id: number; - identifier: string; - endpoint: string; - requests: number; - windowStart: Date; - expiresAt: Date; - } | null>; - create: (args: { - data: { - identifier: string; - endpoint: string; - requests: number; - windowStart: Date; - expiresAt: Date; - }; - }) => Promise; - update: (args: { - where: { id: number }; - data: { - requests?: number; - windowStart?: Date; - expiresAt?: Date; - }; - }) => Promise; -} - // Database-based rate limiter for production use async function checkRateLimit( identifier: string, @@ -63,10 +24,8 @@ async function checkRateLimit( const endpoint = "/api/auth/callback/github"; try { - const rateLimitDb = db as unknown as { rateLimit: RateLimitClient }; - // Clean up expired rate limit entries first - await rateLimitDb.rateLimit.deleteMany({ + await db.rateLimit.deleteMany({ where: { expiresAt: { lt: now, @@ -75,7 +34,7 @@ async function checkRateLimit( }); // Get existing rate limit record - const existingLimit = await rateLimitDb.rateLimit.findUnique({ + const existingLimit = await db.rateLimit.findUnique({ where: { identifier_endpoint: { identifier, @@ -86,7 +45,7 @@ async function checkRateLimit( if (!existingLimit) { // First request in window - create new record - await rateLimitDb.rateLimit.create({ + await db.rateLimit.create({ data: { identifier, endpoint, @@ -106,7 +65,7 @@ async function checkRateLimit( // Check if window has expired if (existingLimit.windowStart < windowStart) { // Window expired - reset counter - await rateLimitDb.rateLimit.update({ + await db.rateLimit.update({ where: { id: existingLimit.id }, data: { requests: 1, @@ -132,7 +91,7 @@ async function checkRateLimit( } // Increment counter - await rateLimitDb.rateLimit.update({ + await db.rateLimit.update({ where: { id: existingLimit.id }, data: { requests: existingLimit.requests + 1, diff --git a/vercel.json b/vercel.json index c73960e..1f873c1 100644 --- a/vercel.json +++ b/vercel.json @@ -7,16 +7,10 @@ ], "functions": { "src/app/api/cron/retry/route.ts": { - "maxDuration": 300, - "environment": { - "GITHUB_APP_CALLBACK_URL": "@github_app_callback_url" - } + "maxDuration": 300 }, - "src/app/api/webhooks/github/route.ts": { - "maxDuration": 60, - "environment": { - "GITHUB_APP_CALLBACK_URL": "@github_app_callback_url" - } + "src/app/api/webhooks/github-app/route.ts": { + "maxDuration": 60 } }, "headers": [ From 12ff19a11705342529a57d8152771c867be3a191 Mon Sep 17 00:00:00 2001 From: iHildy Date: Sun, 20 Jul 2025 21:20:16 -0500 Subject: [PATCH 7/9] Refactor GitHub OAuth flow and improve error handling - Updated Prisma schema to use camelCase for token fields in GitHubInstallation model. - Enhanced the GitHub OAuth callback route with improved state validation and error handling. - Implemented fallback logic for installation_id retrieval and added detailed logging for debugging. - Updated token management to ensure proper handling of expired tokens and error responses. - Improved cleanup route to handle expired refresh tokens more effectively. - Added URL validation for OAuth redirects in the installation status handler. --- .../migration.sql | 18 ++ prisma/schema.prisma | 12 +- src/app/api/auth/authorize/github/route.ts | 96 +++++++ src/app/api/auth/callback/github/route.ts | 237 +++++++++++++++--- src/app/api/cron/cleanup/route.ts | 19 +- src/app/api/github-app/star-check/route.ts | 91 +++++-- src/app/api/webhooks/github-app/route.ts | 10 +- src/components/success/error-state.tsx | 2 +- .../success/installation-status-handler.tsx | 57 +++++ src/lib/env.ts | 2 +- src/lib/token-manager.ts | 111 ++++++-- 11 files changed, 560 insertions(+), 95 deletions(-) create mode 100644 prisma/migrations/20250721021416_rename_oauth_fields_to_camelcase/migration.sql create mode 100644 src/app/api/auth/authorize/github/route.ts 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..0e81061 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,172 @@ 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 the incoming request details for debugging + logger.info("OAuth callback received", { + url: request.url, + state, + code: code ? "present" : "missing", + installationId: searchParams.get("installation_id"), + 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", { + originalState: state, + decodedState, + 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", + "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({ 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 +367,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 +382,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 +430,54 @@ 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.create({ + data: { + 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 + }, + }); } 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/github-app/star-check/route.ts b/src/app/api/github-app/star-check/route.ts index 77d8691..a487ce6 100644 --- a/src/app/api/github-app/star-check/route.ts +++ b/src/app/api/github-app/star-check/route.ts @@ -1,5 +1,7 @@ +import { decrypt } from "@/lib/crypto"; import { env } from "@/lib/env"; import { githubClient } from "@/lib/github"; +import { db } from "@/server/db"; import { NextRequest, NextResponse } from "next/server"; export async function GET(req: NextRequest) { @@ -32,29 +34,86 @@ 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 (!installationInfo.account) { + if (!installation) { return NextResponse.json( - { error: "Unable to determine user account from installation" }, - { status: 400 }, + { 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 }, ); } - // Handle both User and Organization accounts - const username = - "login" in installationInfo.account - ? installationInfo.account.login - : installationInfo.account.name; + // 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) { + console.error("Failed to decrypt user access token:", decryptError); + return NextResponse.json( + { + error: "token_decryption_failed", + message: + "Failed to decrypt user access token. Please reinstall the app.", + }, + { status: 500 }, + ); + } + + if (!decryptedToken) { + console.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( diff --git a/src/app/api/webhooks/github-app/route.ts b/src/app/api/webhooks/github-app/route.ts index c76cef9..3d48a3e 100644 --- a/src/app/api/webhooks/github-app/route.ts +++ b/src/app/api/webhooks/github-app/route.ts @@ -1,12 +1,12 @@ 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"; import { createHmac, timingSafeEqual } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import logger from "@/lib/logger"; // GitHub webhook payload interfaces interface GitHubAccount { @@ -241,10 +241,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, }, }); 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/env.ts b/src/lib/env.ts index d05414f..51e1ea0 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -37,7 +37,7 @@ export const env = createEnv({ .string() .min( 64, - "TOKEN_ENCRYPTION_KEY must be at least 64 hex characters (32 bytes) for AES-256-CBC", + "TOKEN_ENCRYPTION_KEY must be at least 64 hex characters for AES-256-CBC", ) .regex( /^[0-9a-fA-F]+$/, 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}.`, From cf9ea72a9bef0e09513d6c6622545e14afd7e7a2 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 11 Aug 2025 22:32:09 -0500 Subject: [PATCH 8/9] Refactor logging and configuration in API routes - Replaced console logging with a centralized logger for improved consistency and error tracking across various API routes. - Updated Next.js configuration to remove deprecated image settings and streamline header management. - Implemented minimal rate limiting for webhook endpoints to prevent abuse and enhance security. - Enhanced error handling and logging in GitHub App installation and webhook processing functions. --- .github/workflows/ci.yml | 34 ++++ .github/workflows/codeql.yml | 33 +++ .nvmrc | 2 + next.config.ts | 22 +- src/app/api/auth/callback/github/route.ts | 16 +- src/app/api/cron/retry/route.ts | 71 +++---- src/app/api/github-app/install/route.ts | 39 ++-- .../[installationId]/repositories/route.ts | 3 +- src/app/api/github-app/label-setup/route.ts | 7 +- src/app/api/github-app/star-check/route.ts | 20 +- src/app/api/health/route.ts | 17 +- src/app/api/trpc/[trpc]/route.ts | 6 +- src/app/api/webhooks/github-app/route.ts | 192 ++++++++++++++++-- src/lib/github-app-utils.ts | 3 +- src/lib/github-labels.ts | 33 ++- src/lib/installation-service.ts | 18 +- src/lib/jules.ts | 6 +- src/lib/webhook-processor.ts | 56 ++--- src/server/api/routers/admin.ts | 23 ++- 19 files changed, 422 insertions(+), 179 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .nvmrc 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/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/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index 0e81061..bd80841 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -176,12 +176,12 @@ export async function GET(request: NextRequest) { const code = searchParams.get("code"); const state = searchParams.get("state"); - // Log the incoming request details for debugging + // Log minimal details. Do NOT log raw state value. logger.info("OAuth callback received", { - url: request.url, - state, - code: code ? "present" : "missing", - installationId: searchParams.get("installation_id"), + url: new URL(request.url).origin, + statePresent: Boolean(state), + codePresent: Boolean(code), + installationId: searchParams.get("installation_id") ? "present" : "missing", setupAction: searchParams.get("setup_action"), }); @@ -265,8 +265,6 @@ export async function GET(request: NextRequest) { // 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", { - originalState: state, - decodedState, hasCookie: !!oauthStateCookie, }); } @@ -281,7 +279,7 @@ export async function GET(request: NextRequest) { if (!stateValidationPassed) { logger.error( - { state, oauthStateCookie: oauthStateCookie?.value }, + { statePresent: Boolean(state) }, "CSRF state validation failed", ); return NextResponse.json( @@ -317,7 +315,7 @@ export async function GET(request: NextRequest) { ); redirectTo = "/github-app/success"; } else { - logger.error({ state }, "Invalid state format"); + logger.error({ statePresent: Boolean(state) }, "Invalid state format"); return NextResponse.json( { error: "Invalid state format" }, { status: 400 }, 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 a487ce6..f9b0557 100644 --- a/src/app/api/github-app/star-check/route.ts +++ b/src/app/api/github-app/star-check/route.ts @@ -1,6 +1,7 @@ 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"; @@ -16,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", @@ -88,7 +89,10 @@ export async function GET(req: NextRequest) { try { decryptedToken = decrypt(installation.userAccessToken); } catch (decryptError) { - console.error("Failed to decrypt user access token:", decryptError); + logger.error( + { error: decryptError }, + "Failed to decrypt user access token", + ); return NextResponse.json( { error: "token_decryption_failed", @@ -100,7 +104,7 @@ export async function GET(req: NextRequest) { } if (!decryptedToken) { - console.error("Failed to decrypt user access token"); + logger.error("Failed to decrypt user access token"); return NextResponse.json( { error: "token_decryption_failed", @@ -134,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( { @@ -156,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", @@ -167,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 3d48a3e..5756026 100644 --- a/src/app/api/webhooks/github-app/route.ts +++ b/src/app/api/webhooks/github-app/route.ts @@ -87,10 +87,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 +118,131 @@ 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 { + await db.rateLimit.create({ + data: { + identifier, + endpoint, + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + return { allowed: true, remaining: maxRequests - 1 } as const; + } catch { + // 3. If insert failed (conflict), 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; + } + // Final increment if under limit + 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 */ @@ -415,6 +546,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 +589,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 +839,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/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..8fb3307 100644 --- a/src/lib/jules.ts +++ b/src/lib/jules.ts @@ -1,7 +1,7 @@ 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"; import type { CommentAnalysis, CommentClassification, @@ -400,7 +400,7 @@ export async function handleTaskLimit( `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 +484,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/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"}`, ); From 9cf7629a3db6484bb0e3d7252261671103017874 Mon Sep 17 00:00:00 2001 From: iHildy Date: Thu, 14 Aug 2025 19:15:31 -0500 Subject: [PATCH 9/9] Refactor GitHub installation handling and rate limit handling = fully tested and working - Refactored GitHub installation record creation to use `upsert` for better handling of existing records. - Enhanced rate limit handling in webhook processing to prevent unique constraint races and improve request management. --- .cursor/rules/review-gate.mdc | 5 +-- example.png | Bin 0 -> 148599 bytes src/app/api/auth/callback/github/route.ts | 6 ++- src/app/api/webhooks/github-app/route.ts | 40 +++++++++++++++--- .../label-setup/label-setup-handler.tsx | 2 +- src/lib/jules.ts | 4 +- 6 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 example.png 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/example.png b/example.png new file mode 100644 index 0000000000000000000000000000000000000000..e7483e582f12f3eeec4c40ffbfe4b3f71f7155e3 GIT binary patch literal 148599 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 Xy8e}ir+!TTwdHG>xtz1q`KMZMfxd8e$jt$x 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 We?1oeHf-ouNxuIM1-~W#%gA4&o)^Rb literal 0 HcmV?d00001 diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index bd80841..381cebf 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -440,8 +440,9 @@ export async function GET(request: NextRequest) { ); // Create a minimal installation record - the webhook will update it with full details later - existingInstallation = await db.gitHubInstallation.create({ - data: { + 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 @@ -451,6 +452,7 @@ export async function GET(request: NextRequest) { events: "[]", // Will be updated by webhook repositorySelection: "all", // Will be updated by webhook }, + update: {}, // No update needed if it already exists }); } diff --git a/src/app/api/webhooks/github-app/route.ts b/src/app/api/webhooks/github-app/route.ts index 5756026..8e7f393 100644 --- a/src/app/api/webhooks/github-app/route.ts +++ b/src/app/api/webhooks/github-app/route.ts @@ -142,7 +142,7 @@ async function checkRateLimit( const updated = await db.$executeRawUnsafe( `UPDATE rate_limits SET requests = requests + 1 - WHERE identifier = $1 AND endpoint = $2 AND expiresAt > $3 AND requests < $4`, + WHERE identifier = $1 AND endpoint = $2 AND "expiresAt" > $3 AND requests < $4`, identifier, endpoint, now, @@ -164,8 +164,11 @@ async function checkRateLimit( // 2. Either new window or first request: try insert try { - await db.rateLimit.create({ - data: { + // Use upsert to avoid unique constraint races + const record = await db.rateLimit.upsert({ + where: { identifier_endpoint: { identifier, endpoint } }, + update: {}, + create: { identifier, endpoint, requests: 1, @@ -173,9 +176,35 @@ async function checkRateLimit( expiresAt: new Date(now.getTime() + windowMs), }, }); - return { allowed: true, remaining: maxRequests - 1 } as const; + // 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 insert failed (conflict), reset window if expired, else check limit + // 3. If upsert failed (rare), reset window if expired, else check limit const record = await db.rateLimit.findUnique({ where: { identifier_endpoint: { identifier, endpoint } }, }); @@ -196,7 +225,6 @@ async function checkRateLimit( if (record.requests >= maxRequests) { return { allowed: false, remaining: 0 } as const; } - // Final increment if under limit const newCount = record.requests + 1; await db.rateLimit.update({ where: { id: record.id }, 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/lib/jules.ts b/src/lib/jules.ts index 8fb3307..bc5d9f3 100644 --- a/src/lib/jules.ts +++ b/src/lib/jules.ts @@ -20,7 +20,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,6 +396,7 @@ export async function handleTaskLimit( repo, analysis.comment.id, "eyes", + installationId, ); logger.info( `Added refresh emoji reaction to Jules comment for task limit`,