From 055cd7cb90ded96b2ad9ae6edf19af063774eb1d Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 16 Apr 2026 11:27:33 -0400 Subject: [PATCH 1/5] feat: filter private repos by GitHub App installation access Build a cached installation access index that tracks which repos/owners the GitHub App is installed for. Private repos are only shown if the owning account has an "all" installation or the specific repo is in the "selected" set. Public repos always pass through. - Add `installationAccess` signal key and cache policy (30 min stale) - Webhook events (installation, installation_repositories, github_app_authorization) invalidate the index immediately - `getInstallationAccessIndex` fetches installations and paginates selected repos, cached via getOrRevalidateGitHubResource - `isRepoVisibleWithInstallationAccess` filter utility (fail-open when no app is configured) - Apply filter in `getUserRepos` (parallel fetch with repo list) - Expose `getInstallationAccess` server function for other consumers - 7 new tests covering all filter scenarios --- apps/dashboard/src/lib/github-access.test.ts | 80 +++++++ apps/dashboard/src/lib/github-access.ts | 52 +++++ apps/dashboard/src/lib/github-cache-policy.ts | 4 + apps/dashboard/src/lib/github-revalidation.ts | 9 + apps/dashboard/src/lib/github.functions.ts | 216 +++++++++++++++--- 5 files changed, 333 insertions(+), 28 deletions(-) diff --git a/apps/dashboard/src/lib/github-access.test.ts b/apps/dashboard/src/lib/github-access.test.ts index cd70cc0..7f33cf3 100644 --- a/apps/dashboard/src/lib/github-access.test.ts +++ b/apps/dashboard/src/lib/github-access.test.ts @@ -4,7 +4,9 @@ import { buildGitHubOrganizationInstallationsUrl, findInstallationForOwner, type GitHubAppAccessState, + type GitHubInstallationAccessIndex, getAccessHrefForOwner, + isRepoVisibleWithInstallationAccess, } from "./github-access"; const state: GitHubAppAccessState = { @@ -106,3 +108,81 @@ describe("buildGitHubOrganizationInstallationsUrl", () => { ); }); }); + +describe("isRepoVisibleWithInstallationAccess", () => { + const index: GitHubInstallationAccessIndex = { + available: true, + allAccessOwners: new Set(["supabase"]), + selectedRepos: new Set(["adn/private-app", "adn/secret-tool"]), + }; + + it("always allows public repos regardless of index", () => { + expect( + isRepoVisibleWithInstallationAccess(index, "random-org", "repo", false), + ).toBe(true); + }); + + it("allows private repos from an owner with 'all' access", () => { + expect( + isRepoVisibleWithInstallationAccess( + index, + "supabase", + "private-repo", + true, + ), + ).toBe(true); + }); + + it("allows private repos in the selected set", () => { + expect( + isRepoVisibleWithInstallationAccess(index, "adn", "private-app", true), + ).toBe(true); + }); + + it("blocks private repos not in the selected set", () => { + expect( + isRepoVisibleWithInstallationAccess(index, "adn", "other-private", true), + ).toBe(false); + }); + + it("blocks private repos from owners without any installation", () => { + expect( + isRepoVisibleWithInstallationAccess( + index, + "vercel", + "private-repo", + true, + ), + ).toBe(false); + }); + + it("fails open when the index is unavailable", () => { + const unavailable: GitHubInstallationAccessIndex = { + available: false, + allAccessOwners: new Set(), + selectedRepos: new Set(), + }; + expect( + isRepoVisibleWithInstallationAccess( + unavailable, + "any-org", + "private-repo", + true, + ), + ).toBe(true); + }); + + it("is case-insensitive for owner and repo matching", () => { + expect( + isRepoVisibleWithInstallationAccess( + index, + "Supabase", + "Private-Repo", + true, + ), + ).toBe(true); + expect( + isRepoVisibleWithInstallationAccess(index, "ADN", "Private-App", true), + ).toBe(true); + }); +}); diff --git a/apps/dashboard/src/lib/github-access.ts b/apps/dashboard/src/lib/github-access.ts index c6f66fd..1b5bd0e 100644 --- a/apps/dashboard/src/lib/github-access.ts +++ b/apps/dashboard/src/lib/github-access.ts @@ -99,3 +99,55 @@ export function getAccessHrefForOwner( return state.publicInstallUrl ?? fallbackHref ?? null; } + +// --------------------------------------------------------------------------- +// Installation access index +// --------------------------------------------------------------------------- + +/** + * A pre-computed index of which repos/owners are accessible via the GitHub App + * installations. Used to filter private repos so the OAuth token doesn't leak + * access beyond what the user configured in their App installation. + */ +export type GitHubInstallationAccessIndex = { + /** `false` when the app-user token isn't available (no app auth yet). */ + available: boolean; + /** Normalized owner logins with `repositorySelection: "all"`. */ + allAccessOwners: Set; + /** Normalized `owner/repo` strings for `repositorySelection: "selected"`. */ + selectedRepos: Set; +}; + +const EMPTY_INSTALLATION_ACCESS_INDEX: GitHubInstallationAccessIndex = { + available: false, + allAccessOwners: new Set(), + selectedRepos: new Set(), +}; + +export function emptyInstallationAccessIndex(): GitHubInstallationAccessIndex { + return EMPTY_INSTALLATION_ACCESS_INDEX; +} + +/** + * Returns `true` when a repo should be visible given the current installation + * access index. + * + * Rules: + * - Public repos always pass. + * - When the index isn't available (no app setup), all repos pass (fail-open). + * - Private repos pass only when the owning account has an "all" installation + * **or** the specific repo is in the "selected" set. + */ +export function isRepoVisibleWithInstallationAccess( + index: GitHubInstallationAccessIndex, + owner: string, + repo: string, + isPrivate: boolean, +): boolean { + if (!isPrivate) return true; + if (!index.available) return true; + + const normalizedOwner = normalizeLogin(owner); + if (index.allAccessOwners.has(normalizedOwner)) return true; + return index.selectedRepos.has(`${normalizedOwner}/${repo.toLowerCase()}`); +} diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index 64ad4db..3af82a3 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -35,4 +35,8 @@ export const githubCachePolicy = { staleTimeMs: 30 * 60 * 1000, gcTimeMs: 24 * 60 * 60 * 1000, }, + installationAccess: { + staleTimeMs: 30 * 60 * 1000, + gcTimeMs: 24 * 60 * 60 * 1000, + }, } as const; diff --git a/apps/dashboard/src/lib/github-revalidation.ts b/apps/dashboard/src/lib/github-revalidation.ts index d94b40e..c0dc9b9 100644 --- a/apps/dashboard/src/lib/github-revalidation.ts +++ b/apps/dashboard/src/lib/github-revalidation.ts @@ -22,6 +22,7 @@ export const githubRevalidationSignalKeys = { `workflowJob:${input.owner}/${input.repo}#${input.jobId}`, repoCode: (input: { owner: string; repo: string }) => `repoCode:${input.owner}/${input.repo}`, + installationAccess: "installationAccess", } as const; function isRecord(value: unknown): value is Record { @@ -161,6 +162,14 @@ export function getGitHubWebhookRevalidationSignalKeys( event: string, payload: unknown, ) { + if ( + event === "installation" || + event === "installation_repositories" || + event === "github_app_authorization" + ) { + return [githubRevalidationSignalKeys.installationAccess]; + } + const repository = getRepositoryIdentity(payload); if (!repository) { return []; diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 2042e2f..c70007c 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -53,10 +53,13 @@ import type { import { buildGitHubAppAuthorizePath, buildGitHubAppInstallUrl, + emptyInstallationAccessIndex, type GitHubAppAccessState, type GitHubAppInstallation, + type GitHubInstallationAccessIndex, type GitHubInstallationTargetType, type GitHubOrganization, + isRepoVisibleWithInstallationAccess, } from "./github-access"; import { getGitHubAppSlug } from "./github-app.server"; import { @@ -1566,6 +1569,129 @@ async function getGitHubAuthenticatedOrganizations( } } +// --------------------------------------------------------------------------- +// Installation access index — cached list of repos accessible via the app +// --------------------------------------------------------------------------- + +type SerializableInstallationAccessIndex = { + available: boolean; + allAccessOwners: string[]; + selectedRepos: string[]; +}; + +function syntheticGitHubResponseMetadata() { + return { + etag: null, + lastModified: null, + rateLimitRemaining: null, + rateLimitReset: null, + statusCode: 200, + }; +} + +async function getInstallationAccessIndex( + context: GitHubContext, +): Promise { + try { + const serializable = + await getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource: "installationAccess", + params: null, + freshForMs: githubCachePolicy.installationAccess.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.installationAccess], + namespaceKeys: [githubRevalidationSignalKeys.installationAccess], + cacheMode: "split", + fetcher: async () => { + const { installations, installationsAvailable } = + await getGitHubAppUserInstallations(context.session.user.id); + + if (!installationsAvailable) { + return { + kind: "success", + data: { + available: false, + allAccessOwners: [], + selectedRepos: [], + }, + metadata: syntheticGitHubResponseMetadata(), + }; + } + + const allAccessOwners: string[] = []; + const selectedRepos: string[] = []; + + for (const installation of installations) { + if (installation.suspendedAt) continue; + + const ownerLogin = installation.account.login.toLowerCase(); + + if (installation.repositorySelection === "all") { + allAccessOwners.push(ownerLogin); + continue; + } + + if (installation.repositorySelection === "selected") { + try { + const repos = await listPaginatedGitHubItems({ + request: (page) => + context.octokit.rest.apps.listInstallationReposForAuthenticatedUser( + { + installation_id: installation.id, + page, + per_page: 100, + }, + ), + getItems: (payload) => + ((payload as GitHubInstallationRepositoriesPayload) + .repositories ?? []) as NonNullable< + GitHubInstallationRepositoriesPayload["repositories"] + >, + label: `installation-access repos ${installation.id}`, + }); + + for (const repo of repos) { + const fullName = + repo.full_name ?? + (repo.owner?.login && repo.name + ? `${repo.owner.login}/${repo.name}` + : null); + if (fullName) { + selectedRepos.push(fullName.toLowerCase()); + } + } + } catch (error) { + console.error( + `[installation-access] failed to list repos for installation ${installation.id}`, + error, + ); + } + } + } + + return { + kind: "success", + data: { + available: true, + allAccessOwners, + selectedRepos, + }, + metadata: syntheticGitHubResponseMetadata(), + }; + }, + }); + + return { + available: serializable.available, + allAccessOwners: new Set(serializable.allAccessOwners), + selectedRepos: new Set(serializable.selectedRepos), + }; + } catch (error) { + console.error("[installation-access] failed to build access index", error); + return emptyInstallationAccessIndex(); + } +} + async function getGitHubContextForInstallation( baseContext: GitHubContext, installation: GitHubAppInstallation, @@ -4491,6 +4617,28 @@ export const getGitHubAppAccessState = createServerFn({ }; }); +export type SerializedInstallationAccessIndex = { + available: boolean; + allAccessOwners: string[]; + selectedRepos: string[]; +}; + +export const getInstallationAccess = createServerFn({ + method: "GET", +}).handler(async (): Promise => { + const context = await getGitHubContext(); + if (!context) { + return { available: false, allAccessOwners: [], selectedRepos: [] }; + } + + const index = await getInstallationAccessIndex(context); + return { + available: index.available, + allAccessOwners: [...index.allAccessOwners], + selectedRepos: [...index.selectedRepos], + }; +}); + export const getUserRepos = createServerFn({ method: "GET" }).handler( async (): Promise => { const context = await getGitHubContext(); @@ -4498,35 +4646,47 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( return []; } - return getCachedGitHubRequest({ - context, - resource: "repos.list", - params: { sort: "updated", perPage: 10 }, - freshForMs: githubCachePolicy.reposList.staleTimeMs, - namespaceKeys: ["repos.list"], - cacheMode: "split", - request: (headers) => - context.octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 10, - headers, - }), - mapData: (repos) => - repos.map( - (repo: AuthenticatedUserRepo): UserRepoSummary => ({ - id: repo.id, - name: repo.name, - fullName: repo.full_name, - description: repo.description, - stars: repo.stargazers_count, - language: repo.language, - updatedAt: repo.updated_at, - isPrivate: repo.private, - url: repo.html_url, - owner: repo.owner.login, + const [repos, accessIndex] = await Promise.all([ + getCachedGitHubRequest({ + context, + resource: "repos.list", + params: { sort: "updated", perPage: 10 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + namespaceKeys: ["repos.list"], + cacheMode: "split", + request: (headers) => + context.octokit.rest.repos.listForAuthenticatedUser({ + sort: "updated", + per_page: 10, + headers, }), - ), - }); + mapData: (repos) => + repos.map( + (repo: AuthenticatedUserRepo): UserRepoSummary => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + description: repo.description, + stars: repo.stargazers_count, + language: repo.language, + updatedAt: repo.updated_at, + isPrivate: repo.private, + url: repo.html_url, + owner: repo.owner.login, + }), + ), + }), + getInstallationAccessIndex(context), + ]); + + return repos.filter((repo) => + isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner, + repo.name, + repo.isPrivate, + ), + ); }, ); From a4e6616e299eda8cf366459c6c5443e7b3b16a78 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 16 Apr 2026 11:36:01 -0400 Subject: [PATCH 2/5] feat: extend installation access filtering to all GitHub data flows Add isPrivate to RepositoryRef and all GraphQL repository fragments so the installation access filter can be applied everywhere, not just getUserRepos. - Add isPrivate to RepositoryRef type and GitHubGraphQLRepositoryRef - Update buildRepositoryRef, parseRepositoryRef, mapGraphQLRepositoryRef - Add isPrivate to GraphQL fragments for pull search, issue search, pull detail, and issue detail queries - Add generic filterItemsByInstallationAccess helper and specialized filterMyPullsResult / filterMyIssuesResult wrappers - Apply filter in getMyPulls, getMyIssues (parallel fetch with result) - Apply filter in searchCommandPaletteGitHub (parallel with search) - Apply filter in getNotifications (parallel with notification fetch) - Apply filter in getUserPinnedRepos --- apps/dashboard/src/lib/github.functions.ts | 137 +++++++++++++++++---- apps/dashboard/src/lib/github.types.ts | 1 + 2 files changed, 113 insertions(+), 25 deletions(-) diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index c70007c..be97354 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -126,6 +126,7 @@ type GitHubGraphQLRepositoryRef = { name: string; nameWithOwner: string; url: string; + isPrivate: boolean; owner: { login: string; }; @@ -671,17 +672,20 @@ function buildRepositoryRef( owner: string, repo: string, url?: string | null, + isPrivate = false, ): RepositoryRef { return { name: repo, owner, fullName: `${owner}/${repo}`, url: url ?? `https://github.com/${owner}/${repo}`, + isPrivate, }; } function parseRepositoryRef( repositoryUrl?: string | null, + isPrivate = false, ): RepositoryRef | null { if (!repositoryUrl) { return null; @@ -696,6 +700,7 @@ function parseRepositoryRef( match[1], match[2], `https://github.com/${match[1]}/${match[2]}`, + isPrivate, ); } @@ -723,6 +728,7 @@ function mapGraphQLRepositoryRef( owner, fullName: repository.nameWithOwner, url: repository.url, + isPrivate: repository.isPrivate, }; } @@ -3476,6 +3482,7 @@ async function getPullPageDataViaGraphQL( name nameWithOwner url + isPrivate owner { login } } reviewThreads(first: 1) { totalCount } @@ -3779,6 +3786,7 @@ async function getIssuePageDataViaGraphQL( name nameWithOwner url + isPrivate owner { login } } assignees(first: 20) { @@ -4256,6 +4264,7 @@ async function getMyPullsResult({ name nameWithOwner url + isPrivate owner { login } @@ -4434,6 +4443,7 @@ async function getMyIssuesResult({ name nameWithOwner url + isPrivate owner { login } @@ -4707,7 +4717,7 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) const login = viewer.login; const perPage = clampCommandSearchPerPage(data.perPage); - const [pullItems, issueItems] = await Promise.all([ + const [pullItems, issueItems, accessIndex] = await Promise.all([ safeCommandPaletteSearch({ label: "pull requests", fallback: [] as SearchItem[], @@ -4738,14 +4748,63 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) return response.data.items; }, }), + getInstallationAccessIndex(context), ]); return { - pulls: mapPullSearchItems(pullItems), - issues: mapIssueSearchItems(issueItems), + pulls: filterItemsByInstallationAccess( + mapPullSearchItems(pullItems), + accessIndex, + ), + issues: filterItemsByInstallationAccess( + mapIssueSearchItems(issueItems), + accessIndex, + ), }; }); +function filterItemsByInstallationAccess< + T extends { repository: RepositoryRef }, +>(items: T[], accessIndex: GitHubInstallationAccessIndex): T[] { + return items.filter((item) => + isRepoVisibleWithInstallationAccess( + accessIndex, + item.repository.owner, + item.repository.name, + item.repository.isPrivate, + ), + ); +} + +function filterMyPullsResult( + result: MyPullsResult, + accessIndex: GitHubInstallationAccessIndex, +): MyPullsResult { + return { + ...result, + reviewRequested: filterItemsByInstallationAccess( + result.reviewRequested, + accessIndex, + ), + assigned: filterItemsByInstallationAccess(result.assigned, accessIndex), + authored: filterItemsByInstallationAccess(result.authored, accessIndex), + mentioned: filterItemsByInstallationAccess(result.mentioned, accessIndex), + involved: filterItemsByInstallationAccess(result.involved, accessIndex), + }; +} + +function filterMyIssuesResult( + result: MyIssuesResult, + accessIndex: GitHubInstallationAccessIndex, +): MyIssuesResult { + return { + ...result, + assigned: filterItemsByInstallationAccess(result.assigned, accessIndex), + authored: filterItemsByInstallationAccess(result.authored, accessIndex), + mentioned: filterItemsByInstallationAccess(result.mentioned, accessIndex), + }; +} + function toInstallationTargetType( value: string | undefined, ): GitHubInstallationTargetType { @@ -4770,7 +4829,11 @@ export const getMyPulls = createServerFn({ method: "GET" }).handler( } const viewer = await getViewer(context); - return getMyPullsResult({ context, username: viewer.login }); + const [result, accessIndex] = await Promise.all([ + getMyPullsResult({ context, username: viewer.login }), + getInstallationAccessIndex(context), + ]); + return filterMyPullsResult(result, accessIndex); }, ); @@ -4924,7 +4987,11 @@ export const getMyIssues = createServerFn({ method: "GET" }).handler( } const viewer = await getViewer(context); - return getMyIssuesResult({ context, username: viewer.login }); + const [result, accessIndex] = await Promise.all([ + getMyIssuesResult({ context, username: viewer.login }), + getInstallationAccessIndex(context), + ]); + return filterMyIssuesResult(result, accessIndex); }, ); @@ -6386,6 +6453,8 @@ export const getUserPinnedRepos = createServerFn({ method: "GET" }) return []; } + const accessIndex = await getInstallationAccessIndex(context); + try { const response: { user: { @@ -6426,17 +6495,26 @@ export const getUserPinnedRepos = createServerFn({ method: "GET" }) { username: data.username }, ); - return response.user.pinnedItems.nodes.map((repo) => ({ - name: repo.name, - description: repo.description, - stars: repo.stargazerCount, - language: repo.primaryLanguage?.name ?? null, - languageColor: repo.primaryLanguage?.color ?? null, - url: repo.url, - owner: repo.owner.login, - isPrivate: repo.isPrivate, - forks: repo.forkCount, - })); + return response.user.pinnedItems.nodes + .filter((repo) => + isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner.login, + repo.name, + repo.isPrivate, + ), + ) + .map((repo) => ({ + name: repo.name, + description: repo.description, + stars: repo.stargazerCount, + language: repo.primaryLanguage?.name ?? null, + languageColor: repo.primaryLanguage?.color ?? null, + url: repo.url, + owner: repo.owner.login, + isPrivate: repo.isPrivate, + forks: repo.forkCount, + })); } catch { return []; } @@ -7128,14 +7206,14 @@ export const getNotifications = createServerFn({ method: "GET" }) return { notifications: [] }; } - const response = - await context.octokit.rest.activity.listNotificationsForAuthenticatedUser( - { - all: data.all ?? false, - participating: data.participating ?? false, - per_page: 50, - }, - ); + const [response, accessIndex] = await Promise.all([ + context.octokit.rest.activity.listNotificationsForAuthenticatedUser({ + all: data.all ?? false, + participating: data.participating ?? false, + per_page: 50, + }), + getInstallationAccessIndex(context), + ]); // Batch-fetch participants for PR/Issue notifications in parallel const participantMap = new Map(); @@ -7252,7 +7330,16 @@ export const getNotifications = createServerFn({ method: "GET" }) url: n.url, })); - return { notifications }; + return { + notifications: notifications.filter((notification) => + isRepoVisibleWithInstallationAccess( + accessIndex, + notification.repository.owner.login, + notification.repository.name, + notification.repository.private, + ), + ), + }; }); type MarkNotificationReadInput = { threadId: string }; diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index da7a66c..35fccc3 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -3,6 +3,7 @@ export type RepositoryRef = { owner: string; fullName: string; url: string; + isPrivate: boolean; }; export type GitHubActor = { From 7848eb48938949c9292660b29a5bfb224d2eb368 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 16 Apr 2026 17:26:00 -0400 Subject: [PATCH 3/5] chore: add debug logs throughout installation access filtering Add debug() calls so the access index building and filtering behavior is fully observable in local dev: - Log when fetching the index (cache miss vs resolved from cache) - Log each installation's access mode (all/selected/suspended) - Log the full built index (owners, repos, counts) - Log whenever items are filtered out, with repo names and counts (getUserRepos, getMyPulls, getMyIssues, command palette, notifications, pinned repos) --- apps/dashboard/src/lib/github.functions.ts | 203 +++++++++++++++++---- 1 file changed, 170 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index be97354..23a15da 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -1609,10 +1609,15 @@ async function getInstallationAccessIndex( namespaceKeys: [githubRevalidationSignalKeys.installationAccess], cacheMode: "split", fetcher: async () => { + debug("installation-access", "fetching access index (cache miss)"); const { installations, installationsAvailable } = await getGitHubAppUserInstallations(context.session.user.id); if (!installationsAvailable) { + debug( + "installation-access", + "app-user token unavailable, index not available (fail-open)", + ); return { kind: "success", data: { @@ -1624,15 +1629,30 @@ async function getInstallationAccessIndex( }; } + debug("installation-access", "processing installations", { + count: installations.length, + owners: installations.map((i) => i.account.login), + }); + const allAccessOwners: string[] = []; const selectedRepos: string[] = []; for (const installation of installations) { - if (installation.suspendedAt) continue; + if (installation.suspendedAt) { + debug("installation-access", "skipping suspended installation", { + owner: installation.account.login, + installationId: installation.id, + }); + continue; + } const ownerLogin = installation.account.login.toLowerCase(); if (installation.repositorySelection === "all") { + debug( + "installation-access", + `owner "${ownerLogin}" has "all" repo access`, + ); allAccessOwners.push(ownerLogin); continue; } @@ -1656,6 +1676,7 @@ async function getInstallationAccessIndex( label: `installation-access repos ${installation.id}`, }); + const repoNames: string[] = []; for (const repo of repos) { const fullName = repo.full_name ?? @@ -1663,9 +1684,21 @@ async function getInstallationAccessIndex( ? `${repo.owner.login}/${repo.name}` : null); if (fullName) { - selectedRepos.push(fullName.toLowerCase()); + const normalized = fullName.toLowerCase(); + selectedRepos.push(normalized); + repoNames.push(normalized); } } + + debug( + "installation-access", + `owner "${ownerLogin}" has "selected" repo access`, + { + installationId: installation.id, + repoCount: repoNames.length, + repos: repoNames, + }, + ); } catch (error) { console.error( `[installation-access] failed to list repos for installation ${installation.id}`, @@ -1675,6 +1708,12 @@ async function getInstallationAccessIndex( } } + debug("installation-access", "access index built", { + allAccessOwners, + selectedRepoCount: selectedRepos.length, + selectedRepos, + }); + return { kind: "success", data: { @@ -1687,6 +1726,13 @@ async function getInstallationAccessIndex( }, }); + debug("installation-access", "resolved access index", { + available: serializable.available, + allAccessOwners: serializable.allAccessOwners, + selectedRepoCount: serializable.selectedRepos.length, + selectedRepos: serializable.selectedRepos, + }); + return { available: serializable.available, allAccessOwners: new Set(serializable.allAccessOwners), @@ -4689,7 +4735,7 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( getInstallationAccessIndex(context), ]); - return repos.filter((repo) => + const filtered = repos.filter((repo) => isRepoVisibleWithInstallationAccess( accessIndex, repo.owner, @@ -4697,6 +4743,28 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( repo.isPrivate, ), ); + + const removedCount = repos.length - filtered.length; + if (removedCount > 0) { + debug("installation-access", "getUserRepos filtered", { + total: repos.length, + kept: filtered.length, + removed: removedCount, + removedRepos: repos + .filter( + (repo) => + !isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner, + repo.name, + repo.isPrivate, + ), + ) + .map((repo) => repo.fullName), + }); + } + + return filtered; }, ); @@ -4766,7 +4834,7 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) function filterItemsByInstallationAccess< T extends { repository: RepositoryRef }, >(items: T[], accessIndex: GitHubInstallationAccessIndex): T[] { - return items.filter((item) => + const filtered = items.filter((item) => isRepoVisibleWithInstallationAccess( accessIndex, item.repository.owner, @@ -4774,6 +4842,30 @@ function filterItemsByInstallationAccess< item.repository.isPrivate, ), ); + + const removedCount = items.length - filtered.length; + if (removedCount > 0) { + const removed = items + .filter( + (item) => + !isRepoVisibleWithInstallationAccess( + accessIndex, + item.repository.owner, + item.repository.name, + item.repository.isPrivate, + ), + ) + .map((item) => item.repository.fullName); + + debug("installation-access", "filtered items by access scope", { + total: items.length, + kept: filtered.length, + removed: removedCount, + removedRepos: [...new Set(removed)], + }); + } + + return filtered; } function filterMyPullsResult( @@ -6495,26 +6587,47 @@ export const getUserPinnedRepos = createServerFn({ method: "GET" }) { username: data.username }, ); - return response.user.pinnedItems.nodes - .filter((repo) => - isRepoVisibleWithInstallationAccess( - accessIndex, - repo.owner.login, - repo.name, - repo.isPrivate, - ), - ) - .map((repo) => ({ - name: repo.name, - description: repo.description, - stars: repo.stargazerCount, - language: repo.primaryLanguage?.name ?? null, - languageColor: repo.primaryLanguage?.color ?? null, - url: repo.url, - owner: repo.owner.login, - isPrivate: repo.isPrivate, - forks: repo.forkCount, - })); + const allPinned = response.user.pinnedItems.nodes; + const visiblePinned = allPinned.filter((repo) => + isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner.login, + repo.name, + repo.isPrivate, + ), + ); + + const removedCount = allPinned.length - visiblePinned.length; + if (removedCount > 0) { + debug("installation-access", "getUserPinnedRepos filtered", { + total: allPinned.length, + kept: visiblePinned.length, + removed: removedCount, + removedRepos: allPinned + .filter( + (repo) => + !isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner.login, + repo.name, + repo.isPrivate, + ), + ) + .map((repo) => `${repo.owner.login}/${repo.name}`), + }); + } + + return visiblePinned.map((repo) => ({ + name: repo.name, + description: repo.description, + stars: repo.stargazerCount, + language: repo.primaryLanguage?.name ?? null, + languageColor: repo.primaryLanguage?.color ?? null, + url: repo.url, + owner: repo.owner.login, + isPrivate: repo.isPrivate, + forks: repo.forkCount, + })); } catch { return []; } @@ -7330,16 +7443,40 @@ export const getNotifications = createServerFn({ method: "GET" }) url: n.url, })); - return { - notifications: notifications.filter((notification) => - isRepoVisibleWithInstallationAccess( - accessIndex, - notification.repository.owner.login, - notification.repository.name, - notification.repository.private, - ), + const filteredNotifications = notifications.filter((notification) => + isRepoVisibleWithInstallationAccess( + accessIndex, + notification.repository.owner.login, + notification.repository.name, + notification.repository.private, ), - }; + ); + + const removedCount = notifications.length - filteredNotifications.length; + if (removedCount > 0) { + debug("installation-access", "getNotifications filtered", { + total: notifications.length, + kept: filteredNotifications.length, + removed: removedCount, + removedRepos: [ + ...new Set( + notifications + .filter( + (n) => + !isRepoVisibleWithInstallationAccess( + accessIndex, + n.repository.owner.login, + n.repository.name, + n.repository.private, + ), + ) + .map((n) => n.repository.fullName), + ), + ], + }); + } + + return { notifications: filteredNotifications }; }); type MarkNotificationReadInput = { threadId: string }; From be81af3a89a6a9c9d5945636dc63dcdb707df077 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 16 Apr 2026 17:33:44 -0400 Subject: [PATCH 4/5] feat: refresh installation access cache when returning from GitHub Add visibilitychange listener to /setup page and access dialog that busts the server-side installation access cache and invalidates all GitHub queries when the user returns from configuring permissions. --- .../layouts/github-access-dialog.tsx | 3 + apps/dashboard/src/lib/github.functions.ts | 19 +++++++ .../src/lib/use-refresh-on-return.ts | 56 +++++++++++++++++++ apps/dashboard/src/routes/setup.tsx | 2 + 4 files changed, 80 insertions(+) create mode 100644 apps/dashboard/src/lib/use-refresh-on-return.ts diff --git a/apps/dashboard/src/components/layouts/github-access-dialog.tsx b/apps/dashboard/src/components/layouts/github-access-dialog.tsx index 6221f28..43f2ea3 100644 --- a/apps/dashboard/src/components/layouts/github-access-dialog.tsx +++ b/apps/dashboard/src/components/layouts/github-access-dialog.tsx @@ -24,6 +24,7 @@ import { useGitHubAccessPrompt, } from "#/lib/github-access-modal-store"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useRefreshOnReturn } from "#/lib/use-refresh-on-return"; function getExternalLinkProps(href: string) { if (href.startsWith("http://") || href.startsWith("https://")) { @@ -39,6 +40,8 @@ export function GitHubAccessDialog({ userId }: { userId: string }) { const [showOrgSetup, setShowOrgSetup] = useShowOrgSetupQueryState(); const isOpen = showOrgSetup; + useRefreshOnReturn({ enabled: isOpen }); + const accessQuery = useQuery({ queryKey: ["github-app-access-state", userId], queryFn: () => getGitHubAppAccessState(), diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 23a15da..bd7c763 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -69,6 +69,7 @@ import { type GitHubConditionalHeaders, type GitHubFetchResult, getOrRevalidateGitHubResource, + markGitHubRevalidationSignals, } from "./github-cache"; import { githubCachePolicy } from "./github-cache-policy"; import { githubRevalidationSignalKeys } from "./github-revalidation"; @@ -4695,6 +4696,24 @@ export const getInstallationAccess = createServerFn({ }; }); +/** + * Invalidates the server-side installation access cache so the next request + * fetches fresh data from GitHub. Called when the user returns from changing + * permissions on GitHub (e.g. from /setup or the access dialog). + */ +export const refreshInstallationAccess = createServerFn({ + method: "POST", +}).handler(async () => { + await markGitHubRevalidationSignals([ + githubRevalidationSignalKeys.installationAccess, + ]); + debug( + "refreshInstallationAccess", + "marked installationAccess for revalidation", + ); + return { ok: true }; +}); + export const getUserRepos = createServerFn({ method: "GET" }).handler( async (): Promise => { const context = await getGitHubContext(); diff --git a/apps/dashboard/src/lib/use-refresh-on-return.ts b/apps/dashboard/src/lib/use-refresh-on-return.ts new file mode 100644 index 0000000..fc29ff3 --- /dev/null +++ b/apps/dashboard/src/lib/use-refresh-on-return.ts @@ -0,0 +1,56 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef } from "react"; +import { refreshInstallationAccess } from "./github.functions"; +import { githubQueryKeys } from "./github.query"; + +/** + * Listens for the tab becoming visible again (user returning from an external + * site like GitHub) and refreshes the installation access cache + all GitHub + * queries so the UI reflects any permission changes. + * + * Only fires once per hidden→visible transition to avoid duplicate work. + */ +export function useRefreshOnReturn({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + const queryClient = useQueryClient(); + const router = useRouter(); + const wasHiddenRef = useRef(false); + + const refresh = useCallback(async () => { + await refreshInstallationAccess(); + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + void queryClient.invalidateQueries({ + queryKey: ["github-app-access-state"], + }); + void router.invalidate(); + }, [queryClient, router]); + + useEffect(() => { + if (!enabled) return; + + function handleVisibilityChange() { + if (document.hidden) { + wasHiddenRef.current = true; + return; + } + + if (wasHiddenRef.current) { + wasHiddenRef.current = false; + void refresh(); + } + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [enabled, refresh]); + + return refresh; +} diff --git a/apps/dashboard/src/routes/setup.tsx b/apps/dashboard/src/routes/setup.tsx index 56d1bae..aea92be 100644 --- a/apps/dashboard/src/routes/setup.tsx +++ b/apps/dashboard/src/routes/setup.tsx @@ -10,6 +10,7 @@ import { getAccessHrefForOwner, } from "#/lib/github-access"; import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; +import { useRefreshOnReturn } from "#/lib/use-refresh-on-return"; export const Route = createFileRoute("/setup")({ beforeLoad: async () => { @@ -37,6 +38,7 @@ export const Route = createFileRoute("/setup")({ function SetupPage() { const { accessState: state } = Route.useLoaderData(); + useRefreshOnReturn(); const hasInstallations = state?.installationsAvailable === true && From 318a19b9a1c2f49ef46b4d7609a2778e93e96f16 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 16 Apr 2026 17:42:52 -0400 Subject: [PATCH 5/5] fix: address review feedback on installation access filtering - Paginate GET /user/installations (handles >100 installations) - Let transient API errors propagate instead of silently failing open - Use app-user client (not OAuth) for listInstallationReposForAuthenticatedUser - Default unknown repo visibility to null instead of false (public), so REST search results are checked against the access index - Wrap refreshInstallationAccess in try/finally so client-side cache invalidation runs even if the server call fails --- apps/dashboard/src/lib/github-access.test.ts | 12 +++ apps/dashboard/src/lib/github-access.ts | 6 +- apps/dashboard/src/lib/github.functions.ts | 97 +++++++++++++------ apps/dashboard/src/lib/github.types.ts | 3 +- .../src/lib/use-refresh-on-return.ts | 19 ++-- 5 files changed, 96 insertions(+), 41 deletions(-) diff --git a/apps/dashboard/src/lib/github-access.test.ts b/apps/dashboard/src/lib/github-access.test.ts index 7f33cf3..996bf68 100644 --- a/apps/dashboard/src/lib/github-access.test.ts +++ b/apps/dashboard/src/lib/github-access.test.ts @@ -172,6 +172,18 @@ describe("isRepoVisibleWithInstallationAccess", () => { ).toBe(true); }); + it("treats unknown visibility (null) as potentially private", () => { + expect( + isRepoVisibleWithInstallationAccess(index, "adn", "private-app", null), + ).toBe(true); + expect( + isRepoVisibleWithInstallationAccess(index, "adn", "other-private", null), + ).toBe(false); + expect( + isRepoVisibleWithInstallationAccess(index, "vercel", "some-repo", null), + ).toBe(false); + }); + it("is case-insensitive for owner and repo matching", () => { expect( isRepoVisibleWithInstallationAccess( diff --git a/apps/dashboard/src/lib/github-access.ts b/apps/dashboard/src/lib/github-access.ts index 1b5bd0e..095605f 100644 --- a/apps/dashboard/src/lib/github-access.ts +++ b/apps/dashboard/src/lib/github-access.ts @@ -142,9 +142,11 @@ export function isRepoVisibleWithInstallationAccess( index: GitHubInstallationAccessIndex, owner: string, repo: string, - isPrivate: boolean, + isPrivate: boolean | null, ): boolean { - if (!isPrivate) return true; + // Only skip the check when the repo is *explicitly* public. + // `null` (unknown visibility) is treated as potentially private. + if (isPrivate === false) return true; if (!index.available) return true; const normalizedOwner = normalizeLogin(owner); diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index bd7c763..83c5765 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -493,6 +493,7 @@ type GitHubUserInstallationPayload = { }; type GitHubUserInstallationsPayload = { + total_count?: number; installations?: GitHubUserInstallationPayload[]; }; @@ -673,7 +674,7 @@ function buildRepositoryRef( owner: string, repo: string, url?: string | null, - isPrivate = false, + isPrivate: boolean | null = null, ): RepositoryRef { return { name: repo, @@ -686,7 +687,7 @@ function buildRepositoryRef( function parseRepositoryRef( repositoryUrl?: string | null, - isPrivate = false, + isPrivate: boolean | null = null, ): RepositoryRef | null { if (!repositoryUrl) { return null; @@ -1512,37 +1513,64 @@ function mapGitHubAppInstallations( async function getGitHubAppUserInstallations(userId: string): Promise<{ installations: GitHubAppInstallation[]; + /** `true` when the app-user token is configured and the API responded. */ installationsAvailable: boolean; + /** The app-user Octokit instance (for follow-up calls like listing repos). */ + appUserOctokit: GitHubClient | null; }> { - try { - const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); - const appUserOctokit = await getGitHubAppUserClientByUserId(userId); - if (!appUserOctokit) { - debug("github-access", "no app user client, skipping installations"); - return { installations: [], installationsAvailable: false }; - } - - const installationsResponse = await appUserOctokit.request( - "GET /user/installations", - { - per_page: 100, - }, - ); - const installations = mapGitHubAppInstallations( - installationsResponse.data as GitHubUserInstallationsPayload, - ); - debug("github-access", "loaded app installations", { - count: installations.length, - owners: installations.map((i) => i.account.login), - }); + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId(userId); + if (!appUserOctokit) { + debug("github-access", "no app user client, skipping installations"); return { - installations, - installationsAvailable: true, + installations: [], + installationsAvailable: false, + appUserOctokit: null, }; - } catch (error) { - console.error("[github-access] failed to load app installations", error); - return { installations: [], installationsAvailable: false }; } + + // The app-user token exists — any failures from here on are transient + // and should propagate so the cache layer can serve stale data or the + // outer catch handles them (rather than silently failing open). + const PAGE_SIZE = 100; + const firstResponse = await appUserOctokit.request( + "GET /user/installations", + { per_page: PAGE_SIZE }, + ); + const firstPayload = firstResponse.data as GitHubUserInstallationsPayload; + const firstPage = firstPayload.installations ?? []; + const allRawInstallations = [...firstPage]; + + if (firstPage.length >= PAGE_SIZE) { + let page = 2; + while (true) { + const response = await appUserOctokit.request("GET /user/installations", { + per_page: PAGE_SIZE, + page, + }); + const payload = response.data as GitHubUserInstallationsPayload; + const pageItems = payload.installations ?? []; + allRawInstallations.push(...pageItems); + + if (pageItems.length < PAGE_SIZE) { + break; + } + page += 1; + } + } + + const installations = mapGitHubAppInstallations({ + installations: allRawInstallations, + }); + debug("github-access", "loaded app installations", { + count: installations.length, + owners: installations.map((i) => i.account.login), + }); + return { + installations, + installationsAvailable: true, + appUserOctokit, + }; } async function getGitHubAuthenticatedOrganizations( @@ -1611,7 +1639,7 @@ async function getInstallationAccessIndex( cacheMode: "split", fetcher: async () => { debug("installation-access", "fetching access index (cache miss)"); - const { installations, installationsAvailable } = + const { installations, installationsAvailable, appUserOctokit } = await getGitHubAppUserInstallations(context.session.user.id); if (!installationsAvailable) { @@ -1660,9 +1688,11 @@ async function getInstallationAccessIndex( if (installation.repositorySelection === "selected") { try { + // Use the app-user client (not the OAuth client) — + // this endpoint requires a GitHub App user-to-server token. const repos = await listPaginatedGitHubItems({ request: (page) => - context.octokit.rest.apps.listInstallationReposForAuthenticatedUser( + appUserOctokit!.rest.apps.listInstallationReposForAuthenticatedUser( { installation_id: installation.id, page, @@ -1740,6 +1770,13 @@ async function getInstallationAccessIndex( selectedRepos: new Set(serializable.selectedRepos), }; } catch (error) { + // Transient failure (network, 500, etc.) — not cached, so the next + // request will retry. Fail-open so the current request doesn't block + // all private repos for the user. + debug( + "installation-access", + "transient error building access index, failing open", + ); console.error("[installation-access] failed to build access index", error); return emptyInstallationAccessIndex(); } diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 35fccc3..1f4fb80 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -3,7 +3,8 @@ export type RepositoryRef = { owner: string; fullName: string; url: string; - isPrivate: boolean; + /** `null` means the visibility is unknown (e.g. REST search doesn't return it). */ + isPrivate: boolean | null; }; export type GitHubActor = { diff --git a/apps/dashboard/src/lib/use-refresh-on-return.ts b/apps/dashboard/src/lib/use-refresh-on-return.ts index fc29ff3..62a6c49 100644 --- a/apps/dashboard/src/lib/use-refresh-on-return.ts +++ b/apps/dashboard/src/lib/use-refresh-on-return.ts @@ -21,14 +21,17 @@ export function useRefreshOnReturn({ const wasHiddenRef = useRef(false); const refresh = useCallback(async () => { - await refreshInstallationAccess(); - void queryClient.invalidateQueries({ - queryKey: githubQueryKeys.all, - }); - void queryClient.invalidateQueries({ - queryKey: ["github-app-access-state"], - }); - void router.invalidate(); + try { + await refreshInstallationAccess(); + } finally { + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + void queryClient.invalidateQueries({ + queryKey: ["github-app-access-state"], + }); + void router.invalidate(); + } }, [queryClient, router]); useEffect(() => {