Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions layers/registry/app/pages/gh/[owner]/[repo]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,20 @@ const repoRouteFetch = useFetch<RepoRouteResolution>(
() => `/api/repos/${repoHub.value.owner}/${repoHub.value.repo}/route-target`,
{
watch: [repoHub],
lazy: !isBot.value,
lazy: false,
immediate: true,
},
) as ReturnType<typeof useFetch<RepoRouteResolution>>
if (isBot.value)
await repoRouteFetch
await repoRouteFetch
const { data: repoRouteResolution, status: repoRouteStatus } = repoRouteFetch
const repoRouteTarget = computed(() => {
const resolution = repoRouteResolution.value
if (!resolution || `${resolution.owner}/${resolution.repo}`.toLowerCase() !== repoKey.value)
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}`,
Expand All @@ -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),
},
)
Expand All @@ -54,9 +54,9 @@ const repoSourceFetch = useFetch<RepoSourceProfile>(
() => `/api/repos/${repoHub.value.owner}/${repoHub.value.repo}`,
{
watch: false,
lazy: !fetchRepoDetailsOnServer,
immediate: fetchRepoDetailsOnServer,
server: fetchRepoDetailsOnServer,
lazy: !fetchRepoSourceOnServer,
immediate: fetchRepoSourceOnServer,
server: fetchRepoSourceOnServer,
},
) as ReturnType<typeof useFetch<RepoSourceProfile>>
const { data: fetchedRepoSource, status: repoSourceStatus, error: repoSourceError, refresh: refreshRepoSource } = repoSourceFetch
Expand All @@ -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/<owner>/<skill>` is what an inbound link looks like when the
// author drops the repository. The transform decides recovery from the full
Expand Down Expand Up @@ -261,7 +262,7 @@ const repositoryBadgeInput = computed(() => ({
registryPath: repoHubPath(repoHub.value.owner, repoHub.value.repo),
}))

const repoSourceRequested = ref<string | null>(fetchRepoDetailsOnServer ? repoKey.value : null)
const repoSourceRequested = ref<string | null>(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)
Expand Down
31 changes: 31 additions & 0 deletions test/unit/repo-public-existence.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading