diff --git a/layers/registry/server/utils/github-client.ts b/layers/registry/server/utils/github-client.ts index 576f7209..f017b9bd 100644 --- a/layers/registry/server/utils/github-client.ts +++ b/layers/registry/server/utils/github-client.ts @@ -363,6 +363,7 @@ interface RepoSummaryGql { createdAt: string isArchived: boolean isFork: boolean + isPrivate: boolean defaultBranchRef: { name: string target: { oid: string, tree: { oid: string } } | null @@ -370,7 +371,7 @@ interface RepoSummaryGql { } const REPO_SUMMARY_FIELDS = `databaseId name nameWithOwner url owner{login} - description stargazerCount forkCount pushedAt createdAt isArchived isFork + description stargazerCount forkCount pushedAt createdAt isArchived isFork isPrivate defaultBranchRef{name target{... on Commit{oid tree{oid}}}}` export interface RepoSummary { @@ -385,12 +386,11 @@ export interface RepoSummary { } /** - * Parse one GraphQL Repository into a summary, or null without a Repository - * ID. The schema allows a null ID, and a Repository without one cannot be - * followed across a move. + * Parse one public GraphQL Repository. Reject missing IDs or visibility. + * Its numeric ID confirms identity across moves. */ function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary | null { - if (typeof r.databaseId !== 'number') + if (typeof r.databaseId !== 'number' || typeof r.isPrivate !== 'boolean' || r.isPrivate) return null const branch = r.defaultBranchRef?.name || 'main' const meta: RepoMeta = { @@ -398,6 +398,7 @@ function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary | null { full_name: r.nameWithOwner, html_url: r.url, owner: { login: r.owner.login }, + private: false, default_branch: branch, description: r.description, stargazers_count: r.stargazerCount, @@ -443,7 +444,7 @@ export async function getRepoSummary( return { status: notFound ? 404 : 502, data: null, rateLimit: out.rateLimit, notModified: false } } const r = out.data?.repository - if (!r) + if (!r || r.isPrivate === true) return { status: 404, data: null, rateLimit: out.rateLimit, notModified: false } // A Repository without an ID fails like any other malformed answer. const summary = repoSummaryFromGql(r) diff --git a/test/unit/discovery-size-guard-not-modified.test.ts b/test/unit/discovery-size-guard-not-modified.test.ts index 711e3bb0..6f3607f8 100644 --- a/test/unit/discovery-size-guard-not-modified.test.ts +++ b/test/unit/discovery-size-guard-not-modified.test.ts @@ -47,6 +47,7 @@ function summaryResponse() { createdAt: '2026-01-01T00:00:00Z', isArchived: false, isFork: false, + isPrivate: false, defaultBranchRef: { name: 'main', target: { oid: 'commit', tree: { oid: 'tree-sha' } } }, }, }, diff --git a/test/unit/github-repo-identity.test.ts b/test/unit/github-repo-identity.test.ts index 6b56539c..336a73f3 100644 --- a/test/unit/github-repo-identity.test.ts +++ b/test/unit/github-repo-identity.test.ts @@ -1,9 +1,35 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getRepoSummary } from '../../layers/registry/server/utils/github-client' +import { readRepositoryPurposeEvidence } from '../../layers/registry/server/utils/repository-purpose-effect' describe('github repository identity', () => { afterEach(() => vi.unstubAllGlobals()) + it('hides a private repository even when the read credential can access it', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + data: { repository: { databaseId: 1, isPrivate: true, name: 'private', nameWithOwner: 'acme/private', owner: { login: 'acme' }, defaultBranchRef: null } }, + }))) + expect(await getRepoSummary('acme', 'private', {})).toMatchObject({ status: 404, data: null }) + }) + + it('fails closed when the source omits repository visibility', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + data: { repository: { databaseId: 1, name: 'unknown', nameWithOwner: 'acme/unknown', owner: { login: 'acme' }, defaultBranchRef: null } }, + }))) + expect(await getRepoSummary('acme', 'unknown', {})).toMatchObject({ status: 502, data: null }) + }) + + it('stops purpose evidence before reading any private tree or file', async () => { + const fetch = vi.fn(async () => Response.json({ + data: { repository: { databaseId: 1, isPrivate: true, name: 'private', owner: { login: 'acme' }, defaultBranchRef: null } }, + })) + vi.stubGlobal('fetch', fetch) + await expect(readRepositoryPurposeEvidence({ owner: 'acme', repo: 'private' }, {})) + .rejects + .toThrow('Repository purpose source unavailable: 404') + expect(fetch).toHaveBeenCalledTimes(1) + }) + it('returns GitHub canonical identity when an old repository name resolves', async () => { const fetch = vi.fn(async () => Response.json({ data: { @@ -20,6 +46,7 @@ describe('github repository identity', () => { createdAt: '2025-01-01T00:00:00Z', isArchived: false, isFork: false, + isPrivate: false, defaultBranchRef: { name: 'main', target: { oid: 'commit', tree: { oid: 'tree' } }, diff --git a/test/unit/github-repo-summary-batch.test.ts b/test/unit/github-repo-summary-batch.test.ts index a4272b6d..b5a47998 100644 --- a/test/unit/github-repo-summary-batch.test.ts +++ b/test/unit/github-repo-summary-batch.test.ts @@ -22,6 +22,7 @@ function repository(owner: string, name: string, tree: string) { createdAt: '2025-01-01T00:00:00Z', isArchived: false, isFork: false, + isPrivate: false, defaultBranchRef: { name: 'main', target: { oid: 'c'.repeat(40), tree: { oid: tree } } }, } } @@ -54,6 +55,15 @@ afterEach(() => { }) describe('getRepoSummariesBatch', () => { + it('omits a readable private repository while preserving the public neighbor', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ data: { + r0: { ...repository('acme', 'private', 'private-tree'), isPrivate: true }, + r1: repository('acme', 'public', 'public-tree'), + } }))) + const result = await getRepoSummariesBatch([{ owner: 'acme', repo: 'private' }, { owner: 'acme', repo: 'public' }], bindings) + expect(result).toMatchObject({ _tag: 'read', summaries: [null, { meta: { full_name: 'acme/public' } }] }) + }) + it('reads 230 repositories in three queries, in request order', async () => { const fetchMock = vi.fn(graphqlAnswer) vi.stubGlobal('fetch', fetchMock) diff --git a/test/unit/registry-github-app-credential.test.ts b/test/unit/registry-github-app-credential.test.ts index 9177965b..5cbb6409 100644 --- a/test/unit/registry-github-app-credential.test.ts +++ b/test/unit/registry-github-app-credential.test.ts @@ -584,6 +584,7 @@ async function stubGithub(options: StubOptions) { createdAt: '2026-01-01T00:00:00Z', isArchived: false, isFork: false, + isPrivate: false, defaultBranchRef: { name: 'main', target: { oid: 'a'.repeat(40), tree: { oid: 'b'.repeat(40) } } }, }) if ('owner' in body.variables) { diff --git a/test/unit/sync-prefetch.test.ts b/test/unit/sync-prefetch.test.ts index 07d198e6..9c170215 100644 --- a/test/unit/sync-prefetch.test.ts +++ b/test/unit/sync-prefetch.test.ts @@ -43,6 +43,7 @@ function graphqlAnswer(_input: unknown, init?: RequestInit): Response { createdAt: '2025-01-01T00:00:00Z', isArchived: false, isFork: false, + isPrivate: false, defaultBranchRef: { name: 'main', target: { oid: 'c'.repeat(40), tree: { oid: tree } } }, } }