diff --git a/.gitignore b/.gitignore index 5ef6a52..bf2fedc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + + +*.db + +dev.db. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bca74d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +# Grepbase Project Guidelines + +## React Patterns + +### Hooks Usage + +**Do NOT use `useEffect`** for derived state or side effects that can be handled declaratively. + +- State initialization should use lazy initializers in `useState`, not `useEffect` +- Derived values should use `useMemo`, not `useEffect` + state +- Prefer event handlers over effects for user-triggered changes + +If you think you need `useEffect`, consider: +1. Can this be computed during render? +2. Can this be handled in an event handler? +3. Is this truly a side effect that requires synchronization? diff --git a/dev.db b/dev.db deleted file mode 100644 index 66bfccb..0000000 Binary files a/dev.db and /dev/null differ diff --git a/src/app/api/explain/commit/route.ts b/src/app/api/explain/commit/route.ts index f8e9466..8faea24 100644 --- a/src/app/api/explain/commit/route.ts +++ b/src/app/api/explain/commit/route.ts @@ -3,79 +3,40 @@ import { repositories, commits } from '@/db'; import { eq, and, sql } from 'drizzle-orm'; import { fetchCommitDiff } from '@/services/github'; import { explainCommit } from '@/services/explain'; -import { explainRequestSchema } from '@/lib/validation'; +import { explainCommitSchema } from '@/lib/validation'; import { logger } from '@/lib/logger'; import { RATE_LIMITS } from '@/lib/constants'; import { analytics } from '@/lib/analytics'; import { getDb } from '@/db'; -import { - applyPrivateNoStoreHeaders, - enforceCsrfProtection, - enforceRateLimit, - resolveSession, -} from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; -import { getClientIdFromHeaders, resolveAvailableFilePathsForCommit, resolveProviderConfigFromRequest } from '../utils'; +import { applyPrivateNoStoreHeaders, guardRoute, getClientIdFromHeaders } from '@/lib/api-security'; +import { ensureRepoAccess } from '@/services/resource-access'; +import { resolveAvailableFilePathsForCommit, resolveProviderConfigFromRequest } from '../utils'; export async function POST(request: NextRequest) { const requestLogger = logger.child({ endpoint: 'POST /api/explain/commit' }); const startTime = Date.now(); - requestLogger.debug({ method: request.method, url: request.url }, 'Request received'); try { - const csrfError = enforceCsrfProtection(request); - if (csrfError) { - requestLogger.warn('CSRF validation failed'); - return csrfError; - } - - const session = await resolveSession(request); - if (!session) { - requestLogger.warn('Session not found'); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const rateLimitError = await enforceRateLimit(request, { - keyPrefix: 'api:explain:commit', - limit: RATE_LIMITS.EXPLAIN_API, - sessionId: session.sessionId, + const guard = await guardRoute(request, { + rateLimit: { keyPrefix: 'api:explain:commit', limit: RATE_LIMITS.EXPLAIN_API }, + analytics: { endpoint: '/api/explain/commit' }, }); - if (rateLimitError) { - const clientId = getClientIdFromHeaders(request); - requestLogger.warn({ clientId }, 'Rate limit exceeded'); - await analytics.trackRateLimit({ endpoint: '/api/explain/commit', clientId, blocked: true }); - return rateLimitError.response; - } - - const clientId = getClientIdFromHeaders(request); - await analytics.trackRateLimit({ endpoint: '/api/explain/commit', clientId, blocked: false }); + if (!guard.ok) return guard.response; + const { session, clientId } = guard; const db = getDb(); const rawBody = await request.json().catch(() => null); - const parseResult = explainRequestSchema.safeParse(rawBody); + const parseResult = explainCommitSchema.safeParse(rawBody); if (!parseResult.success) { return NextResponse.json({ error: 'Validation failed', details: parseResult.error.issues }, { status: 400 }); } - const { repoId, commitSha, provider, providerType, model, baseUrl, visibleFiles } = parseResult.data; + const { repoId, commitSha, provider, visibleFiles } = parseResult.data; - if (parseResult.data.type !== 'commit' || !commitSha) { - return NextResponse.json({ error: 'Invalid request wrapper for commit' }, { status: 400 }); - } - - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Repository access denied for explain'); - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); - const providerConfig = await resolveProviderConfigFromRequest(request, { - provider, - providerType, - baseUrl, - model, - }, session.sessionId); + const providerConfig = await resolveProviderConfigFromRequest(request, provider, session.sessionId); const repo = await db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1); if (repo.length === 0) return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); diff --git a/src/app/api/explain/day-summary/route.ts b/src/app/api/explain/day-summary/route.ts index 79e8831..48fea21 100644 --- a/src/app/api/explain/day-summary/route.ts +++ b/src/app/api/explain/day-summary/route.ts @@ -1,71 +1,36 @@ import { NextRequest, NextResponse } from 'next/server'; -import { explainRequestSchema } from '@/lib/validation'; +import { explainDaySummarySchema } from '@/lib/validation'; import { logger } from '@/lib/logger'; import { RATE_LIMITS, AI_CONSTANTS } from '@/lib/constants'; import { analytics } from '@/lib/analytics'; -import { - applyPrivateNoStoreHeaders, - enforceCsrfProtection, - enforceRateLimit, - resolveSession, -} from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; -import { getClientIdFromHeaders, resolveProviderConfigFromRequest } from '../utils'; +import { applyPrivateNoStoreHeaders, guardRoute, getClientIdFromHeaders } from '@/lib/api-security'; +import { ensureRepoAccess } from '@/services/resource-access'; +import { resolveProviderConfigFromRequest } from '../utils'; export async function POST(request: NextRequest) { const requestLogger = logger.child({ endpoint: 'POST /api/explain/day-summary' }); const startTime = Date.now(); try { - const csrfError = enforceCsrfProtection(request); - if (csrfError) { - return csrfError; - } - - const session = await resolveSession(request); - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const rateLimitError = await enforceRateLimit(request, { - keyPrefix: 'api:explain:day-summary', - limit: RATE_LIMITS.EXPLAIN_API, - sessionId: session.sessionId, + const guard = await guardRoute(request, { + rateLimit: { keyPrefix: 'api:explain:day-summary', limit: RATE_LIMITS.EXPLAIN_API }, + analytics: { endpoint: '/api/explain/day-summary' }, }); - if (rateLimitError) { - const clientId = getClientIdFromHeaders(request); - requestLogger.warn({ clientId }, 'Rate limit exceeded'); - await analytics.trackRateLimit({ endpoint: '/api/explain/day-summary', clientId, blocked: true }); - return rateLimitError.response; - } - - const clientId = getClientIdFromHeaders(request); - await analytics.trackRateLimit({ endpoint: '/api/explain/day-summary', clientId, blocked: false }); + if (!guard.ok) return guard.response; + const { session, clientId } = guard; const rawBody = await request.json().catch(() => null); - const parseResult = explainRequestSchema.safeParse(rawBody); + const parseResult = explainDaySummarySchema.safeParse(rawBody); if (!parseResult.success) { return NextResponse.json({ error: 'Validation failed', details: parseResult.error.issues }, { status: 400 }); } - const { repoId, commits: dayCommits, projectName, projectOwner, provider, providerType, model, baseUrl } = parseResult.data; + const { repoId, commits: dayCommits, projectName, projectOwner, provider } = parseResult.data; - if (parseResult.data.type !== 'day-summary' || !dayCommits || dayCommits.length === 0) { - return NextResponse.json({ error: 'Invalid request wrapper for day-summary' }, { status: 400 }); - } - - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); - const providerConfig = await resolveProviderConfigFromRequest(request, { - provider, - providerType, - baseUrl, - model, - }, session.sessionId); + const providerConfig = await resolveProviderConfigFromRequest(request, provider, session.sessionId); const { streamText } = await import('ai'); const { createAIProviderAsync } = await import('@/services/ai-providers'); diff --git a/src/app/api/explain/project/route.ts b/src/app/api/explain/project/route.ts index 11f2229..e72d0ff 100644 --- a/src/app/api/explain/project/route.ts +++ b/src/app/api/explain/project/route.ts @@ -2,75 +2,40 @@ import { NextRequest, NextResponse } from 'next/server'; import { repositories, commits } from '@/db'; import { eq, sql } from 'drizzle-orm'; import { explainProject } from '@/services/explain'; -import { explainRequestSchema } from '@/lib/validation'; +import { explainProjectSchema } from '@/lib/validation'; import { logger } from '@/lib/logger'; import { RATE_LIMITS } from '@/lib/constants'; import { analytics } from '@/lib/analytics'; import { getDb } from '@/db'; -import { - applyPrivateNoStoreHeaders, - enforceCsrfProtection, - enforceRateLimit, - resolveSession, -} from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; -import { getClientIdFromHeaders, resolveProviderConfigFromRequest } from '../utils'; +import { applyPrivateNoStoreHeaders, guardRoute, getClientIdFromHeaders } from '@/lib/api-security'; +import { ensureRepoAccess } from '@/services/resource-access'; +import { resolveProviderConfigFromRequest } from '../utils'; export async function POST(request: NextRequest) { const requestLogger = logger.child({ endpoint: 'POST /api/explain/project' }); const startTime = Date.now(); try { - const csrfError = enforceCsrfProtection(request); - if (csrfError) { - return csrfError; - } - - const session = await resolveSession(request); - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const rateLimitError = await enforceRateLimit(request, { - keyPrefix: 'api:explain:project', - limit: RATE_LIMITS.EXPLAIN_API, - sessionId: session.sessionId, + const guard = await guardRoute(request, { + rateLimit: { keyPrefix: 'api:explain:project', limit: RATE_LIMITS.EXPLAIN_API }, + analytics: { endpoint: '/api/explain/project' }, }); - if (rateLimitError) { - const clientId = getClientIdFromHeaders(request); - requestLogger.warn({ clientId }, 'Rate limit exceeded'); - await analytics.trackRateLimit({ endpoint: '/api/explain/project', clientId, blocked: true }); - return rateLimitError.response; - } - - const clientId = getClientIdFromHeaders(request); - await analytics.trackRateLimit({ endpoint: '/api/explain/project', clientId, blocked: false }); + if (!guard.ok) return guard.response; + const { session, clientId } = guard; const db = getDb(); const rawBody = await request.json().catch(() => null); - const parseResult = explainRequestSchema.safeParse(rawBody); + const parseResult = explainProjectSchema.safeParse(rawBody); if (!parseResult.success) { return NextResponse.json({ error: 'Validation failed', details: parseResult.error.issues }, { status: 400 }); } - const { repoId, provider, providerType, model, baseUrl } = parseResult.data; + const { repoId, provider } = parseResult.data; - if (parseResult.data.type !== 'project') { - return NextResponse.json({ error: 'Invalid request wrapper for project' }, { status: 400 }); - } - - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); - const providerConfig = await resolveProviderConfigFromRequest(request, { - provider, - providerType, - baseUrl, - model, - }, session.sessionId); + const providerConfig = await resolveProviderConfigFromRequest(request, provider, session.sessionId); const repo = await db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1); if (repo.length === 0) return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); diff --git a/src/app/api/explain/question/route.ts b/src/app/api/explain/question/route.ts index 5318c60..87a6de6 100644 --- a/src/app/api/explain/question/route.ts +++ b/src/app/api/explain/question/route.ts @@ -2,75 +2,40 @@ import { NextRequest, NextResponse } from 'next/server'; import { repositories, commits } from '@/db'; import { eq, and, sql } from 'drizzle-orm'; import { answerQuestion } from '@/services/explain'; -import { explainRequestSchema } from '@/lib/validation'; +import { explainQuestionSchema } from '@/lib/validation'; import { logger } from '@/lib/logger'; import { RATE_LIMITS } from '@/lib/constants'; import { analytics } from '@/lib/analytics'; import { getDb } from '@/db'; -import { - applyPrivateNoStoreHeaders, - enforceCsrfProtection, - enforceRateLimit, - resolveSession, -} from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; -import { getClientIdFromHeaders, resolveAvailableFilePathsForCommit, resolveProviderConfigFromRequest } from '../utils'; +import { applyPrivateNoStoreHeaders, guardRoute, getClientIdFromHeaders } from '@/lib/api-security'; +import { ensureRepoAccess } from '@/services/resource-access'; +import { resolveAvailableFilePathsForCommit, resolveProviderConfigFromRequest } from '../utils'; export async function POST(request: NextRequest) { const requestLogger = logger.child({ endpoint: 'POST /api/explain/question' }); const startTime = Date.now(); try { - const csrfError = enforceCsrfProtection(request); - if (csrfError) { - return csrfError; - } - - const session = await resolveSession(request); - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const rateLimitError = await enforceRateLimit(request, { - keyPrefix: 'api:explain:question', - limit: RATE_LIMITS.EXPLAIN_API, - sessionId: session.sessionId, + const guard = await guardRoute(request, { + rateLimit: { keyPrefix: 'api:explain:question', limit: RATE_LIMITS.EXPLAIN_API }, + analytics: { endpoint: '/api/explain/question' }, }); - if (rateLimitError) { - const clientId = getClientIdFromHeaders(request); - requestLogger.warn({ clientId }, 'Rate limit exceeded'); - await analytics.trackRateLimit({ endpoint: '/api/explain/question', clientId, blocked: true }); - return rateLimitError.response; - } - - const clientId = getClientIdFromHeaders(request); - await analytics.trackRateLimit({ endpoint: '/api/explain/question', clientId, blocked: false }); + if (!guard.ok) return guard.response; + const { session, clientId } = guard; const db = getDb(); const rawBody = await request.json().catch(() => null); - const parseResult = explainRequestSchema.safeParse(rawBody); + const parseResult = explainQuestionSchema.safeParse(rawBody); if (!parseResult.success) { return NextResponse.json({ error: 'Validation failed', details: parseResult.error.issues }, { status: 400 }); } - const { repoId, commitSha, question, provider, providerType, model, baseUrl, visibleFiles } = parseResult.data; + const { repoId, commitSha, question, provider, visibleFiles } = parseResult.data; - if (parseResult.data.type !== 'question' || !question) { - return NextResponse.json({ error: 'Invalid request wrapper for question' }, { status: 400 }); - } - - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); - const providerConfig = await resolveProviderConfigFromRequest(request, { - provider, - providerType, - baseUrl, - model, - }, session.sessionId); + const providerConfig = await resolveProviderConfigFromRequest(request, provider, session.sessionId); const repo = await db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1); if (repo.length === 0) return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); diff --git a/src/app/api/explain/story/route.ts b/src/app/api/explain/story/route.ts index 504ccff..ba50b49 100644 --- a/src/app/api/explain/story/route.ts +++ b/src/app/api/explain/story/route.ts @@ -2,105 +2,46 @@ import { NextRequest, NextResponse } from 'next/server'; import { repositories, commits } from '@/db'; import { asc, eq } from 'drizzle-orm'; import { explainStory } from '@/services/explain'; -import { explainRequestSchema } from '@/lib/validation'; +import { explainStorySchema } from '@/lib/validation'; import { logger } from '@/lib/logger'; import { RATE_LIMITS } from '@/lib/constants'; import { analytics } from '@/lib/analytics'; import { getDb } from '@/db'; -import { - applyPrivateNoStoreHeaders, - enforceCsrfProtection, - enforceRateLimit, - resolveSession, -} from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; -import { getClientIdFromHeaders, resolveProviderConfigFromRequest } from '../utils'; +import { applyPrivateNoStoreHeaders, guardRoute, getClientIdFromHeaders } from '@/lib/api-security'; +import { ensureRepoAccess } from '@/services/resource-access'; +import { resolveProviderConfigFromRequest } from '../utils'; export async function POST(request: NextRequest) { const requestLogger = logger.child({ endpoint: 'POST /api/explain/story' }); const startTime = Date.now(); try { - const csrfError = enforceCsrfProtection(request); - if (csrfError) { - return csrfError; - } - - const session = await resolveSession(request); - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const rateLimitError = await enforceRateLimit(request, { - keyPrefix: 'api:explain:story', - limit: RATE_LIMITS.EXPLAIN_API, - sessionId: session.sessionId, + const guard = await guardRoute(request, { + rateLimit: { keyPrefix: 'api:explain:story', limit: RATE_LIMITS.EXPLAIN_API }, + analytics: { endpoint: '/api/explain/story' }, }); - if (rateLimitError) { - const clientId = getClientIdFromHeaders(request); - requestLogger.warn({ clientId }, 'Rate limit exceeded'); - await analytics.trackRateLimit({ endpoint: '/api/explain/story', clientId, blocked: true }); - return rateLimitError.response; - } - - const clientId = getClientIdFromHeaders(request); - await analytics.trackRateLimit({ endpoint: '/api/explain/story', clientId, blocked: false }); + if (!guard.ok) return guard.response; + const { session, clientId } = guard; const db = getDb(); const rawBody = await request.json().catch(() => null); - const parseResult = explainRequestSchema.safeParse(rawBody); + const parseResult = explainStorySchema.safeParse(rawBody); if (!parseResult.success) { return NextResponse.json({ error: 'Validation failed', details: parseResult.error.issues }, { status: 400 }); } - const { - type, - repoId, - startSha, - endSha, - chapterSize, - provider, - providerType, - model, - baseUrl, - } = parseResult.data; - - if (type !== 'story') { - return NextResponse.json({ error: 'Invalid request wrapper for story' }, { status: 400 }); - } + const { repoId, startSha, endSha, chapterSize, provider } = parseResult.data; - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + const repo = await db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1); + if (repo.length === 0) return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); - const providerConfig = await resolveProviderConfigFromRequest(request, { - provider, - providerType, - baseUrl, - model, - }, session.sessionId); - - const repo = await db - .select() - .from(repositories) - .where(eq(repositories.id, repoId)) - .limit(1); - - if (repo.length === 0) { - return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); - const repoCommits = await db - .select() - .from(commits) - .where(eq(commits.repoId, repoId)) - .orderBy(asc(commits.order)); + const providerConfig = await resolveProviderConfigFromRequest(request, provider, session.sessionId); - if (repoCommits.length === 0) { - return NextResponse.json({ error: 'No commits found' }, { status: 404 }); - } + const repoCommits = await db.select().from(commits).where(eq(commits.repoId, repoId)).orderBy(asc(commits.order)); + if (repoCommits.length === 0) return NextResponse.json({ error: 'No commits found' }, { status: 404 }); let startIndex = startSha ? repoCommits.findIndex(commit => commit.sha === startSha) @@ -109,17 +50,9 @@ export async function POST(request: NextRequest) { ? repoCommits.findIndex(commit => commit.sha === endSha) : repoCommits.length - 1; - if (startSha && startIndex < 0) { - return NextResponse.json({ error: 'startSha not found in repository commits' }, { status: 400 }); - } - - if (endSha && endIndex < 0) { - return NextResponse.json({ error: 'endSha not found in repository commits' }, { status: 400 }); - } - - if (startIndex > endIndex) { - [startIndex, endIndex] = [endIndex, startIndex]; - } + if (startSha && startIndex < 0) return NextResponse.json({ error: 'startSha not found in repository commits' }, { status: 400 }); + if (endSha && endIndex < 0) return NextResponse.json({ error: 'endSha not found in repository commits' }, { status: 400 }); + if (startIndex > endIndex) [startIndex, endIndex] = [endIndex, startIndex]; const MAX_STORY_COMMITS = 120; let selectedCommits = repoCommits.slice(startIndex, endIndex + 1); @@ -148,38 +81,15 @@ export async function POST(request: NextRequest) { ); const duration = Date.now() - startTime; - await analytics.trackAIUsage({ - provider: providerConfig.type, - model: providerConfig.model, - type: 'story', - success: true, - duration, - }); - await analytics.trackRequest({ - endpoint: '/api/explain/story', - method: 'POST', - statusCode: 200, - duration, - clientId, - }); + await analytics.trackAIUsage({ provider: providerConfig.type, model: providerConfig.model, type: 'story', success: true, duration }); + await analytics.trackRequest({ endpoint: '/api/explain/story', method: 'POST', statusCode: 200, duration, clientId }); return applyPrivateNoStoreHeaders(response); } catch (error) { const duration = Date.now() - startTime; const clientId = getClientIdFromHeaders(request); - - await analytics.trackRequest({ - endpoint: '/api/explain/story', - method: 'POST', - statusCode: 500, - duration, - clientId, - }); - + await analytics.trackRequest({ endpoint: '/api/explain/story', method: 'POST', statusCode: 500, duration, clientId }); requestLogger.error({ error }, 'Error generating story'); - return NextResponse.json( - { error: 'Failed to generate story' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to generate story' }, { status: 500 }); } } diff --git a/src/app/api/explain/utils.ts b/src/app/api/explain/utils.ts index 1919a21..846adee 100644 --- a/src/app/api/explain/utils.ts +++ b/src/app/api/explain/utils.ts @@ -1,10 +1,10 @@ import { NextRequest } from 'next/server'; import { eq } from 'drizzle-orm'; import { files } from '@/db'; -import { shouldFetchFileContent } from '@/lib/constants'; +import { shouldFetchFileContent } from '@/lib/file-utils'; import { normalizeProviderBaseUrl } from '@/lib/network-security'; import type { Database } from '@/db'; -import type { AIProviderConfig, AIProviderType } from '@/services/ai-providers'; +import type { AIProviderConfig } from '@/services/ai-providers'; import { isLocalProvider } from '@/services/ai-providers'; import { AI_CREDENTIALS_SESSION_COOKIE, @@ -12,19 +12,6 @@ import { resolveCredentialSessionId, } from '@/services/ai-credentials'; -export function getClientIdFromHeaders(req: NextRequest): string { - const cfConnectingIp = req.headers.get('cf-connecting-ip'); - if (cfConnectingIp) return cfConnectingIp; - - const xForwardedFor = req.headers.get('x-forwarded-for'); - if (xForwardedFor) return xForwardedFor.split(',')[0].trim(); - - const xRealIp = req.headers.get('x-real-ip'); - if (xRealIp) return xRealIp; - - return 'unknown'; -} - export function normalizePath(path: string): string { return path.trim().replace(/^\/+/, '').replace(/^\.\/+/, '').replace(/\/+$/, ''); } @@ -64,45 +51,33 @@ export async function resolveAvailableFilePathsForCommit( .map((file: { path: string }) => normalizePath(file.path)); } -interface ProviderRequestPayload { - provider?: Pick; - providerType?: AIProviderType; - model?: string; - baseUrl?: string; -} - export async function resolveProviderConfigFromRequest( request: NextRequest, - payload: ProviderRequestPayload, + provider: Pick, resolvedSessionId?: string | null ): Promise { - const providerType = payload.provider?.type ?? payload.providerType; - if (!providerType) { - throw new Error('Provider type is required'); - } - - const requestedBaseUrl = payload.provider?.baseUrl ?? payload.baseUrl; - const normalizedBaseUrl = normalizeProviderBaseUrl(providerType, requestedBaseUrl); + const normalizedBaseUrl = normalizeProviderBaseUrl(provider.type, provider.baseUrl); const config: AIProviderConfig = { - type: providerType, - model: payload.provider?.model ?? payload.model, + type: provider.type, + model: provider.model, baseUrl: normalizedBaseUrl, }; - if (isLocalProvider(providerType)) { + if (isLocalProvider(provider.type)) { return config; } - const sessionId = resolvedSessionId ?? await (async () => { + let sessionId = resolvedSessionId; + if (sessionId == null) { const sessionToken = request.cookies.get(AI_CREDENTIALS_SESSION_COOKIE)?.value; - return resolveCredentialSessionId(sessionToken); - })(); + sessionId = await resolveCredentialSessionId(sessionToken); + } if (!sessionId) { return config; } - const storedApiKey = await getStoredProviderApiKey(sessionId, providerType); + const storedApiKey = await getStoredProviderApiKey(sessionId, provider.type); if (storedApiKey) { config.apiKey = storedApiKey; } diff --git a/src/app/api/repos/[id]/commits/[sha]/content/route.ts b/src/app/api/repos/[id]/commits/[sha]/content/route.ts index aa93051..cb69188 100644 --- a/src/app/api/repos/[id]/commits/[sha]/content/route.ts +++ b/src/app/api/repos/[id]/commits/[sha]/content/route.ts @@ -5,7 +5,7 @@ import { getDb } from '@/db'; import { logger } from '@/lib/logger'; import { RATE_LIMITS, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; +import { ensureRepoAccess } from '@/services/resource-access'; import { fetchFileContent, getLanguageFromPath } from '@/services/github'; import { isSafeFilePath } from '@/lib/sanitize'; @@ -43,16 +43,7 @@ export async function GET( return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } - try { - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - const { safeGrantRepoAccess } = await import('@/services/resource-access'); - await safeGrantRepoAccess(repoId, session.sessionId); - requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); - } - } catch { - requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); const [repo, commit] = await Promise.all([ db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1), diff --git a/src/app/api/repos/[id]/commits/[sha]/diff/route.ts b/src/app/api/repos/[id]/commits/[sha]/diff/route.ts index b6d6554..a682195 100644 --- a/src/app/api/repos/[id]/commits/[sha]/diff/route.ts +++ b/src/app/api/repos/[id]/commits/[sha]/diff/route.ts @@ -5,7 +5,7 @@ import { getDb } from '@/db'; import { logger } from '@/lib/logger'; import { RATE_LIMITS, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; +import { ensureRepoAccess } from '@/services/resource-access'; import { fetchCommitFileDiffs } from '@/services/github'; import { isSafeFilePath } from '@/lib/sanitize'; @@ -44,16 +44,7 @@ export async function GET( return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } - try { - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - const { safeGrantRepoAccess } = await import('@/services/resource-access'); - await safeGrantRepoAccess(repoId, session.sessionId); - requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); - } - } catch { - requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); const [repo, commit] = await Promise.all([ db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1), diff --git a/src/app/api/repos/[id]/commits/[sha]/route.ts b/src/app/api/repos/[id]/commits/[sha]/route.ts index 65c68d5..9f8354b 100644 --- a/src/app/api/repos/[id]/commits/[sha]/route.ts +++ b/src/app/api/repos/[id]/commits/[sha]/route.ts @@ -3,9 +3,10 @@ import { and, eq } from 'drizzle-orm'; import { repositories, commits, files } from '@/db'; import { getDb } from '@/db'; import { logger } from '@/lib/logger'; -import { RATE_LIMITS, INGEST, shouldFetchFileContent, COMMIT_SHA_REGEX } from '@/lib/constants'; +import { RATE_LIMITS, INGEST, COMMIT_SHA_REGEX } from '@/lib/constants'; +import { shouldFetchFileContent } from '@/lib/file-utils'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; +import { ensureRepoAccess } from '@/services/resource-access'; import { fetchFilesAtCommit, getLanguageFromPath } from '@/services/github'; export async function GET( @@ -37,16 +38,7 @@ export async function GET( return NextResponse.json({ error: 'Invalid commit SHA' }, { status: 400 }); } - try { - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - const { safeGrantRepoAccess } = await import('@/services/resource-access'); - await safeGrantRepoAccess(repoId, session.sessionId); - requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); - } - } catch { - requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); const [repo, commit] = await Promise.all([ db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1), diff --git a/src/app/api/repos/[id]/commits/route.ts b/src/app/api/repos/[id]/commits/route.ts index 4506be1..ca2b922 100644 --- a/src/app/api/repos/[id]/commits/route.ts +++ b/src/app/api/repos/[id]/commits/route.ts @@ -5,7 +5,7 @@ import { logger } from '@/lib/logger'; import { PAGINATION, RATE_LIMITS } from '@/lib/constants'; import { getDb } from '@/db'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; +import { ensureRepoAccess } from '@/services/resource-access'; /** * GET /api/repos/:id/commits - List commits for a repository @@ -59,20 +59,7 @@ export async function GET( return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); } - // Access control: check KV-based access grant, but allow access if repo exists in DB - // This supports both multi-tenant (with KV) and single-user (DB-only) deployments - try { - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - // No access grant - try to create one, but don't block access - const { safeGrantRepoAccess } = await import('@/services/resource-access'); - await safeGrantRepoAccess(repoId, session.sessionId); - requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); - } - } catch { - // Access control system unavailable - allow access to existing repos - requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); // Run count and data fetch in parallel const [totalResult, repoCommits] = await Promise.all([ diff --git a/src/app/api/repos/[id]/compare/route.ts b/src/app/api/repos/[id]/compare/route.ts index 4af5efb..bb8005b 100644 --- a/src/app/api/repos/[id]/compare/route.ts +++ b/src/app/api/repos/[id]/compare/route.ts @@ -5,7 +5,7 @@ import { getDb } from '@/db'; import { logger } from '@/lib/logger'; import { RATE_LIMITS, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; -import { hasRepoAccess } from '@/services/resource-access'; +import { ensureRepoAccess } from '@/services/resource-access'; import { fetchCompareDiff } from '@/services/github'; import { isSafeFilePath } from '@/lib/sanitize'; @@ -34,11 +34,7 @@ export async function GET( const { id } = await params; const repoId = id; - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Forbidden repository access'); - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + await ensureRepoAccess(repoId, session.sessionId, requestLogger); const baseSha = request.nextUrl.searchParams.get('base')?.trim() || ''; const headSha = request.nextUrl.searchParams.get('head')?.trim() || ''; diff --git a/src/app/api/repos/branches/route.ts b/src/app/api/repos/branches/route.ts index 440de03..9d7ce01 100644 --- a/src/app/api/repos/branches/route.ts +++ b/src/app/api/repos/branches/route.ts @@ -7,12 +7,14 @@ import { resolveSession, } from '@/lib/api-security'; import { RATE_LIMITS } from '@/lib/constants'; +import { logger } from '@/lib/logger'; /** * GET /api/repos/branches?url= * Returns the list of branches and the default branch for a public repository. */ export async function GET(request: NextRequest) { + const requestLogger = logger.child({ endpoint: 'GET /api/repos/branches' }); const { searchParams } = new URL(request.url); const rawUrl = searchParams.get('url'); @@ -39,7 +41,7 @@ export async function GET(request: NextRequest) { const result = await fetchRepoBranches(owner, repo); return applyPrivateNoStoreHeaders(NextResponse.json(result)); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to fetch branches'; - return NextResponse.json({ error: message }, { status: 400 }); + requestLogger.error({ error: err }, 'Failed to fetch branches'); + return NextResponse.json({ error: 'Failed to fetch branches' }, { status: 400 }); } } diff --git a/src/app/api/search/commits/route.ts b/src/app/api/search/commits/route.ts new file mode 100644 index 0000000..fa61547 --- /dev/null +++ b/src/app/api/search/commits/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { commits } from '@/db'; +import { eq } from 'drizzle-orm'; +import { generateText } from 'ai'; +import { createAIProviderAsync } from '@/services/ai-providers'; +import { logger } from '@/lib/logger'; +import { RATE_LIMITS } from '@/lib/constants'; +import { analytics } from '@/lib/analytics'; +import { getDb } from '@/db'; +import { applyPrivateNoStoreHeaders, guardRoute, getClientIdFromHeaders } from '@/lib/api-security'; +import { ensureRepoAccess } from '@/services/resource-access'; +import { resolveProviderConfigFromRequest } from '../../explain/utils'; +import { z } from 'zod'; +import { clientProviderSchema } from '@/lib/validation'; + +const searchCommitsSchema = z.object({ + repoId: z.string().min(1), + query: z.string().min(1).max(500), + provider: clientProviderSchema, +}); + +const MAX_COMMITS = 300; + +export async function POST(request: NextRequest) { + const requestLogger = logger.child({ endpoint: 'POST /api/search/commits' }); + const startTime = Date.now(); + + try { + const guard = await guardRoute(request, { + rateLimit: { keyPrefix: 'api:search:commits', limit: RATE_LIMITS.EXPLAIN_API }, + analytics: { endpoint: '/api/search/commits' }, + }); + if (!guard.ok) return guard.response; + const { session, clientId } = guard; + + const rawBody = await request.json().catch(() => null); + const parseResult = searchCommitsSchema.safeParse(rawBody); + if (!parseResult.success) { + return NextResponse.json({ error: 'Validation failed', details: parseResult.error.issues }, { status: 400 }); + } + + const { repoId, query, provider } = parseResult.data; + + await ensureRepoAccess(repoId, session.sessionId, requestLogger); + + const providerConfig = await resolveProviderConfigFromRequest(request, provider, session.sessionId); + + const db = getDb(); + const allCommits = await db + .select({ sha: commits.sha, message: commits.message, authorName: commits.authorName }) + .from(commits) + .where(eq(commits.repoId, repoId)) + .limit(MAX_COMMITS); + + if (allCommits.length === 0) { + return applyPrivateNoStoreHeaders(NextResponse.json({ shas: [] })); + } + + const commitList = allCommits + .map((c, i) => `${i + 1}. [${c.sha.slice(0, 7)}] ${c.message.split('\n')[0].slice(0, 120)}${c.authorName ? ` (${c.authorName})` : ''}`) + .join('\n'); + + const aiModel = await createAIProviderAsync(providerConfig); + + const { text } = await generateText({ + model: aiModel, + system: `You are a commit search engine. When given a list of git commits and a search query, you identify which commits semantically match what the user is looking for — even if they use different words. + +Return ONLY a raw JSON array of 7-character SHA strings for the matching commits, ordered by relevance (best match first). No explanation, no markdown, no extra text. Just the JSON array. + +Example output: ["abc1234","def5678"] +If nothing matches, return: []`, + prompt: `Search query: "${query.replace(/"/g, '\\"')}"\n\nCommits:\n${commitList}`, + maxOutputTokens: 500, + }); + + let shas: string[] = []; + try { + const match = text.match(/\[[\s\S]*?\]/); + if (match) { + const parsed = JSON.parse(match[0]); + if (Array.isArray(parsed)) { + shas = parsed.filter((s): s is string => typeof s === 'string' && /^[0-9a-f]{7,}$/i.test(s)); + } + } + } catch (err) { + requestLogger.warn({ text, err }, 'Failed to parse AI response as JSON'); + } + + const duration = Date.now() - startTime; + await analytics.trackAIUsage({ provider: providerConfig.type, model: providerConfig.model, type: 'question', success: true, duration }); + await analytics.trackRequest({ endpoint: '/api/search/commits', method: 'POST', statusCode: 200, duration, clientId }); + + return applyPrivateNoStoreHeaders(NextResponse.json({ shas })); + } catch (error) { + const duration = Date.now() - startTime; + const clientId = getClientIdFromHeaders(request); + await analytics.trackRequest({ endpoint: '/api/search/commits', method: 'POST', statusCode: 500, duration, clientId }); + requestLogger.error({ error }, 'Error searching commits'); + return NextResponse.json({ error: 'Failed to search commits' }, { status: 500 }); + } +} diff --git a/src/app/explore/[id]/explore.module.css b/src/app/explore/[id]/explore.module.css index 4fb018d..898477a 100644 --- a/src/app/explore/[id]/explore.module.css +++ b/src/app/explore/[id]/explore.module.css @@ -113,7 +113,7 @@ border: 1px solid rgba(0, 112, 243, 0.2); border-radius: 999px; color: var(--accent-primary); - font-size: 0.68rem; + font-size: 0.72rem; font-family: var(--font-mono); font-weight: 500; cursor: pointer; @@ -223,7 +223,7 @@ } .branchMenuDefault { - font-size: 0.62rem; + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; @@ -291,7 +291,7 @@ } .chapterLabel { - font-size: 0.68rem; + font-size: 0.72rem; color: var(--accent-primary); font-weight: 600; font-family: var(--font-mono); @@ -373,23 +373,26 @@ display: flex; align-items: center; gap: 6px; - width: 100%; - height: 30px; - padding: 0 var(--space-md); - border: none; - background: transparent; - color: var(--text-muted); + width: calc(100% - 16px); + margin: 6px 8px; + height: 28px; + padding: 0 var(--space-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.03); + color: var(--text-secondary); font-size: 0.72rem; - font-weight: 500; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.06em; cursor: pointer; - transition: color var(--transition-fast), background var(--transition-fast); + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); } .sidebarFooterBtn:hover { - color: var(--text-secondary); - background: rgba(255, 255, 255, 0.03); + color: var(--text-primary); + background: rgba(47, 156, 255, 0.08); + border-color: rgba(47, 156, 255, 0.25); } /* ─── Commit Sort Bar ─────────────────────────────────────────────── */ @@ -423,48 +426,67 @@ background: rgba(0, 112, 243, 0.08); } -/* ─── Commit Strip (slim metadata bar in center panel) ────────────── */ -.commitStrip { - height: 30px; +/* ─── View Tabs (merged with commit meta) ─────────────────────────── */ +.viewTabs { display: flex; align-items: center; - gap: var(--space-md); + justify-content: space-between; padding: 0 var(--space-md); + height: 36px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); - flex-shrink: 0; background: var(--bg-primary); + flex-shrink: 0; +} + +.viewTabsLeft { + display: flex; + align-items: center; + gap: 2px; +} + +.commitMeta { + display: flex; + align-items: center; + gap: var(--space-sm); } .commitSha { display: flex; align-items: center; gap: 4px; - font-size: 0.75rem; + font-size: 0.72rem; color: var(--accent-primary); font-family: var(--font-mono); - background: rgba(0, 112, 243, 0.08); + background: rgba(47, 156, 255, 0.08); padding: 1px 6px; border-radius: 3px; - border: 1px solid rgba(0, 112, 243, 0.15); + border: 1px solid rgba(47, 156, 255, 0.15); } -.commitAuthor, -.commitDate { - display: flex; +.commitAuthor { + font-size: 0.72rem; + color: var(--text-muted); +} + +.commitDateBtn { + display: inline-flex; align-items: center; gap: 4px; - font-size: 0.75rem; - color: var(--text-muted); + font-size: 0.72rem; + font-family: inherit; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-sm); + padding: 2px 7px; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); } -/* ─── View Tabs ───────────────────────────────────────────────────── */ -.viewTabs { - display: flex; - gap: 2px; - padding: 6px var(--space-md); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - background: var(--bg-primary); - flex-shrink: 0; +.commitDateBtn:hover { + color: var(--text-primary); + background: rgba(47, 156, 255, 0.1); + border-color: rgba(47, 156, 255, 0.35); } .viewTab { @@ -486,8 +508,8 @@ } .viewTabActive { - background: rgba(0, 112, 243, 0.12); - border-color: rgba(0, 112, 243, 0.25); + background: rgba(47, 156, 255, 0.12); + border-color: rgba(47, 156, 255, 0.25); color: var(--accent-primary) !important; } @@ -639,7 +661,7 @@ display: flex; flex-direction: column; gap: 2px; - font-size: 0.64rem; + font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); @@ -744,7 +766,7 @@ align-items: center; padding: 1px 6px; border-radius: 3px; - font-size: 0.68rem; + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; @@ -842,7 +864,7 @@ .aiCollapsedLabel { writing-mode: vertical-rl; text-orientation: mixed; - font-size: 0.65rem; + font-size: 0.72rem; font-weight: 700; color: var(--text-muted); letter-spacing: 0.12em; @@ -882,7 +904,7 @@ } .panelTitle { - font-size: 0.68rem; + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; diff --git a/src/app/explore/[id]/page.tsx b/src/app/explore/[id]/page.tsx index 54ea506..54b78f5 100644 --- a/src/app/explore/[id]/page.tsx +++ b/src/app/explore/[id]/page.tsx @@ -7,10 +7,10 @@ import { ChevronLeft, ChevronRight, Home, + Search, Settings, Loader2, GitCommit, - User, Calendar, Maximize2, Minimize2, @@ -25,7 +25,7 @@ import CodeViewer from '@/components/CodeViewer'; import AIPanel from '@/components/AIPanel'; import FileTree from '@/components/FileTree'; import CommitHistoryModal from '@/components/CommitHistoryModal'; -import CommitTimeline from '@/components/CommitTimeline'; +import CommitSearchPalette from '@/components/CommitSearchPalette'; import DiffViewer from '@/components/DiffViewer'; import StoryModePanel from '@/components/StoryModePanel'; import { api } from '@/lib/api-client'; @@ -68,10 +68,17 @@ function useKeyboardNav( goNext: (n: number) => void, goPrev: () => void, setCenterView: (v: 'code' | 'diff' | 'story') => void, + setShowSearchPalette: (show: boolean) => void, blocked: boolean, ) { useEffect(() => { function handler(e: KeyboardEvent) { + // ⌘K / Ctrl+K opens search palette (always, even when blocked) + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setShowSearchPalette(true); + return; + } if (blocked) return; const tag = (e.target as HTMLElement).tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' @@ -84,7 +91,7 @@ function useKeyboardNav( } window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [blocked, commitsLength, goNext, goPrev, setCenterView]); + }, [blocked, commitsLength, goNext, goPrev, setCenterView, setShowSearchPalette]); } /** Persist & restore commit selection to URL + storage */ @@ -169,8 +176,6 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } currentIndex, setCurrentIndex, selectedFile, setSelectedFile, centerView, setCenterView, - sidebarTab, setSidebarTab, - commitOrder, setCommitOrder, diffScope, setDiffScope, diffViewMode, setDiffViewMode, focusMode, toggleFocusMode, @@ -178,7 +183,8 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } showSettings, setShowSettings, showHistoryModal, setShowHistoryModal, showBranchMenu, setShowBranchMenu, - pinnedBaseSha, setPinnedBaseSha, + showSearchPalette, setShowSearchPalette, + pinnedBaseSha, goToCommit, goNext, goPrev, reset: resetExploreStore, } = useExploreStore(); @@ -189,6 +195,7 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } }, [id, resetExploreStore]); // Local state (not shareable) + const [historyInitialDate, setHistoryInitialDate] = useState(null); const [switchingBranch, setSwitchingBranch] = useState(false); const [switchBranchJobId, setSwitchBranchJobId] = useState(null); const [syncing, setSyncing] = useState(false); @@ -274,10 +281,6 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } return atIdx !== -1 ? url.slice(0, atIdx) : url; }, [repository]); - const orderedCommits = useMemo( - () => commitOrder === 'asc' ? commits : [...commits].reverse(), - [commits, commitOrder] - ); // Compare SHAs — derived with useMemo, not synced via useEffect const defaultCompareBaseSha = useMemo(() => { @@ -356,6 +359,25 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } { enabled: centerView === 'diff' && diffScope === 'compare' && !!selectedFile } ); + // ── Skip empty commits on initial load ─────────────────── + // If the landing commit has no files, advance until we find one that does. + // The ref locks after the first commit-with-files is found so manual + // navigation to an empty commit later doesn't auto-jump the user away. + const foundFilesRef = useRef(false); + useEffect(() => { + if (foundFilesRef.current) return; + if (filesQuery.isLoading || filesQuery.isError) return; + if (files.length > 0) { + foundFilesRef.current = true; + return; + } + if (currentIndex < commits.length - 1) { + setCurrentIndex(currentIndex + 1); + } else { + foundFilesRef.current = true; // all commits empty, give up + } + }, [files, filesQuery.isLoading, filesQuery.isError, currentIndex, commits.length, setCurrentIndex]); + // ── Commit persistence ─────────────────────────────────── const { persist: persistCommit } = useCommitPersistence(commits, id, setCurrentIndex); @@ -378,7 +400,7 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } // ── DOM hooks (2 legitimate effects) ───────────────────── useClickOutside(branchMenuRef, showBranchMenu, useCallback(() => setShowBranchMenu(false), [setShowBranchMenu])); - useKeyboardNav(commits.length, goNext, goPrev, setCenterView, showSettings || showHistoryModal); + useKeyboardNav(commits.length, goNext, goPrev, setCenterView, setShowSearchPalette, showSettings || showHistoryModal || showSearchPalette); // ── File opening from AI references ────────────────────── const openFileFromAIReference = useCallback(async (path: string) => { @@ -407,49 +429,6 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } if (firstInDirectory) { selectFile(firstInDirectory); } }, [filePathMap, files, selectFile]); - const handleLoadOlder = useCallback(async () => { - if (!repository || commits.length === 0 || syncing) return; - setSyncing(true); - const oldestSha = commits[commits.length - 1].sha; - - try { - const data = await api.post<{ jobId?: string; cached?: boolean }>('/api/repos', { - url: `github.com/${repository.owner}/${repository.name}`, - branch: activeBranch || undefined, - startSha: oldestSha, - clearExisting: true - }); - - if (data.jobId) { - router.replace(`/explore/${id}?jobId=${data.jobId}`); - } - } catch (error) { - fireToast('Failed to load older commits', 'error'); - } finally { - setSyncing(false); - } - }, [repository, commits, activeBranch, id, router, syncing]); - - const handleLoadNewer = useCallback(async () => { - // To load newer commits, we just do a fresh sync without a startSha (starts from HEAD) - if (!repository || syncing) return; - setSyncing(true); - try { - const data = await api.post<{ jobId?: string; cached?: boolean }>('/api/repos', { - url: `github.com/${repository.owner}/${repository.name}`, - branch: activeBranch || undefined, - clearExisting: true - }); - - if (data.jobId) { - router.replace(`/explore/${id}?jobId=${data.jobId}`); - } - } catch (error) { - fireToast('Failed to load newer commits', 'error'); - } finally { - setSyncing(false); - } - }, [repository, activeBranch, id, router, syncing]); // ── Branch switching ───────────────────────────────────── const switchBranch = useCallback(async (branch: string) => { @@ -653,6 +632,13 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string }
+ - -
- {sidebarTab === 'commits' && ( -
- - -
- )}
- {sidebarTab === 'commits' ? ( - goToCommit(commitOrder === 'asc' ? displayIdx : commits.length - 1 - displayIdx, commits.length)} - pinnedBaseSha={pinnedBaseSha} - onPinAsBase={setPinnedBaseSha} - onLoadOlder={handleLoadOlder} - onLoadNewer={handleLoadNewer} - hasMoreOlder={commits.length >= 5000} - hasMoreNewer={true} // Allow jump to present - loadingCommits={syncing || !!ingestJobId} - /> - ) : loadingFiles ? ( + {loadingFiles ? (
) : ( )}
-
- -
)} {!focusMode && } -
-
- - {currentCommit.sha.substring(0, 7)} -
- - - {currentCommit.authorName || 'Unknown'} - - - - {new Date(currentCommit.date).toLocaleDateString()} - -
-
- - - +
+ + + +
+
+
+ + {currentCommit.sha.substring(0, 7)} +
+ + {currentCommit.authorName || 'Unknown'} + + +
@@ -1002,14 +941,24 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } {showHistoryModal && ( setShowHistoryModal(false)} + onClose={() => { setShowHistoryModal(false); setHistoryInitialDate(null); }} commits={commits} currentIndex={currentIndex} onSelectCommit={(idx) => goToCommit(idx, commits.length)} + initialDate={historyInitialDate} /> )} setShowSettings(false)} /> + + setShowSearchPalette(false)} + commits={commits} + repoId={id} + currentIndex={currentIndex} + onSelectCommit={(idx) => goToCommit(idx, commits.length)} + />
); } diff --git a/src/app/explore/[id]/timeline/timeline.module.css b/src/app/explore/[id]/timeline/timeline.module.css index 559b5ad..c451290 100644 --- a/src/app/explore/[id]/timeline/timeline.module.css +++ b/src/app/explore/[id]/timeline/timeline.module.css @@ -105,7 +105,7 @@ .overviewLabel { display: block; color: #90a6be; - font-size: 0.68rem; + font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; diff --git a/src/app/globals.css b/src/app/globals.css index 659c99c..1b3810a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,27 +1,27 @@ /* Grepbase Design System */ :root { - /* Colors - Vercel Black/White/Blue */ - --bg-primary: #000000; - --bg-secondary: #111111; - --bg-tertiary: #222222; - --bg-elevated: #333333; + /* Colors */ + --bg-primary: #070a10; + --bg-secondary: #0d1117; + --bg-tertiary: #161b22; + --bg-elevated: #21262d; - --text-primary: #ffffff; - --text-secondary: #888888; - --text-muted: #666666; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; - --accent-primary: #0070f3; - --accent-secondary: #00a4ff; - --accent-gradient: linear-gradient(135deg, #0070f3 0%, #00a4ff 100%); + --accent-primary: #2f9cff; + --accent-secondary: #58b8ff; + --accent-gradient: linear-gradient(135deg, #2f9cff 0%, #58b8ff 100%); - --success: #0070f3; + --success: #3fb950; --warning: #f5a623; --error: #ee0000; - --border-subtle: #333333; - --border-default: #444444; - --border-strong: #666666; + --border-subtle: #21262d; + --border-default: #30363d; + --border-strong: #484f58; /* Glass effect - Subtler for Vercel */ --glass-bg: rgba(255, 255, 255, 0.05); @@ -73,22 +73,6 @@ body { min-height: 100vh; } -/* Background grid effect (Vercel style) */ -body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-image: - linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); - background-size: 50px 50px; - pointer-events: none; - z-index: -1; - opacity: 0.5; -} /* Typography */ h1, diff --git a/src/app/page.module.css b/src/app/page.module.css index 857a845..5d62c71 100644 --- a/src/app/page.module.css +++ b/src/app/page.module.css @@ -29,7 +29,7 @@ } .eyebrow { - font-size: 0.68rem; + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; @@ -46,7 +46,7 @@ } .recentLabel { - font-size: 0.65rem; + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; @@ -93,7 +93,11 @@ left: 0; right: 0; bottom: 0; - background: radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.05), transparent 70%); + background-image: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px), + radial-gradient(circle at 50% -20%, rgba(47, 156, 255, 0.07), transparent 70%); + background-size: 50px 50px, 50px 50px, 100% 100%; pointer-events: none; } diff --git a/src/components/AIPanel.module.css b/src/components/AIPanel.module.css index ac85d9c..4907288 100644 --- a/src/components/AIPanel.module.css +++ b/src/components/AIPanel.module.css @@ -8,8 +8,9 @@ display: flex; align-items: center; gap: var(--space-sm); - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-subtle); + height: 36px; + padding: 0 var(--space-md); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); font-weight: 600; flex-shrink: 0; } @@ -39,7 +40,7 @@ display: flex; align-items: center; justify-content: center; - background: rgba(99, 102, 241, 0.15); + background: rgba(47, 156, 255, 0.12); border-radius: 50%; margin-bottom: var(--space-lg); color: var(--accent-primary); @@ -146,7 +147,7 @@ align-items: center; gap: var(--space-sm); padding: var(--space-md); - border-top: 1px solid var(--border-subtle); + border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; } diff --git a/src/components/BranchPicker.module.css b/src/components/BranchPicker.module.css index 4481d78..b4d0e70 100644 --- a/src/components/BranchPicker.module.css +++ b/src/components/BranchPicker.module.css @@ -158,7 +158,7 @@ } .defaultBadge { - font-size: 0.65rem; + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; diff --git a/src/components/CalendarTimeline.module.css b/src/components/CalendarTimeline.module.css index a1693da..600c02a 100644 --- a/src/components/CalendarTimeline.module.css +++ b/src/components/CalendarTimeline.module.css @@ -114,7 +114,7 @@ .weekday { text-align: center; - font-size: 0.68rem; + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; diff --git a/src/components/CodeViewer.module.css b/src/components/CodeViewer.module.css index 5ff9d1e..47fe95e 100644 --- a/src/components/CodeViewer.module.css +++ b/src/components/CodeViewer.module.css @@ -14,8 +14,8 @@ height: 100%; display: flex; flex-direction: column; - border: 1px solid var(--viewer-border); - border-radius: clamp(12px, 2.4vw, 18px); + border: none; + border-radius: 0; background: radial-gradient(circle at 86% 12%, rgba(61, 217, 255, 0.22), rgba(61, 217, 255, 0) 34%), linear-gradient(160deg, var(--viewer-bg-soft) 0%, var(--viewer-bg) 62%, #060913 100%); @@ -87,7 +87,7 @@ border-radius: 999px; text-transform: uppercase; letter-spacing: 0.08em; - font-size: 0.66rem; + font-size: 0.72rem; color: #e4f6ff; background: rgba(40, 184, 255, 0.22); border: 1px solid rgba(40, 184, 255, 0.34); @@ -188,6 +188,10 @@ white-space: pre; } +.wrapLines { + min-width: unset; +} + .wrapLines .lineContent { white-space: pre-wrap; word-break: break-word; diff --git a/src/components/CommitHistoryModal.module.css b/src/components/CommitHistoryModal.module.css index 065a78c..a4b701a 100644 --- a/src/components/CommitHistoryModal.module.css +++ b/src/components/CommitHistoryModal.module.css @@ -128,7 +128,7 @@ border-radius: var(--radius-sm); height: 26px; padding: 0 8px; - font-size: 0.68rem; + font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; diff --git a/src/components/CommitHistoryModal.tsx b/src/components/CommitHistoryModal.tsx index b848160..d776f25 100644 --- a/src/components/CommitHistoryModal.tsx +++ b/src/components/CommitHistoryModal.tsx @@ -12,6 +12,7 @@ interface CommitHistoryModalProps { commits: Commit[]; currentIndex: number; onSelectCommit: (index: number) => void; + initialDate?: Date | null; } export default function CommitHistoryModal({ @@ -19,9 +20,10 @@ export default function CommitHistoryModal({ onClose, commits, currentIndex, - onSelectCommit + onSelectCommit, + initialDate = null, }: CommitHistoryModalProps) { - const [selectedDate, setSelectedDate] = useState(null); + const [selectedDate, setSelectedDate] = useState(initialDate); const filteredCommits = useMemo(() => { if (!selectedDate) return commits; @@ -102,7 +104,11 @@ export default function CommitHistoryModal({
- {filteredCommits.length > 0 ? ( + {!selectedDate ? ( +
+ Select a day to see its commits. +
+ ) : filteredCommits.length > 0 ? ( void; + commits: Commit[]; + repoId: string; + currentIndex: number; + onSelectCommit: (index: number) => void; +} + +type ResultSource = 'ai' | 'text'; + +interface SearchResult { + commit: Commit; + globalIndex: number; + source: ResultSource; +} + +function textMatch(commits: Commit[], query: string): SearchResult[] { + const q = query.trim().toLowerCase(); + if (!q) return []; + return commits + .map((commit, i) => ({ commit, globalIndex: i })) + .filter(({ commit }) => + commit.message.toLowerCase().includes(q) || + commit.sha.startsWith(q) || + (commit.authorName ?? '').toLowerCase().includes(q) + ) + .slice(0, 30) + .map(r => ({ ...r, source: 'text' as ResultSource })); +} + +export default function CommitSearchPalette({ + isOpen, + onClose, + commits, + repoId, + currentIndex, + onSelectCommit, +}: CommitSearchPaletteProps) { + const [query, setQuery] = useState(''); + const [activeIdx, setActiveIdx] = useState(0); + // null = not yet run, [] = ran but empty, [...] = results + const [aiResults, setAiResults] = useState(null); + const [aiLoading, setAiLoading] = useState(false); + const [aiError, setAiError] = useState(null); + const inputRef = useRef(null); + const listRef = useRef(null); + const abortRef = useRef(null); + + // Reset and focus on open + useEffect(() => { + if (isOpen) { + setQuery(''); + setAiResults(null); + setAiLoading(false); + setAiError(null); + setActiveIdx(0); + setTimeout(() => inputRef.current?.focus(), 0); + } else { + abortRef.current?.abort(); + } + }, [isOpen]); + + // Reset AI results when query changes so text results show immediately + useEffect(() => { + setAiResults(null); + setAiLoading(false); + setAiError(null); + abortRef.current?.abort(); + }, [query]); + + const textResults = useMemo(() => textMatch(commits, query), [commits, query]); + + // While AI is running, show text results underneath. Once AI returns, show those. + const results: SearchResult[] = aiResults ?? textResults; + const isAiMode = aiResults !== null && !aiLoading; + + const runAISearch = useCallback(async (q: string) => { + const settings = getAISettings(); + if (!settings || !q.trim()) return; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setAiLoading(true); + setAiError(null); + setAiResults(null); + + try { + const data = await api.post<{ shas: string[] }>('/api/search/commits', { + repoId, + query: q.trim(), + provider: { + type: settings.provider, + baseUrl: settings.config.baseUrl, + model: settings.config.model, + }, + }); + + if (controller.signal.aborted) return; + + const shaSet = new Map(commits.map((c, i) => [c.sha.slice(0, 7), i])); + const matched: SearchResult[] = []; + for (const sha of data.shas) { + const idx = shaSet.get(sha.slice(0, 7)); + if (idx !== undefined) { + matched.push({ commit: commits[idx], globalIndex: idx, source: 'ai' }); + } + } + setAiResults(matched); + setActiveIdx(0); + } catch (err) { + if (controller.signal.aborted) return; + setAiError(err instanceof Error ? err.message : 'AI search failed'); + } finally { + if (!controller.signal.aborted) setAiLoading(false); + } + }, [commits, repoId]); + + // Keep active item in view + useEffect(() => { + const list = listRef.current; + if (!list) return; + const item = list.children[activeIdx] as HTMLElement | undefined; + item?.scrollIntoView({ block: 'nearest' }); + }, [activeIdx]); + + const handleSelect = useCallback((result: SearchResult) => { + onSelectCommit(result.globalIndex); + onClose(); + }, [onSelectCommit, onClose]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return; } + + // ⌘↵ / Ctrl+↵ → trigger AI search explicitly + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (query.trim().length >= 2) runAISearch(query); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx(i => Math.min(i + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + if (results[activeIdx]) handleSelect(results[activeIdx]); + } + }, [activeIdx, query, results, handleSelect, runAISearch, onClose]); + + const hasAIConfigured = !!getAISettings(); + + if (!isOpen) return null; + + const isEmpty = query.trim().length > 0 && !aiLoading && results.length === 0; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ + {/* Input row */} +
+ {aiLoading + ? + : isAiMode + ? + : + } + { + setQuery(e.target.value); + setActiveIdx(0); + }} + onKeyDown={handleKeyDown} + spellCheck={false} + autoComplete="off" + /> + {aiLoading && ( + Searching… + )} + {!aiLoading && isAiMode && ( + + AI + + )} + {hasAIConfigured && !aiLoading && !isAiMode && query.trim().length >= 2 && ( + + )} + +
+ + {/* AI running — text results still visible below with a banner */} + {aiLoading && textResults.length > 0 && ( +
+ + Searching with AI — showing text matches while you wait +
+ )} + {aiLoading && textResults.length === 0 && ( +
+ + Searching with AI… +
+ )} + + {/* Error banner */} + {aiError && ( +
+ + {aiError} — showing text results +
+ )} + + {/* Results */} + {query.trim().length === 0 ? ( +
+ +

Search commits by message, SHA, or author

+ {hasAIConfigured && ( +

Press ⌘↵ to search with AI in plain English

+ )} +
+ ) : isEmpty ? ( +
No commits match “{query}”
+ ) : ( +
    + {results.map((result, i) => { + const { commit, globalIndex, source } = result; + const isActive = i === activeIdx; + const isCurrent = globalIndex === currentIndex; + return ( +
  • setActiveIdx(i)} + onMouseDown={() => handleSelect(result)} + > + #{globalIndex + 1} + {source === 'ai' + ? + : + } + + {commit.message.split('\n')[0]} + + + {commit.sha.slice(0, 7)} + {commit.authorName && ( + {commit.authorName} + )} + +
  • + ); + })} +
+ )} + + {/* Footer */} +
+ ↑↓ navigate + jump + {hasAIConfigured + ? ⌘↵ search with AI + : Configure AI in settings for semantic search + } + esc close +
+
+
+ ); +} diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index a593891..293a7fe 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -2,7 +2,11 @@ import { describe, test, expect } from 'bun:test'; import { githubUrlSchema, aiProviderConfigSchema, - explainRequestSchema, + explainCommitSchema, + explainQuestionSchema, + explainProjectSchema, + explainDaySummarySchema, + explainStorySchema, ingestRepoSchema, } from '../validation'; @@ -23,9 +27,9 @@ describe('validation', () => { test('rejects invalid GitHub URLs', () => { const invalidUrls = [ - 'https://gitlab.com/owner/repo', // Not GitHub + 'https://gitlab.com/owner/repo', 'not-a-url', - 'https://github.com/owner', // Missing repo + 'https://github.com/owner', '', ]; @@ -38,9 +42,7 @@ describe('validation', () => { describe('aiProviderConfigSchema', () => { test('validates cloud provider without API key', () => { - const result = aiProviderConfigSchema.safeParse({ - type: 'openai', - }); + const result = aiProviderConfigSchema.safeParse({ type: 'openai' }); expect(result.success).toBe(true); }); @@ -53,92 +55,133 @@ describe('validation', () => { }); test('rejects invalid provider type', () => { - const result = aiProviderConfigSchema.safeParse({ - type: 'invalid-provider', - apiKey: 'test', - }); + const result = aiProviderConfigSchema.safeParse({ type: 'invalid-provider' }); expect(result.success).toBe(false); }); }); - describe('explainRequestSchema', () => { + describe('explainCommitSchema', () => { test('validates commit explanation request', () => { - const result = explainRequestSchema.safeParse({ + const result = explainCommitSchema.safeParse({ type: 'commit', - repoId: 1, + repoId: 'repo-123', commitSha: 'abc1234', - provider: { - type: 'openai', - }, + provider: { type: 'openai' }, }); expect(result.success).toBe(true); }); + test('rejects missing commitSha', () => { + const result = explainCommitSchema.safeParse({ + type: 'commit', + repoId: 'repo-123', + provider: { type: 'openai' }, + }); + expect(result.success).toBe(false); + }); + + test('rejects client-sent apiKey in provider', () => { + const result = explainCommitSchema.safeParse({ + type: 'commit', + repoId: 'repo-123', + commitSha: 'abc1234', + provider: { type: 'openai', apiKey: 'sk-test' }, + }); + expect(result.success).toBe(false); + }); + + test('rejects empty repoId', () => { + const result = explainCommitSchema.safeParse({ + type: 'commit', + repoId: '', + commitSha: 'abc1234', + provider: { type: 'openai' }, + }); + expect(result.success).toBe(false); + }); + }); + + describe('explainProjectSchema', () => { test('validates project explanation request', () => { - const result = explainRequestSchema.safeParse({ + const result = explainProjectSchema.safeParse({ type: 'project', - repoId: 1, - provider: { - type: 'anthropic', - }, + repoId: 'repo-123', + provider: { type: 'anthropic' }, }); expect(result.success).toBe(true); }); + test('rejects missing provider', () => { + const result = explainProjectSchema.safeParse({ + type: 'project', + repoId: 'repo-123', + }); + expect(result.success).toBe(false); + }); + }); + + describe('explainStorySchema', () => { test('validates story mode request', () => { - const result = explainRequestSchema.safeParse({ + const result = explainStorySchema.safeParse({ type: 'story', - repoId: 1, + repoId: 'repo-123', startSha: 'abc1234', endSha: 'def5678', chapterSize: 4, - provider: { - type: 'openai', - }, + provider: { type: 'openai' }, }); expect(result.success).toBe(true); }); - test('rejects commit request without sha', () => { - const result = explainRequestSchema.safeParse({ - type: 'commit', - repoId: 1, - provider: { - type: 'openai', - }, + test('rejects invalid chapterSize', () => { + const result = explainStorySchema.safeParse({ + type: 'story', + repoId: 'repo-123', + chapterSize: 1, + provider: { type: 'openai' }, }); expect(result.success).toBe(false); }); + }); - test('rejects invalid repoId', () => { - const result = explainRequestSchema.safeParse({ - type: 'project', - repoId: -1, - provider: { - type: 'openai', - }, + describe('explainQuestionSchema', () => { + test('validates question request', () => { + const result = explainQuestionSchema.safeParse({ + type: 'question', + repoId: 'repo-123', + question: 'What does this code do?', + provider: { type: 'openai' }, }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); - test('rejects client-sent API key in nested provider config', () => { - const result = explainRequestSchema.safeParse({ - type: 'project', - repoId: 1, - provider: { - type: 'openai', - apiKey: 'sk-test', - }, + test('rejects missing question', () => { + const result = explainQuestionSchema.safeParse({ + type: 'question', + repoId: 'repo-123', + provider: { type: 'openai' }, }); expect(result.success).toBe(false); }); + }); - test('rejects client-sent API key in flat payload', () => { - const result = explainRequestSchema.safeParse({ - type: 'project', - repoId: 1, - providerType: 'openai', - apiKey: 'sk-test', + describe('explainDaySummarySchema', () => { + test('validates day-summary request', () => { + const result = explainDaySummarySchema.safeParse({ + type: 'day-summary', + repoId: 'repo-123', + commits: [{ sha: 'abc1234', message: 'fix bug', authorName: 'Alice', date: '2024-01-01' }], + provider: { type: 'openai' }, + }); + expect(result.success).toBe(true); + }); + + test('rejects empty commits array', () => { + const result = explainDaySummarySchema.safeParse({ + type: 'day-summary', + repoId: 'repo-123', + commits: [], + provider: { type: 'openai' }, }); expect(result.success).toBe(false); }); @@ -152,16 +195,6 @@ describe('validation', () => { expect(result.success).toBe(true); }); - test('applies default branch', () => { - const result = ingestRepoSchema.safeParse({ - url: 'https://github.com/facebook/react', - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.branch).toBe('main'); - } - }); - test('accepts custom branch', () => { const result = ingestRepoSchema.safeParse({ url: 'https://github.com/facebook/react', diff --git a/src/lib/api-security.ts b/src/lib/api-security.ts index 371bc26..5f55752 100644 --- a/src/lib/api-security.ts +++ b/src/lib/api-security.ts @@ -6,6 +6,7 @@ import { issueCredentialSessionToken, resolveCredentialSessionId, } from '@/services/ai-credentials'; +import { analytics } from '@/lib/analytics'; export const CSRF_HEADER = 'x-grepbase-csrf'; export const CSRF_HEADER_VALUE = '1'; @@ -89,6 +90,19 @@ export function applyPrivateNoStoreHeaders(response: T): T { return response; } +export function getClientIdFromHeaders(req: NextRequest): string { + const cfConnectingIp = req.headers.get('cf-connecting-ip'); + if (cfConnectingIp) return cfConnectingIp; + + const xForwardedFor = req.headers.get('x-forwarded-for'); + if (xForwardedFor) return xForwardedFor.split(',')[0].trim(); + + const xRealIp = req.headers.get('x-real-ip'); + if (xRealIp) return xRealIp; + + return 'unknown'; +} + export async function enforceRateLimit( request: NextRequest, options: { keyPrefix: string; limit: number; windowSeconds?: number; sessionId?: string } @@ -121,3 +135,44 @@ export async function enforceRateLimit( ), }; } + +export type GuardResult = + | { ok: false; response: NextResponse } + | { ok: true; session: SessionResolutionResult; clientId: string }; + +/** + * Run standard API guards (CSRF + session + rate limit + analytics) for POST routes. + * Returns { ok: false, response } on any failure, or { ok: true, session, clientId } on success. + */ +export async function guardRoute( + request: NextRequest, + options: { + rateLimit: { keyPrefix: string; limit: number }; + analytics: { endpoint: string }; + } +): Promise { + const csrfError = enforceCsrfProtection(request); + if (csrfError) return { ok: false, response: csrfError }; + + const session = await resolveSession(request); + if (!session) { + return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + + const clientId = getClientIdFromHeaders(request); + + const rateLimitError = await enforceRateLimit(request, { + keyPrefix: options.rateLimit.keyPrefix, + limit: options.rateLimit.limit, + sessionId: session.sessionId, + }); + + if (rateLimitError) { + await analytics.trackRateLimit({ endpoint: options.analytics.endpoint, clientId, blocked: true }); + return { ok: false, response: rateLimitError.response }; + } + + await analytics.trackRateLimit({ endpoint: options.analytics.endpoint, clientId, blocked: false }); + + return { ok: true, session, clientId }; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index b073c74..3106258 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -31,17 +31,6 @@ export const RATE_LIMITS = { GENERAL_API: 60, } as const; -// Cache TTLs (in seconds) -export const CACHE_TTL = { - MINUTE: 60, - HOUR: 3600, - DAY: 86400, - WEEK: 604800, - COMMIT_EXPLANATION: 604800, // 1 week - PROJECT_SUMMARY: 86400, // 1 day - FILE_CONTENT: 3600, // 1 hour -} as const; - // Tiered Cache TTLs - different expiration based on data volatility export const CACHE_TIER = { // Volatile - changes frequently (PRs, issues, events, recent commits) @@ -95,33 +84,3 @@ export const JOB_RETRY = { MAX_RETRIES: 3, } as const; -// File extensions recognized as source code (for content fetching/display) -export const CODE_EXTENSIONS = new Set([ - '.js', '.jsx', '.ts', '.tsx', '.py', '.rs', '.go', '.java', - '.cpp', '.c', '.h', '.hpp', '.rb', '.php', '.swift', '.kt', - '.md', '.json', '.yaml', '.yml', '.toml', '.css', '.scss', - '.html', '.xml', '.sql', '.sh', '.bash', -]); - -// Maximum file size for content fetching (100KB) -export const MAX_FILE_SIZE = 100_000; - -export function getFileExtension(path: string): string { - const ext = path.split('.').pop(); - return ext ? `.${ext.toLowerCase()}` : ''; -} - -export function isCodeFilePath(path: string): boolean { - return CODE_EXTENSIONS.has(getFileExtension(path)); -} - -export function shouldFetchFileContent(path: string, size: number | null | undefined): boolean { - return isCodeFilePath(path) && Number(size || 0) <= MAX_FILE_SIZE; -} - -export function shouldFailOpen(envOverride?: string): boolean { - if (envOverride && envOverride !== 'false') { - return true; - } - return process.env.NODE_ENV !== 'production'; -} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..a49cf03 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,6 @@ +export function shouldFailOpen(envOverride?: string): boolean { + if (envOverride && envOverride !== 'false') { + return true; + } + return process.env.NODE_ENV !== 'production'; +} diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts new file mode 100644 index 0000000..f4a162c --- /dev/null +++ b/src/lib/file-utils.ts @@ -0,0 +1,23 @@ +// File extensions recognized as source code +export const CODE_EXTENSIONS = new Set([ + '.js', '.jsx', '.ts', '.tsx', '.py', '.rs', '.go', '.java', + '.cpp', '.c', '.h', '.hpp', '.rb', '.php', '.swift', '.kt', + '.md', '.json', '.yaml', '.yml', '.toml', '.css', '.scss', + '.html', '.xml', '.sql', '.sh', '.bash', +]); + +// Maximum file size for content fetching (100KB) +export const MAX_FILE_SIZE = 100_000; + +export function getFileExtension(path: string): string { + const ext = path.split('.').pop(); + return ext ? `.${ext.toLowerCase()}` : ''; +} + +export function isCodeFilePath(path: string): boolean { + return CODE_EXTENSIONS.has(getFileExtension(path)); +} + +export function shouldFetchFileContent(path: string, size: number | null | undefined): boolean { + return isCodeFilePath(path) && Number(size || 0) <= MAX_FILE_SIZE; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index e8d90f5..01b04ce 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -3,7 +3,7 @@ */ import { getPlatformEnv } from './platform/context'; import { logger } from './logger'; -import { shouldFailOpen } from './constants'; +import { shouldFailOpen } from './env'; import type { PlatformCache } from './platform/types'; interface RateLimitResult { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 3ca26a6..75fd32f 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -2,11 +2,11 @@ * Zod validation schemas for API routes */ import { z } from 'zod'; +import { COMMIT_SHA_REGEX } from '@/lib/constants'; // GitHub URL validation - normalizes and validates GitHub repo URLs export const githubUrlSchema = z.string() .transform((input) => { - // Normalize: add https:// if missing let url = input.trim(); if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `https://${url}`; @@ -25,11 +25,11 @@ export const githubUrlSchema = z.string() { message: 'Must be a valid GitHub repository URL' } ); -// AI Provider Configuration +// AI Provider Configuration (for internal use — includes apiKey for stored credentials) export const aiProviderConfigSchema = z.object({ type: z.enum(['gemini', 'openai', 'anthropic', 'ollama', 'lmstudio', 'glm', 'kimi']), apiKey: z.string().optional(), - baseUrl: z.url().optional(), + baseUrl: z.string().url().optional(), model: z.string().max(100).optional(), }); @@ -37,60 +37,60 @@ export const aiProviderConfigSchema = z.object({ export const aiProviderTypeSchema = z.enum(['gemini', 'openai', 'anthropic', 'ollama', 'lmstudio', 'glm', 'kimi']); export type AIProviderTypeFromSchema = z.infer; -// Explain API request -export const explainRequestSchema = z.object({ - type: z.enum(['commit', 'project', 'question', 'day-summary', 'story']), +// Provider schema for client-facing requests — apiKey is never accepted from the client +export const clientProviderSchema = z.object({ + type: aiProviderTypeSchema, + baseUrl: z.string().url().optional(), + model: z.string().max(100).optional(), +}).strict(); + +// Base fields shared by all explain requests +const explainBase = z.object({ repoId: z.string().min(1), - commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i, 'Invalid commit SHA format').optional(), - startSha: z.string().regex(/^[0-9a-f]{7,64}$/i, 'Invalid commit SHA format').optional(), - endSha: z.string().regex(/^[0-9a-f]{7,64}$/i, 'Invalid commit SHA format').optional(), - chapterSize: z.number().int().min(2).max(12).optional(), - question: z.string().max(5000).optional(), + provider: clientProviderSchema, +}); + +export const explainCommitSchema = explainBase.extend({ + type: z.literal('commit'), + commitSha: z.string().regex(COMMIT_SHA_REGEX, 'Invalid commit SHA format'), visibleFiles: z.array(z.string().max(1024)).max(200).optional(), - provider: aiProviderConfigSchema.optional(), - // Flat params for backward compatibility - providerType: aiProviderTypeSchema.optional(), +}); + +export const explainQuestionSchema = explainBase.extend({ + type: z.literal('question'), + question: z.string().max(5000), + commitSha: z.string().regex(COMMIT_SHA_REGEX, 'Invalid commit SHA format').optional(), + visibleFiles: z.array(z.string().max(1024)).max(200).optional(), +}); + +export const explainProjectSchema = explainBase.extend({ + type: z.literal('project'), +}); + +export const explainDaySummarySchema = explainBase.extend({ + type: z.literal('day-summary'), commits: z.array(z.object({ sha: z.string(), message: z.string(), authorName: z.string().nullable(), date: z.string(), - })).max(200).optional(), + })).min(1).max(200), projectName: z.string().max(200).optional(), projectOwner: z.string().max(200).optional(), - apiKey: z.string().optional(), - model: z.string().max(100).optional(), - baseUrl: z.url().optional(), -}).refine( - (data) => { - if (data.type === 'commit') return !!data.commitSha; - if (data.type === 'question') return !!data.question; - if (data.type === 'day-summary') return !!data.commits && data.commits.length > 0; - return true; - }, - { message: 'Invalid request: missing required fields for type' } -).refine( - (data) => { - // Either nested provider OR flat providerType must be provided - return !!(data.provider?.type || data.providerType); - }, - { message: 'Either provider.type or providerType is required' } -).refine( - (data) => { - const nestedApiKey = data.provider?.apiKey; - const flatApiKey = data.apiKey; - const hasNestedApiKey = typeof nestedApiKey === 'string' && nestedApiKey.trim().length > 0; - const hasFlatApiKey = typeof flatApiKey === 'string' && flatApiKey.trim().length > 0; - return !hasNestedApiKey && !hasFlatApiKey; - }, - { message: 'Client API keys are not accepted in explain payloads. Store keys via /api/ai/credentials first.' } -); +}); + +export const explainStorySchema = explainBase.extend({ + type: z.literal('story'), + startSha: z.string().regex(COMMIT_SHA_REGEX, 'Invalid commit SHA format').optional(), + endSha: z.string().regex(COMMIT_SHA_REGEX, 'Invalid commit SHA format').optional(), + chapterSize: z.number().int().min(2).max(12).optional(), +}); // Repository ingest request export const ingestRepoSchema = z.object({ url: githubUrlSchema, branch: z.string().min(1).max(255).optional(), - startSha: z.string().regex(/^[0-9a-f]{7,64}$/i, 'Invalid commit SHA format').optional(), + startSha: z.string().regex(COMMIT_SHA_REGEX, 'Invalid commit SHA format').optional(), clearExisting: z.boolean().optional(), }); diff --git a/src/services/cache.ts b/src/services/cache.ts index 5945e68..f052e53 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -1,11 +1,11 @@ import { getPlatformEnv } from '@/lib/platform/context'; import { logger } from '@/lib/logger'; import type { PlatformCache } from '@/lib/platform/types'; -import { GITHUB, CACHE_TTL } from '@/lib/constants'; +import { GITHUB, CACHE_TIER } from '@/lib/constants'; const cacheLogger = logger.child({ service: 'cache' }); -export { CACHE_TTL }; +export { CACHE_TIER }; export class CacheService { private getKv(): PlatformCache | null { diff --git a/src/services/explain.ts b/src/services/explain.ts index 5885f88..07a7406 100644 --- a/src/services/explain.ts +++ b/src/services/explain.ts @@ -5,7 +5,8 @@ import { streamText } from 'ai'; import { createAIProviderAsync, type AIProviderConfig } from './ai-providers'; -import { cache, CACHE_TTL } from './cache'; +import { cache } from './cache'; +import { CACHE_TIER } from '@/lib/constants'; function sanitizePromptInput(text: string, maxLength: number): string { // Strip control characters except newlines and tabs @@ -169,7 +170,7 @@ ${commit.diff ? `**Diff:**\n\`\`\`diff\n${commit.diff.substring(0, 7000)}${commi prompt: userPrompt, maxOutputTokens: 1400, onFinish: ({ text }) => { - cache.set(cacheKey, text, CACHE_TTL.WEEK); + cache.set(cacheKey, text, CACHE_TIER.IMMUTABLE); }, }); @@ -222,7 +223,7 @@ ${file.content.substring(0, 8000)}${file.content.length > 8000 ? '\n// ... (trun prompt: userPrompt, maxOutputTokens: 1200, onFinish: ({ text }) => { - cache.set(cacheKey, text, CACHE_TTL.WEEK); + cache.set(cacheKey, text, CACHE_TIER.IMMUTABLE); }, }); @@ -269,7 +270,7 @@ Please explain: prompt: userPrompt, maxOutputTokens: 1500, onFinish: ({ text }) => { - cache.set(cacheKey, text, CACHE_TTL.DAY); // Project explanation might change more often? + cache.set(cacheKey, text, CACHE_TIER.SLOW); // Project explanation might change more often? }, }); @@ -337,7 +338,7 @@ Write a narrated walkthrough of this evolution.`; prompt: userPrompt, maxOutputTokens: 1800, onFinish: ({ text }) => { - cache.set(cacheKey, text, CACHE_TTL.DAY); + cache.set(cacheKey, text, CACHE_TIER.SLOW); }, }); @@ -395,7 +396,7 @@ ${contextText}`; prompt: question, maxOutputTokens: 800, onFinish: ({ text }) => { - cache.set(cacheKey, text, CACHE_TTL.WEEK); + cache.set(cacheKey, text, CACHE_TIER.IMMUTABLE); }, }); diff --git a/src/services/github.ts b/src/services/github.ts index b254dda..f4fb5a7 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -4,7 +4,7 @@ */ import { cache } from './cache'; -import { CACHE_TTL, GITHUB, TIMEOUTS } from '@/lib/constants'; +import { CACHE_TIER, GITHUB, TIMEOUTS } from '@/lib/constants'; import { logger } from '@/lib/logger'; import { getPlatformEnv } from '@/lib/platform/context'; @@ -200,7 +200,7 @@ export async function fetchRepository(owner: string, repo: string): Promise { + try { + const hasAccess = await hasRepoAccess(repoId, sessionId); + if (!hasAccess) { + await safeGrantRepoAccess(repoId, sessionId); + log?.info({ repoId, sessionId }, 'Auto-granted repository access'); + } + } catch { + log?.debug({ repoId }, 'Access control unavailable, allowing access to existing repo'); + } +} diff --git a/src/stores/explore-store.ts b/src/stores/explore-store.ts index f109f18..9f7df35 100644 --- a/src/stores/explore-store.ts +++ b/src/stores/explore-store.ts @@ -17,6 +17,7 @@ interface ExploreState { showSettings: boolean; showHistoryModal: boolean; showBranchMenu: boolean; + showSearchPalette: boolean; pinnedBaseSha: string | null; // Actions @@ -32,6 +33,7 @@ interface ExploreState { setShowSettings: (show: boolean) => void; setShowHistoryModal: (show: boolean) => void; setShowBranchMenu: (show: boolean) => void; + setShowSearchPalette: (show: boolean) => void; setPinnedBaseSha: (sha: string | null) => void; goToCommit: (index: number, totalCommits: number) => void; @@ -55,6 +57,7 @@ const initialState = { showSettings: false, showHistoryModal: false, showBranchMenu: false, + showSearchPalette: false, pinnedBaseSha: null as string | null, }; @@ -73,6 +76,7 @@ export const useExploreStore = create((set) => ({ setShowSettings: (show) => set({ showSettings: show }), setShowHistoryModal: (show) => set({ showHistoryModal: show }), setShowBranchMenu: (show) => set({ showBranchMenu: show }), + setShowSearchPalette: (show) => set({ showSearchPalette: show }), setPinnedBaseSha: (sha) => set({ pinnedBaseSha: sha }), goToCommit: (index, totalCommits) => {