diff --git a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue index 4cdfe857..c2c51167 100644 --- a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue +++ b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue @@ -22,12 +22,11 @@ const repoRouteFetch = useFetch( () => `/api/repos/${repoHub.value.owner}/${repoHub.value.repo}/route-target`, { watch: [repoHub], - lazy: !isBot.value, + lazy: false, immediate: true, }, ) as ReturnType> -if (isBot.value) - await repoRouteFetch +await repoRouteFetch const { data: repoRouteResolution, status: repoRouteStatus } = repoRouteFetch const repoRouteTarget = computed(() => { const resolution = repoRouteResolution.value @@ -35,7 +34,8 @@ const repoRouteTarget = computed(() => { return null return resolution.target }) -const fetchRepoDetailsOnServer = isBot.value && repoRouteTarget.value?._tag !== 'skill' +const fetchRepoSourceOnServer = repoRouteTarget.value?._tag !== 'skill' +const fetchRepoDetailsOnServer = isBot.value && fetchRepoSourceOnServer const repoProfileFetch = useFetch( () => `/api/orgs/${sourceHub.value.owner}`, @@ -44,7 +44,7 @@ const repoProfileFetch = useFetch( watch: false, lazy: !fetchRepoDetailsOnServer, immediate: fetchRepoDetailsOnServer, - server: fetchRepoDetailsOnServer, + server: true, transform: (profile: OrgProfile) => repoPageProfile(profile, repoHub.value.repo), }, ) @@ -54,9 +54,9 @@ const repoSourceFetch = useFetch( () => `/api/repos/${repoHub.value.owner}/${repoHub.value.repo}`, { watch: false, - lazy: !fetchRepoDetailsOnServer, - immediate: fetchRepoDetailsOnServer, - server: fetchRepoDetailsOnServer, + lazy: !fetchRepoSourceOnServer, + immediate: fetchRepoSourceOnServer, + server: fetchRepoSourceOnServer, }, ) as ReturnType> const { data: fetchedRepoSource, status: repoSourceStatus, error: repoSourceError, refresh: refreshRepoSource } = repoSourceFetch @@ -67,15 +67,16 @@ const repoSource = computed(() => { return source }) -// Bots render blocking when this is a repository page. A confirmed-missing repo -// (`/api/repos/.../....get.ts` throws 404 once GitHub itself says 404) was -// rendering the "Source not found" card with a 200 status — a soft 404 that -// let nonexistent owner/repo URLs (e.g. from stale tag/collection data) sit -// in the index. Transient upstream failures don't throw there, so this only -// fires for genuine not-found repos. +// Read the cached source API before every public repository response. A lazy +// browser shell must not turn a confirmed-missing repository into a soft 404. +if (fetchRepoSourceOnServer) + await repoSourceFetch if (fetchRepoDetailsOnServer) - await Promise.all([repoProfileFetch, repoSourceFetch]) -if (fetchRepoDetailsOnServer && repoSourceError.value?.statusCode === 404) { + await repoProfileFetch +if (fetchRepoSourceOnServer && repoSourceError.value?.statusCode === 404) { + // Only missing sources need the full Owner profile for alias recovery. + if (!fetchRepoDetailsOnServer) + await refreshRepo() // Before the tombstone, check whether the repo segment is actually a skill // name. `/gh//` is what an inbound link looks like when the // author drops the repository. The transform decides recovery from the full @@ -261,7 +262,7 @@ const repositoryBadgeInput = computed(() => ({ registryPath: repoHubPath(repoHub.value.owner, repoHub.value.repo), })) -const repoSourceRequested = ref(fetchRepoDetailsOnServer ? repoKey.value : null) +const repoSourceRequested = ref(fetchRepoSourceOnServer ? repoKey.value : null) watch([repoRouteStatus, repoRouteTarget, repoKey], ([routeStatus, routeTarget, key]) => { const routeSettled = routeStatus === 'error' || (routeStatus === 'success' && routeTarget !== null) if (import.meta.server || !routeSettled || routeTarget?._tag === 'skill' || repoSourceRequested.value === key) diff --git a/test/unit/repo-public-existence.nuxt.test.ts b/test/unit/repo-public-existence.nuxt.test.ts new file mode 100644 index 00000000..fcfaeb70 --- /dev/null +++ b/test/unit/repo-public-existence.nuxt.test.ts @@ -0,0 +1,31 @@ +import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import { createError } from 'h3' +import { ref } from 'vue' +import RepoPage from '../../layers/registry/app/pages/gh/[owner]/[repo]/index.vue' + +mockNuxtImport('useRoute', () => () => ({ params: { owner: 'missing-owner', repo: 'missing-repo' }, query: {}, path: '/gh/missing-owner/missing-repo' })) +mockNuxtImport('useBotDetection', () => () => ({ isBot: ref(false) })) + +registerEndpoint('/api/repos/missing-owner/missing-repo/route-target', () => ({ owner: 'missing-owner', repo: 'missing-repo', target: { _tag: 'repo' } })) +let missing = true +registerEndpoint('/api/repos/missing-owner/missing-repo', () => { + if (missing) + throw createError({ statusCode: 404, message: 'Repository not found' }) + return { owner: 'missing-owner', repo: 'missing-repo', description: 'Public Repository body', githubUrl: 'https://github.com/missing-owner/missing-repo', defaultBranch: 'main', stars: 2, forks: 0, pushedAt: '2026-01-01T00:00:00Z', createdAt: '2026-01-01T00:00:00Z', archived: false, fork: false, skillFileScanStatus: 'ok', skillFileCount: 0, skillFiles: [] } +}) +registerEndpoint('/api/orgs/missing-owner', () => ({ owner: 'missing-owner', skills: [], repos: [], totalSkills: 0, totalStars: 0 })) + +it('rejects a confirmed missing repository for a public non-bot visitor', async () => { + await mountSuspended(RepoPage) + expect(useError().value).toMatchObject({ statusCode: 404 }) +}) + +it('renders a valid public Repository without a bot identity', async () => { + clearNuxtData() + await clearError() + missing = false + const wrapper = await mountSuspended(RepoPage) + expect(useError().value).toBeUndefined() + expect(wrapper.text()).toContain('Public Repository body') + wrapper.unmount() +})