From e11c323dc461ad12b1eaec077400b3a8e50d2470 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 14 Oct 2026 02:26:18 +1100 Subject: [PATCH 1/2] fix(registry): reject missing repositories before public SSR --- .../app/pages/gh/[owner]/[repo]/index.vue | 33 ++++++++++--------- test/unit/repo-public-existence.nuxt.test.ts | 16 +++++++++ 2 files changed, 33 insertions(+), 16 deletions(-) create mode 100644 test/unit/repo-public-existence.nuxt.test.ts diff --git a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue index 4cdfe857..88ed3c12 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 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..6aa2a42a --- /dev/null +++ b/test/unit/repo-public-existence.nuxt.test.ts @@ -0,0 +1,16 @@ +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' } })) +registerEndpoint('/api/repos/missing-owner/missing-repo', () => { throw createError({ statusCode: 404, message: 'Repository not found' }) }) +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 }) +}) From fd6ee5377c43c577399f3d50d6028462529f12fd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 14 Oct 2026 02:37:59 +1100 Subject: [PATCH 2/2] fix(registry): retain fulfilled source during hydration --- .../app/pages/gh/[owner]/[repo]/index.vue | 2 +- test/unit/repo-public-existence.nuxt.test.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue index 88ed3c12..c2c51167 100644 --- a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue +++ b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue @@ -262,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 index 6aa2a42a..fcfaeb70 100644 --- a/test/unit/repo-public-existence.nuxt.test.ts +++ b/test/unit/repo-public-existence.nuxt.test.ts @@ -7,10 +7,25 @@ mockNuxtImport('useRoute', () => () => ({ params: { owner: 'missing-owner', repo mockNuxtImport('useBotDetection', () => () => ({ isBot: ref(false) })) registerEndpoint('/api/repos/missing-owner/missing-repo/route-target', () => ({ owner: 'missing-owner', repo: 'missing-repo', target: { _tag: 'repo' } })) -registerEndpoint('/api/repos/missing-owner/missing-repo', () => { throw createError({ statusCode: 404, message: 'Repository not found' }) }) +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() +})