diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index baae299150..7f16200f87 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -1,5 +1,6 @@ -import { getTrustLevel, getTrustStatus } from 'packumeta' +import { getTrustStatus } from 'packumeta' import { normalizeLicense } from '#shared/utils/npm' +import { detectSecurityDowngradeForVersion } from '~/utils/publish-security' /** Number of recent versions to include in initial payload */ const RECENT_VERSIONS_COUNT = 5 @@ -37,21 +38,16 @@ export function transformPackument( includedVersions.add(requestedVersion) } - // Build security metadata for all versions, but only include in payload - // when the package has mixed trust levels (i.e. a downgrade could exist) - const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => { - const trustStatus = getTrustStatus(metadata) - return { - version, - time: pkg.time[version], - trustStatus, - deprecated: metadata.deprecated, - } - }) - - const trustLevels = new Set(securityVersionEntries.map(v => getTrustLevel(v.trustStatus))) - const hasMixedTrust = trustLevels.size > 1 - const securityVersions = hasMixedTrust ? securityVersionEntries : undefined + // Computed from the full version history so only the small result is serialized. + const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => ({ + version, + time: pkg.time[version], + trustStatus: getTrustStatus(metadata), + deprecated: metadata.deprecated, + })) + const publishSecurityDowngrade = requestedVersion + ? detectSecurityDowngradeForVersion(securityVersionEntries, requestedVersion) + : null // Build filtered versions object with install scripts info per version const filteredVersions: Record = {} @@ -122,7 +118,7 @@ export function transformPackument( ...(storybook && { storybook }), 'requestedVersion': versionData, 'versions': filteredVersions, - 'securityVersions': securityVersions, + 'publishSecurityDowngrade': publishSecurityDowngrade, } } diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 1cb7073cb8..a4d3dc49db 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -246,17 +246,6 @@ if (isSpaFallback.value || isHydratingWithServerContent.value) { } const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) -const versionSecurityMetadata = computed(() => { - if (!pkg.value) return [] - if (pkg.value.securityVersions?.length) return pkg.value.securityVersions - - return Object.entries(pkg.value.versions).map(([version, metadata]) => ({ - version, - time: pkg.value?.time?.[version], - trustStatus: metadata.trustStatus, - deprecated: metadata.deprecated, - })) -}) // Process package description const pkgDescription = useMarkdown(() => ({ @@ -329,11 +318,7 @@ const deprecationNoticeMessage = useMarkdown(() => ({ text: deprecationNotice.value?.message ?? '', })) -const publishSecurityDowngrade = computed(() => { - const currentVersion = displayVersion.value?.version - if (!currentVersion) return null - return detectPublishSecurityDowngradeForVersion(versionSecurityMetadata.value, currentVersion) -}) +const publishSecurityDowngrade = computed(() => pkg.value?.publishSecurityDowngrade ?? null) const installVersionOverride = computed( () => publishSecurityDowngrade.value?.trustedVersion ?? null, diff --git a/app/utils/publish-security.ts b/app/utils/publish-security.ts index cf3e0c95f1..137465b0b5 100644 --- a/app/utils/publish-security.ts +++ b/app/utils/publish-security.ts @@ -1,15 +1,8 @@ import { getTrustLevel, getTrustLevelName, type TrustLevelName } from 'packumeta' import { compare, getMajor } from 'verkit' +import type { PublishSecurityDowngrade } from '#shared/types/npm-registry' -export interface PublishSecurityDowngrade { - downgradedVersion: string - downgradedPublishedAt?: string - downgradedTrustLevel: TrustLevelName - /** Recommended trusted version within the same major, if one exists */ - trustedVersion?: string - trustedPublishedAt?: string - trustedTrustLevel: TrustLevelName -} +export type { PublishSecurityDowngrade } type VersionWithIndex = PackageVersionInfo & { index: number @@ -48,7 +41,7 @@ function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number { * A version is considered downgraded when it has no provenance and * there exists an older trusted release. */ -export function detectPublishSecurityDowngradeForVersion( +export function detectSecurityDowngradeForVersion( versions: PackageVersionInfo[], viewedVersion: string, ): PublishSecurityDowngrade | null { diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index 46ec7caee1..d59f95e135 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -12,7 +12,7 @@ import type { Contact, } from '@npm/types' import type { ReadmeResponse } from './readme' -import type { TrustStatus } from 'packumeta' +import type { TrustLevelName, TrustStatus } from 'packumeta' // Re-export official npm types for packument/manifest export type { Manifest, ManifestVersion, PackageJSON } from '@npm/types' @@ -91,8 +91,24 @@ export interface SlimPackument { 'requestedVersion': SlimPackumentVersion | null /** Only includes dist-tag versions (with installScripts info added per version) */ 'versions': Record - /** Lightweight security metadata for all versions */ - 'securityVersions'?: PackageVersionInfo[] + /** + * Publish security downgrade for the requested version, or `null`. + * Computed server-side so the payload only carries the small result, not the full version history. + */ + 'publishSecurityDowngrade'?: PublishSecurityDowngrade | null +} + +/** + * A publish-security downgrade detected for a specific viewed version. + */ +export interface PublishSecurityDowngrade { + downgradedVersion: string + downgradedPublishedAt?: string + downgradedTrustLevel: TrustLevelName + /** Recommended trusted version within the same major, if one exists */ + trustedVersion?: string + trustedPublishedAt?: string + trustedTrustLevel: TrustLevelName } /** diff --git a/test/nuxt/composables/use-package-transform.spec.ts b/test/nuxt/composables/use-package-transform.spec.ts index a97e1bc0f8..20e3c13ffd 100644 --- a/test/nuxt/composables/use-package-transform.spec.ts +++ b/test/nuxt/composables/use-package-transform.spec.ts @@ -67,18 +67,6 @@ function createPackument( } } -function toVersionInfos(packument: ReturnType): PackageVersionInfo[] { - return ( - packument.securityVersions ?? - Object.entries(packument.versions).map(([version, metadata]) => ({ - version, - time: packument.time[version], - trustStatus: metadata.trustStatus, - deprecated: metadata.deprecated, - })) - ) -} - describe('transformPackument', () => { it('includes requested old version and preserves provenance on it', () => { const packument = createPackument( @@ -116,10 +104,11 @@ describe('transformPackument', () => { }) expect(transformed.versions['1.0.1']).toBeUndefined() expect(transformed.versions['1.0.2']).toBeUndefined() - expect(transformed.securityVersions).toHaveLength(8) + // 1.0.0 is the oldest and trusted, so the viewed version is not a downgrade + expect(transformed.publishSecurityDowngrade).toBeNull() }) - it('omits securityVersions when all versions have the same trust level', () => { + it('computes no downgrade when all versions have the same trust level', () => { const packument = createPackument( { '1.0.0': createVersion('1.0.0'), @@ -138,11 +127,11 @@ describe('transformPackument', () => { const transformed = transformPackument(packument, '1.0.2') - // All versions have trustLevel 'none', so no mixed trust — omit the array - expect(transformed.securityVersions).toBeUndefined() + // All versions have trustLevel 'none', so nothing to detect + expect(transformed.publishSecurityDowngrade).toBeNull() }) - it('includes securityVersions when package has mixed trust levels', () => { + it('detects a downgrade when the package has mixed trust levels', () => { const packument = createPackument( { '1.0.0': createVersion('1.0.0', true), @@ -159,10 +148,17 @@ describe('transformPackument', () => { const transformed = transformPackument(packument, '1.0.1') - expect(transformed.securityVersions).toHaveLength(2) + expect(transformed.publishSecurityDowngrade).toEqual({ + downgradedVersion: '1.0.1', + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', + downgradedTrustLevel: 'none', + trustedVersion: '1.0.0', + trustedPublishedAt: '2026-01-01T00:00:00.000Z', + trustedTrustLevel: 'provenance', + }) }) - it('works with downgrade detection for viewed version', () => { + it('computes the downgrade for the requested version only', () => { const packument = createPackument( { '1.0.0': createVersion('1.0.0', true), @@ -179,11 +175,8 @@ describe('transformPackument', () => { '1.0.2', ) - const transformed = transformPackument(packument, '1.0.1') - const infos = toVersionInfos(transformed) - - expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.2')).toBeNull() - expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')).toEqual({ + expect(transformPackument(packument, '1.0.2').publishSecurityDowngrade).toBeNull() + expect(transformPackument(packument, '1.0.1').publishSecurityDowngrade).toEqual({ downgradedVersion: '1.0.1', downgradedPublishedAt: '2026-01-02T00:00:00.000Z', downgradedTrustLevel: 'none', @@ -211,14 +204,13 @@ describe('transformPackument', () => { ) const transformed = transformPackument(packument, '1.0.1') - const infos = toVersionInfos(transformed) - expect(infos.find(v => v.version === '1.0.0')?.trustStatus).toEqual({ + expect(transformed.versions['1.0.0']?.trustStatus).toEqual({ provenance: true, trustedPublisher: true, stagedPublish: false, }) - expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')?.trustedVersion).toBe('1.0.0') + expect(transformed.publishSecurityDowngrade?.trustedVersion).toBe('1.0.0') }) it('prefers trustedPublisher trust level when both trustedPublisher and attestations exist', () => { @@ -264,20 +256,19 @@ describe('transformPackument', () => { ) const transformed = transformPackument(packument, '7.0.1') - const infos = toVersionInfos(transformed) // Both versions should be trustedPublisher — no downgrade - expect(infos.find(v => v.version === '7.0.0')?.trustStatus).toEqual({ + expect(transformed.versions['7.0.0']?.trustStatus).toEqual({ provenance: true, trustedPublisher: true, stagedPublish: false, }) - expect(infos.find(v => v.version === '7.0.1')?.trustStatus).toEqual({ + expect(transformed.versions['7.0.1']?.trustStatus).toEqual({ provenance: true, trustedPublisher: true, stagedPublish: false, }) - expect(detectPublishSecurityDowngradeForVersion(infos, '7.0.1')).toBeNull() + expect(transformed.publishSecurityDowngrade).toBeNull() }) it('flags non-direct downgrade chain until trust is restored', () => { @@ -301,12 +292,15 @@ describe('transformPackument', () => { '2.4.0', ) - const transformed = transformPackument(packument, '2.3.0') - const infos = toVersionInfos(transformed) - - expect(detectPublishSecurityDowngradeForVersion(infos, '2.1.1')?.trustedVersion).toBe('2.1.0') - expect(detectPublishSecurityDowngradeForVersion(infos, '2.2.0')?.trustedVersion).toBe('2.1.0') - expect(detectPublishSecurityDowngradeForVersion(infos, '2.3.0')?.trustedVersion).toBe('2.1.0') - expect(detectPublishSecurityDowngradeForVersion(infos, '2.4.0')).toBeNull() + expect(transformPackument(packument, '2.1.1').publishSecurityDowngrade?.trustedVersion).toBe( + '2.1.0', + ) + expect(transformPackument(packument, '2.2.0').publishSecurityDowngrade?.trustedVersion).toBe( + '2.1.0', + ) + expect(transformPackument(packument, '2.3.0').publishSecurityDowngrade?.trustedVersion).toBe( + '2.1.0', + ) + expect(transformPackument(packument, '2.4.0').publishSecurityDowngrade).toBeNull() }) }) diff --git a/test/unit/app/utils/publish-security.spec.ts b/test/unit/app/utils/publish-security.spec.ts index 8c30c6f08e..20934c024b 100644 --- a/test/unit/app/utils/publish-security.spec.ts +++ b/test/unit/app/utils/publish-security.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security' +import { detectSecurityDowngradeForVersion } from '~/utils/publish-security' -describe('detectPublishSecurityDowngradeForVersion', () => { +describe('detectSecurityDowngradeForVersion', () => { const versions = [ { version: '1.0.0', @@ -21,12 +21,12 @@ describe('detectPublishSecurityDowngradeForVersion', () => { ] it('does not flag trusted viewed version (1.0.2)', () => { - const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.2') + const result = detectSecurityDowngradeForVersion(versions, '1.0.2') expect(result).toBeNull() }) it('flags downgraded viewed version (1.0.1)', () => { - const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.1') + const result = detectSecurityDowngradeForVersion(versions, '1.0.1') expect(result).toEqual({ downgradedVersion: '1.0.1', downgradedPublishedAt: '2026-01-02T00:00:00.000Z', @@ -38,7 +38,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => { }) it('flags trust downgrade from trustedPublisher to provenance', () => { - const result = detectPublishSecurityDowngradeForVersion( + const result = detectSecurityDowngradeForVersion( [ { version: '1.0.0', @@ -65,7 +65,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => { }) it('does not flag upgrade from provenance to trustedPublisher', () => { - const result = detectPublishSecurityDowngradeForVersion( + const result = detectSecurityDowngradeForVersion( [ { version: '1.0.0', @@ -113,20 +113,20 @@ describe('detectPublishSecurityDowngradeForVersion', () => { }, ] - expect( - detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.1.1')?.trustedVersion, - ).toBe('2.1.0') - expect( - detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.2.0')?.trustedVersion, - ).toBe('2.1.0') - expect( - detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.3.0')?.trustedVersion, - ).toBe('2.1.0') - expect(detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.4.0')).toBeNull() + expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.1.1')?.trustedVersion).toBe( + '2.1.0', + ) + expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.2.0')?.trustedVersion).toBe( + '2.1.0', + ) + expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.3.0')?.trustedVersion).toBe( + '2.1.0', + ) + expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.4.0')).toBeNull() }) it('skips deprecated versions when selecting trustedVersion', () => { - const result = detectPublishSecurityDowngradeForVersion( + const result = detectSecurityDowngradeForVersion( [ { version: '1.0.0', @@ -153,7 +153,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => { }) it('returns null when all older trusted versions are deprecated', () => { - const result = detectPublishSecurityDowngradeForVersion( + const result = detectSecurityDowngradeForVersion( [ { version: '1.0.0', @@ -174,7 +174,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => { }) it('detects cross-major downgrade but does not recommend a version', () => { - const result = detectPublishSecurityDowngradeForVersion( + const result = detectSecurityDowngradeForVersion( [ { version: '1.0.0', @@ -198,7 +198,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => { }) it('recommends same-major trusted version when cross-major exists', () => { - const result = detectPublishSecurityDowngradeForVersion( + const result = detectSecurityDowngradeForVersion( [ { version: '1.0.0',