diff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts index eabeb0a32..d1514c584 100644 --- a/packages/control-plane/src/auth/github-app.ts +++ b/packages/control-plane/src/auth/github-app.ts @@ -10,6 +10,7 @@ */ import type { InstallationRepository } from "@open-inspect/shared"; +import { createLogger } from "../logger"; /** Timeout for individual GitHub API requests (ms). */ export const GITHUB_FETCH_TIMEOUT_MS = 60_000; @@ -24,6 +25,7 @@ export const INSTALLATION_TOKEN_MIN_REMAINING_MS = 5 * 60 * 1000; export const INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600; const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v1"; +const logger = createLogger("github-app"); interface InstallationTokenCacheBindings { REPOS_CACHE?: KVNamespace; @@ -490,10 +492,41 @@ export async function listInstallationRepositories( } } + const roundedTokenGenerationMs = Math.round(tokenGenerationMs * 100) / 100; + const sampleRepos = allRepos.slice(0, 5).map((repo) => repo.fullName); + const installationContext = { + app_id: config.appId, + installation_id: config.installationId, + }; + + logger.info("github.installation_repos.listed", { + ...installationContext, + repository_selection: first.data.repository_selection, + total_count: totalCount, + total_pages: totalPages, + total_repos: allRepos.length, + token_generation_ms: roundedTokenGenerationMs, + page_timings: pageTiming, + sample_repos: sampleRepos, + }); + + if (allRepos.length === 0) { + logger.warn("github.installation_repos.empty", { + ...installationContext, + repository_selection: first.data.repository_selection, + total_count: totalCount, + total_pages: totalPages, + diagnosis: + first.data.repository_selection === "selected" + ? "installation_has_no_selected_repositories_or_wrong_installation_id" + : "installation_has_no_accessible_repositories", + }); + } + return { repos: allRepos, timing: { - tokenGenerationMs: Math.round(tokenGenerationMs * 100) / 100, + tokenGenerationMs: roundedTokenGenerationMs, pages: pageTiming, totalPages, totalRepos: allRepos.length, @@ -576,9 +609,11 @@ export async function listRepositoryBranches( const token = await getCachedInstallationToken(config, env); const branches: { name: string }[] = []; let page = 1; + const pageTimings: Array<{ page: number; fetchMs: number; branchCount: number }> = []; // Paginate through branches (100 per page, cap at 500) while (branches.length < 500) { + const pageStart = performance.now(); const response = await fetchWithTimeout( `https://api.github.com/repos/${owner}/${repo}/branches?per_page=100&page=${page}`, { @@ -598,11 +633,35 @@ export async function listRepositoryBranches( const data = (await response.json()) as { name: string }[]; branches.push(...data.map((b) => ({ name: b.name }))); + pageTimings.push({ + page, + fetchMs: Math.round((performance.now() - pageStart) * 100) / 100, + branchCount: data.length, + }); if (data.length < 100) break; page++; } + const branchSample = branches.slice(0, 10).map((branch) => branch.name); + logger.info("github.repository_branches.listed", { + app_id: config.appId, + installation_id: config.installationId, + repo_owner: owner, + repo_name: repo, + total_branches: branches.length, + page_timings: pageTimings, + sample_branches: branchSample, + }); + if (branches.length === 0) { + logger.warn("github.repository_branches.empty", { + app_id: config.appId, + installation_id: config.installationId, + repo_owner: owner, + repo_name: repo, + }); + } + return branches; } diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index 122bda25d..7e27fd1d1 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -51,7 +51,13 @@ async function refreshReposCache(env: Env, traceId?: string): Promise { logger.info("Repo fetch completed", { trace_id: traceId, total_repos: repos.length, + sample_repos: repos.slice(0, 5).map((repo) => repo.fullName), }); + if (repos.length === 0) { + logger.warn("Repo fetch returned no repositories", { + trace_id: traceId, + }); + } } catch (e) { if (e instanceof SourceControlProviderError && e.errorType === "permanent" && !e.httpStatus) { logger.warn("SCM provider not configured, skipping repo refresh", { @@ -136,6 +142,21 @@ async function handleListRepos( if (cached) { const isFresh = cached.freshUntil && Date.now() < cached.freshUntil; + logger.info("Serving repos cache", { + trace_id: ctx.trace_id, + cache_state: isFresh ? "fresh" : "stale", + cached_at: cached.cachedAt, + repo_count: cached.repos.length, + sample_repos: cached.repos.slice(0, 5).map((repo) => repo.fullName), + }); + if (cached.repos.length === 0) { + logger.warn("Repos cache returned no repositories", { + trace_id: ctx.trace_id, + cache_state: isFresh ? "fresh" : "stale", + cached_at: cached.cachedAt, + }); + } + if (!isFresh && ctx.executionCtx) { // Stale — serve immediately but refresh in background logger.info("Serving stale repos cache, refreshing in background", { @@ -171,7 +192,13 @@ async function handleListRepos( logger.info("Repo fetch completed", { trace_id: ctx.trace_id, total_repos: repos.length, + sample_repos: repos.slice(0, 5).map((repo) => repo.fullName), }); + if (repos.length === 0) { + logger.warn("Repo fetch returned no repositories", { + trace_id: ctx.trace_id, + }); + } const metadataStore = new RepoMetadataStore(env.DB); let metadataMap: Map; @@ -206,6 +233,19 @@ async function handleListRepos( logger.warn("Failed to cache repos list", { error: e instanceof Error ? e : String(e) }); } + logger.info("Returning repos response", { + trace_id: ctx.trace_id, + cached: false, + repo_count: enrichedRepos.length, + sample_repos: enrichedRepos.slice(0, 5).map((repo) => repo.fullName), + }); + if (enrichedRepos.length === 0) { + logger.warn("Repos response contains no repositories", { + trace_id: ctx.trace_id, + cached: false, + }); + } + return json({ repos: enrichedRepos, cached: false, @@ -300,7 +340,7 @@ async function handleListBranches( _request: Request, env: Env, match: RegExpMatchArray, - _ctx: RequestContext + ctx: RequestContext ): Promise { const params = extractRepoParams(match); if (params instanceof Response) return params; @@ -309,6 +349,20 @@ async function handleListBranches( try { const provider = createRouteSourceControlProvider(env); const branches = await provider.listBranches({ owner, name }); + logger.info("Branches fetched", { + trace_id: ctx.trace_id, + repo_owner: owner, + repo_name: name, + branch_count: branches.length, + sample_branches: branches.slice(0, 10).map((branch) => branch.name), + }); + if (branches.length === 0) { + logger.warn("Branch fetch returned no branches", { + trace_id: ctx.trace_id, + repo_owner: owner, + repo_name: name, + }); + } return json({ branches }); } catch (e) { if (e instanceof SourceControlProviderError && e.errorType === "permanent" && !e.httpStatus) { @@ -318,6 +372,7 @@ async function handleListBranches( error: e instanceof Error ? e : String(e), repo_owner: owner, repo_name: name, + trace_id: ctx.trace_id, }); return error("Failed to list branches", 500); } diff --git a/packages/web/src/app/api/repos/[owner]/[name]/branches/route.ts b/packages/web/src/app/api/repos/[owner]/[name]/branches/route.ts index de48822b4..37019d0c7 100644 --- a/packages/web/src/app/api/repos/[owner]/[name]/branches/route.ts +++ b/packages/web/src/app/api/repos/[owner]/[name]/branches/route.ts @@ -10,19 +10,61 @@ export async function GET( ) { const session = await getServerSession(authOptions); if (!session?.user) { + console.warn("[branches] unauthorized branch list request"); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { owner, name } = await params; + const requester = session.user.login ?? session.user.email ?? "unknown"; + + console.info("[branches] fetching branches from control plane", { + requester, + repo: `${owner}/${name}`, + }); try { const response = await controlPlaneFetch( `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches` ); + console.info("[branches] control plane response received", { + requester, + repo: `${owner}/${name}`, + status: response.status, + ok: response.ok, + }); const data = await response.json(); + if (response.ok) { + const branches = + typeof data === "object" && data !== null && "branches" in data + ? ((data as { branches?: Array<{ name: string }> }).branches ?? []) + : []; + console.info("[branches] branches loaded", { + requester, + repo: `${owner}/${name}`, + branchCount: branches.length, + sampleBranches: branches.slice(0, 10).map((branch) => branch.name), + }); + if (branches.length === 0) { + console.warn("[branches] branch list is empty", { + requester, + repo: `${owner}/${name}`, + }); + } + } else { + console.error("[branches] control plane API error", { + requester, + repo: `${owner}/${name}`, + status: response.status, + data, + }); + } return NextResponse.json(data, { status: response.status }); } catch (error) { - console.error("Failed to fetch branches:", error); + console.error("[branches] unexpected error fetching branches", { + requester, + repo: `${owner}/${name}`, + error, + }); return NextResponse.json({ error: "Failed to fetch branches" }, { status: 500 }); } } diff --git a/packages/web/src/app/api/repos/route.ts b/packages/web/src/app/api/repos/route.ts index e2f70b533..c9469327f 100644 --- a/packages/web/src/app/api/repos/route.ts +++ b/packages/web/src/app/api/repos/route.ts @@ -13,17 +13,34 @@ interface ControlPlaneReposResponse { export async function GET() { const session = await getServerSession(authOptions); if (!session) { + console.warn("[repos] unauthorized repository list request"); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const requester = session.user?.login ?? session.user?.email ?? "unknown"; + + console.info("[repos] fetching repositories from control plane", { + requester, + }); + try { // Fetch repositories from control plane using GitHub App installation token. // This ensures we only show repos the App has access to, not all repos the user can see. const response = await controlPlaneFetch("/repos"); + console.info("[repos] control plane response received", { + requester, + status: response.status, + ok: response.ok, + }); + if (!response.ok) { const error = await response.text(); - console.error("Control plane API error:", error); + console.error("[repos] control plane API error", { + requester, + status: response.status, + error, + }); return NextResponse.json( { error: "Failed to fetch repositories" }, { status: response.status } @@ -32,10 +49,28 @@ export async function GET() { const data: ControlPlaneReposResponse = await response.json(); + console.info("[repos] repositories loaded", { + requester, + repoCount: data.repos.length, + cached: data.cached, + cachedAt: data.cachedAt, + sampleRepos: data.repos.slice(0, 5).map((repo) => repo.fullName), + }); + if (data.repos.length === 0) { + console.warn("[repos] repository list is empty", { + requester, + cached: data.cached, + cachedAt: data.cachedAt, + }); + } + // The control plane returns repos in the format we need return NextResponse.json({ repos: data.repos }); } catch (error) { - console.error("Error fetching repos:", error); + console.error("[repos] unexpected error fetching repositories", { + requester, + error, + }); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts index c7441b127..443a7baa0 100644 --- a/packages/web/src/lib/auth.ts +++ b/packages/web/src/lib/auth.ts @@ -47,12 +47,40 @@ export const authOptions: NextAuthOptions = { }; const githubProfile = profile as { login?: string }; + const githubUsername = githubProfile.login?.trim().toLowerCase(); + const emailDomain = user.email?.split("@")[1]?.trim().toLowerCase(); + const usernameMatch = githubUsername ? config.allowedUsers.includes(githubUsername) : false; + const domainMatch = emailDomain ? config.allowedDomains.includes(emailDomain) : false; + const allowAllMode = + config.allowedDomains.length === 0 && + config.allowedUsers.length === 0 && + config.unsafeAllowAllUsers; + const isAllowed = checkAccessAllowed(config, { githubUsername: githubProfile.login, email: user.email ?? undefined, }); + console.info("[auth] signIn access check", { + githubUsername: githubUsername ?? null, + emailDomain: emailDomain ?? null, + allowedUsers: config.allowedUsers, + allowedDomains: config.allowedDomains, + unsafeAllowAllUsers: config.unsafeAllowAllUsers, + usernameMatch, + domainMatch, + allowAllMode, + isAllowed, + }); + if (!isAllowed) { + console.warn("[auth] signIn denied", { + githubUsername: githubUsername ?? null, + emailDomain: emailDomain ?? null, + allowedUsers: config.allowedUsers, + allowedDomains: config.allowedDomains, + unsafeAllowAllUsers: config.unsafeAllowAllUsers, + }); return false; } return true; diff --git a/scripts/clear-cloudflare-repos-cache.sh b/scripts/clear-cloudflare-repos-cache.sh new file mode 100755 index 000000000..8f00a588d --- /dev/null +++ b/scripts/clear-cloudflare-repos-cache.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: clear-cloudflare-repos-cache.sh [options] + +Clear the control-plane repository cache from the remote Cloudflare KV namespace. + +By default this deletes: + - repos:list + - all github:installation-token:v1:* keys + +Options: + --namespace-id Use an explicit KV namespace id instead of terraform output. + --terraform-dir Terraform dir to read session_index_kv_id from. + Default: terraform/environments/production + --local Operate on local Wrangler KV instead of remote. + --dry-run Print keys that would be deleted without deleting them. + -h, --help Show this help text. +EOF +} + +require_command() { + local name="$1" + if ! command -v "$name" >/dev/null 2>&1; then + echo "Required command not found: $name" >&2 + exit 1 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +TERRAFORM_DIR="$REPO_ROOT/terraform/environments/production" +KV_NAMESPACE_ID="" +RESOURCE_FLAG="--remote" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --namespace-id) + KV_NAMESPACE_ID="${2:?Missing value for --namespace-id}" + shift 2 + ;; + --terraform-dir) + TERRAFORM_DIR="${2:?Missing value for --terraform-dir}" + shift 2 + ;; + --local) + RESOURCE_FLAG="--local" + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$KV_NAMESPACE_ID" ]]; then + require_command terraform + if [[ ! -d "$TERRAFORM_DIR" ]]; then + echo "Terraform directory not found: $TERRAFORM_DIR" >&2 + exit 1 + fi + + KV_NAMESPACE_ID="$( + cd "$TERRAFORM_DIR" + terraform output -raw session_index_kv_id + )" +fi + +if [[ -z "$KV_NAMESPACE_ID" ]]; then + echo "Failed to resolve session_index_kv_id" >&2 + exit 1 +fi + +require_command jq +require_command npx + +WRANGLER=(npx wrangler kv key) +REPO_CACHE_KEY="repos:list" +TOKEN_CACHE_PREFIX="github:installation-token:v1:" + +echo "Using KV namespace: $KV_NAMESPACE_ID" +echo "Resource location: ${RESOURCE_FLAG#--}" + +delete_key() { + local key="$1" + + if [[ "$DRY_RUN" == true ]]; then + echo "[dry-run] would delete: $key" + return + fi + + "${WRANGLER[@]}" delete "$key" --namespace-id "$KV_NAMESPACE_ID" "$RESOURCE_FLAG" +} + +echo +echo "Clearing repo list cache key" +delete_key "$REPO_CACHE_KEY" + +echo +echo "Listing GitHub installation token cache keys" +TOKEN_KEYS="$("${WRANGLER[@]}" list --namespace-id "$KV_NAMESPACE_ID" "$RESOURCE_FLAG" --prefix "$TOKEN_CACHE_PREFIX")" + +TOKEN_KEY_NAMES="$(printf '%s' "$TOKEN_KEYS" | jq -r '.[].name')" + +if [[ -z "$TOKEN_KEY_NAMES" ]]; then + echo "No GitHub installation token cache keys found." +else + while IFS= read -r key; do + [[ -n "$key" ]] || continue + delete_key "$key" + done <<< "$TOKEN_KEY_NAMES" +fi + +echo +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run complete." +else + echo "Cloudflare repo cache cleared." +fi