Skip to content
Open
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
17 changes: 17 additions & 0 deletions modules/runtime/server/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const FIXTURE_PATHS = {
packument: 'npm-registry:packuments',
search: 'npm-registry:search',
org: 'npm-registry:orgs',
orgUsers: 'npm-registry:org-users',
downloads: 'npm-api:downloads',
user: 'users',
esmHeaders: 'esm-sh:headers',
Expand Down Expand Up @@ -56,6 +57,7 @@ function getFixturePath(type: FixtureType, name: string): string {
filename = `${name.replace(/:/g, '-')}.json`
break
case 'org':
case 'orgUsers':
case 'user':
filename = `${name}.json`
break
Expand Down Expand Up @@ -461,6 +463,12 @@ function matchUrlToFixture(url: string): FixtureMatchWithVersion | null {
return { type: 'org', name: orgMatch[1] }
}

// Org users (distinguishes real orgs from user accounts for /org/<name> redirects)
const orgUsersMatch = pathname.match(/^\/-\/org\/([^/]+)\/user$/)
if (orgUsersMatch?.[1]) {
return { type: 'orgUsers', name: orgUsersMatch[1] }
}

// Packument - handle both full packument and version manifest requests
let packagePath = decodeURIComponent(pathname.slice(1))
if (packagePath && !packagePath.startsWith('-/')) {
Expand Down Expand Up @@ -621,6 +629,15 @@ async function fetchFromFixtures<T>(
})
}

// For org users without fixtures, return 404 (name is neither an org nor a user)
if (match.type === 'orgUsers') {
throw createError({
statusCode: 404,
statusMessage: 'Not found',
message: `No fixture for org user: ${match.name}`,
})
}

// For packuments without fixtures, return a stub packument
// This allows tests to work without needing fixtures for every dependency
if (match.type === 'packument') {
Expand Down
36 changes: 36 additions & 0 deletions server/middleware/canonical-redirects.global.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { NPM_REGISTRY } from '#shared/utils/constants'

/**
* Redirect legacy/shorthand URLs to canonical paths.
*
* Handled here:
* - /@org/pkg or /pkg β†’ /package/@org/pkg or /package/pkg
* - /@org/pkg/v/ver or /pkg@ver β†’ /package/@org/pkg/v/ver or /package/pkg/v/ver
* - /@org β†’ /org/org
* - /org/<name> β†’ /~<name> (when <name> is a user account, not an org)
*
* Handled via route aliases (not here):
* - /package/code/* β†’ /package-code/*
Expand Down Expand Up @@ -74,6 +77,39 @@ export default defineEventHandler(async event => {
return
}

// /org/<name> β†’ /~<name> if <name> is a user, not an org (matches npmjs.com).
// NOTE: this must run before the `pages` allowlist check below, since '/org'
// is allowlisted (to protect bare `/org` from the generic /pkg redirect).
// Only exact single-segment /org/<name> paths are checked here; bare `/org`
// and deeper paths still fall through to the allowlist.
// Detection uses /-/org/<name>/user ({} = real org, non-empty = user,
// 404 = neither) β€” /-/org/<name>/package returns 200 for users too, so it
// cannot be used for detection.
const orgPageMatch = path.match(/^\/org\/(?<name>[^/]+)$/)
const orgPageName = orgPageMatch?.groups?.name
if (orgPageName) {
const name = orgPageName.toLowerCase()
try {
const data = await $fetch<Record<string, string>>(
`${NPM_REGISTRY}/-/org/${encodeURIComponent(name)}/user`,
// Bounded lookup: a stalled registry connection must reject (and hit
// the fail-open catch below) instead of hanging the page render.
// retry: 0 keeps the deadline covering the complete lookup.
{ timeout: 5000, retry: 0 },
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (Object.keys(data).length > 0) {
setHeader(event, 'cache-control', cacheControl)
return sendRedirect(event, `/~${name}` + (query ? '?' + query : ''), 301)
}
// {} means real org β€” fall through, let the org page render as normal
} catch {
// 404 (name doesn't exist) or any other error β€” fall through,
// let the org page's own 404 handling take over. Do not throw
// here; a failure in this check must never block rendering the
// org page itself.
}
}

if (pages.some(page => path === page || path.startsWith(page + '/'))) {
return
}
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/url-compatibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ test.describe('npmjs.com URL Compatibility', () => {
// Should show 404 error page
await expect(page.locator('h1')).toContainText('Organization not found')
})

test('/org/<user> redirects to /~<user>', async ({ page, goto }) => {
await goto('/org/qwerzl', { waitUntil: 'domcontentloaded' })
await expect(page).toHaveURL(/\/~qwerzl$/)
})
})

test.describe('npmjs.com activeTab=versions Compatibility', () => {
Expand Down
20 changes: 18 additions & 2 deletions test/fixtures/mock-routes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
'use strict'

const { existsSync, readFileSync } = require('node:fs')
const { join } = require('node:path')
const { join, resolve, sep } = require('node:path')

const FIXTURES_DIR = join(__dirname)

Expand All @@ -21,7 +21,13 @@ const FIXTURES_DIR = join(__dirname)
* @returns {unknown | null}
*/
function readFixture(relativePath) {
const fullPath = join(FIXTURES_DIR, relativePath)
// Containment check: never serve files outside FIXTURES_DIR, even if a
// URL-derived segment contains `..` or (on Windows, after URL decoding)
// a backslash path separator.
const fullPath = resolve(FIXTURES_DIR, relativePath)
if (fullPath !== FIXTURES_DIR && !fullPath.startsWith(FIXTURES_DIR + sep)) {
return null
}
if (!existsSync(fullPath)) {
return null
}
Expand Down Expand Up @@ -134,6 +140,16 @@ function matchNpmRegistry(urlString) {
return json({ error: 'Not found' }, 404)
}

// Org users (distinguishes real orgs from user accounts for /org/<name> redirects)
const orgUserMatch = pathname.match(/^\/-\/org\/([^/]+)\/user$/)
if (orgUserMatch && orgUserMatch[1]) {
const fixture = readFixture(`npm-registry/org-users/${orgUserMatch[1]}.json`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (fixture) {
return json(fixture)
}
return json({ error: 'Not found' }, 404)
}

// Attestations endpoint - return empty attestations
if (pathname.startsWith('/-/npm/v1/attestations/')) {
return json({ attestations: [] })
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/npm-registry/org-users/nuxt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions test/fixtures/npm-registry/org-users/qwerzl.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "qwerzl": "owner" }
1 change: 1 addition & 0 deletions test/fixtures/npm-registry/org-users/testorg.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
107 changes: 107 additions & 0 deletions test/unit/server/middleware/canonical-redirects.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { H3Event } from 'h3'

const fetchMock = vi.fn()
const setHeaderMock = vi.fn()
const sendRedirectMock = vi.fn()

vi.stubGlobal('$fetch', fetchMock)
vi.stubGlobal('setHeader', setHeaderMock)
vi.stubGlobal('sendRedirect', sendRedirectMock)
vi.stubGlobal('defineEventHandler', (fn: Function) => fn)
vi.stubGlobal('getRouteRules', () => ({}))

const handler = (await import('#server/middleware/canonical-redirects.global')).default

function makeEvent(path: string): H3Event {
return { path } as H3Event
}

afterAll(() => {
vi.unstubAllGlobals()
})

describe('canonical-redirects middleware (/org/<name> β†’ /~<name>)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('does not redirect for a real org (/-/org/<name>/user returns {})', async () => {
fetchMock.mockResolvedValue({})

await handler(makeEvent('/org/nuxt'))

expect(fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/-/org/nuxt/user', {
timeout: 5000,
retry: 0,
})
expect(sendRedirectMock).not.toHaveBeenCalled()
})

it('redirects with 301 to /~<name> for a user account (/-/org/<name>/user returns non-empty)', async () => {
fetchMock.mockResolvedValue({ qwerzl: 'owner' })

await handler(makeEvent('/org/qwerzl'))

expect(fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/-/org/qwerzl/user', {
timeout: 5000,
retry: 0,
})
expect(setHeaderMock).toHaveBeenCalledWith(
expect.anything(),
'cache-control',
expect.any(String),
)
expect(sendRedirectMock).toHaveBeenCalledWith(expect.anything(), '/~qwerzl', 301)
})

it('preserves the query string when redirecting a user account', async () => {
fetchMock.mockResolvedValue({ QWERZL: 'owner' })

await handler(makeEvent('/org/QWERZL?tab=members'))

// name is lowercased, matching npmjs.com behavior
expect(sendRedirectMock).toHaveBeenCalledWith(expect.anything(), '/~qwerzl?tab=members', 301)
})

it('does not redirect for a nonexistent name (/-/org/<name>/user 404s), letting the org page 404', async () => {
fetchMock.mockRejectedValue({ statusCode: 404, message: 'Not found' })

await expect(handler(makeEvent('/org/nonexistent-org-12345'))).resolves.toBeUndefined()

expect(fetchMock).toHaveBeenCalledWith(
'https://registry.npmjs.org/-/org/nonexistent-org-12345/user',
{ timeout: 5000, retry: 0 },
)
expect(sendRedirectMock).not.toHaveBeenCalled()
})

it('fails open when the user check itself errors (network error, 500, ...)', async () => {
fetchMock.mockRejectedValue(new Error('network failure'))

await expect(handler(makeEvent('/org/nuxt'))).resolves.toBeUndefined()

expect(sendRedirectMock).not.toHaveBeenCalled()
})

it('fails open on registry timeout (bounded lookup, no retries)', async () => {
const timeoutError = new Error('Request aborted due to timeout')
timeoutError.name = 'TimeoutError'
fetchMock.mockRejectedValue(timeoutError)

await expect(handler(makeEvent('/org/nuxt'))).resolves.toBeUndefined()

expect(fetchMock).toHaveBeenCalledWith(
'https://registry.npmjs.org/-/org/nuxt/user',
expect.objectContaining({ timeout: 5000, retry: 0 }),
)
expect(sendRedirectMock).not.toHaveBeenCalled()
})

it('does not run the user check for non-/org/ paths', async () => {
await handler(makeEvent('/package/vue'))

expect(fetchMock).not.toHaveBeenCalled()
expect(sendRedirectMock).not.toHaveBeenCalled()
})
})
Loading