···11+import type { PackageVersionInfo, PublishTrustLevel } from '#shared/types'
22+import { compare, major } from 'semver'
33+44+export interface PublishSecurityDowngrade {
55+ downgradedVersion: string
66+ downgradedPublishedAt?: string
77+ downgradedTrustLevel: PublishTrustLevel
88+ /** Recommended trusted version within the same major, if one exists */
99+ trustedVersion?: string
1010+ trustedPublishedAt?: string
1111+ trustedTrustLevel: PublishTrustLevel
1212+}
1313+1414+type VersionWithIndex = PackageVersionInfo & {
1515+ index: number
1616+ timestamp: number
1717+ trustRank: number
1818+ resolvedTrustLevel: PublishTrustLevel
1919+}
2020+2121+const TRUST_RANK: Record<PublishTrustLevel, number> = {
2222+ none: 0,
2323+ provenance: 1,
2424+ trustedPublisher: 2,
2525+}
2626+2727+function resolveTrustLevel(version: PackageVersionInfo): PublishTrustLevel {
2828+ if (version.trustLevel) return version.trustLevel
2929+ // Fallback for legacy data: hasProvenance only indicates non-'none' trust,
3030+ // so map it to provenance (the lower rank) to avoid over-ranking
3131+ return version.hasProvenance ? 'provenance' : 'none'
3232+}
3333+3434+function toTimestamp(time?: string): number {
3535+ if (!time) return Number.NaN
3636+ return Date.parse(time)
3737+}
3838+3939+function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number {
4040+ const aValid = !Number.isNaN(a.timestamp)
4141+ const bValid = !Number.isNaN(b.timestamp)
4242+4343+ if (!aValid && !bValid) {
4444+ // Fall back to semver comparison if no valid timestamps
4545+ const semverOrder = compare(b.version, a.version)
4646+ if (semverOrder !== 0) return semverOrder
4747+4848+ // If semver is also equal, maintain original order
4949+ return a.index - b.index
5050+ }
5151+5252+ if (aValid !== bValid) {
5353+ return aValid ? -1 : 1
5454+ }
5555+5656+ return b.timestamp - a.timestamp
5757+}
5858+5959+/**
6060+ * Detects a security downgrade for a specific viewed version.
6161+ * A version is considered downgraded when it has no provenance and
6262+ * there exists an older trusted release.
6363+ */
6464+export function detectPublishSecurityDowngradeForVersion(
6565+ versions: PackageVersionInfo[],
6666+ viewedVersion: string,
6767+): PublishSecurityDowngrade | null {
6868+ if (versions.length < 2 || !viewedVersion) return null
6969+7070+ const sorted = versions
7171+ .map((version, index) => {
7272+ const resolvedTrustLevel = resolveTrustLevel(version)
7373+ return {
7474+ ...version,
7575+ index,
7676+ timestamp: toTimestamp(version.time),
7777+ trustRank: TRUST_RANK[resolvedTrustLevel],
7878+ resolvedTrustLevel,
7979+ }
8080+ })
8181+ .sort(sortByRecency)
8282+8383+ const currentIndex = sorted.findIndex(version => version.version === viewedVersion)
8484+ if (currentIndex === -1) return null
8585+8686+ const current = sorted[currentIndex]
8787+ if (!current) return null
8888+8989+ const currentMajor = major(current.version)
9090+9191+ // Find the strongest older version across all majors (for detection)
9292+ // and the strongest within the same major (for recommendation)
9393+ let strongestOlderAny: VersionWithIndex | null = null
9494+ let strongestOlderSameMajor: VersionWithIndex | null = null
9595+ for (const version of sorted.slice(currentIndex + 1)) {
9696+ // Skip deprecated versions — recommending a deprecated version is misleading
9797+ if (version.deprecated) continue
9898+ if (!strongestOlderAny || version.trustRank > strongestOlderAny.trustRank) {
9999+ strongestOlderAny = version
100100+ }
101101+ if (major(version.version) === currentMajor) {
102102+ if (!strongestOlderSameMajor || version.trustRank > strongestOlderSameMajor.trustRank) {
103103+ strongestOlderSameMajor = version
104104+ }
105105+ }
106106+ }
107107+108108+ // Use same-major for recommendation if available, otherwise any-major for detection only
109109+ const strongestOlder = strongestOlderSameMajor ?? strongestOlderAny
110110+ if (!strongestOlder || strongestOlder.trustRank <= current.trustRank) return null
111111+112112+ // Only recommend a specific version if it's in the same major
113113+ const recommendation = strongestOlderSameMajor
114114+115115+ return {
116116+ downgradedVersion: current.version,
117117+ downgradedPublishedAt: current.time,
118118+ downgradedTrustLevel: current.resolvedTrustLevel,
119119+ trustedVersion: recommendation?.version,
120120+ trustedPublishedAt: recommendation?.time,
121121+ trustedTrustLevel: strongestOlder.resolvedTrustLevel,
122122+ }
123123+}
+10
i18n/locales/en.json
···256256 "view_more_details": "View more details",
257257 "error_loading": "Failed to load provenance details"
258258 },
259259+ "security_downgrade": {
260260+ "title": "Trust downgrade",
261261+ "description_to_none_provenance": "This version was published without {provenance}.",
262262+ "description_to_none_trustedPublisher": "This version was published without {trustedPublishing}.",
263263+ "description_to_provenance_trustedPublisher": "This version uses {provenance} but not {trustedPublishing}.",
264264+ "fallback_install_provenance": "Install commands are pinned to {version}, the last version with provenance.",
265265+ "fallback_install_trustedPublisher": "Install commands are pinned to {version}, the last version with trusted publishing.",
266266+ "provenance_link_text": "provenance",
267267+ "trusted_publishing_link_text": "trusted publishing"
268268+ },
259269 "keywords_title": "Keywords",
260270 "compatibility": "Compatibility",
261271 "card": {
···255255 "view_more_details": "View more details",
256256 "error_loading": "Failed to load provenance details"
257257 },
258258+ "security_downgrade": {
259259+ "title": "Trust downgrade",
260260+ "description_to_none_provenance": "This version was published without {provenance}.",
261261+ "description_to_none_trustedPublisher": "This version was published without {trustedPublishing}.",
262262+ "description_to_provenance_trustedPublisher": "This version uses {provenance} but not {trustedPublishing}.",
263263+ "fallback_install_provenance": "Install commands are pinned to {version}, the last version with provenance.",
264264+ "fallback_install_trustedPublisher": "Install commands are pinned to {version}, the last version with trusted publishing.",
265265+ "provenance_link_text": "provenance",
266266+ "trusted_publishing_link_text": "trusted publishing"
267267+ },
258268 "keywords_title": "Keywords",
259269 "compatibility": "Compatibility",
260270 "card": {
+10
lunaria/files/en-US.json
···255255 "view_more_details": "View more details",
256256 "error_loading": "Failed to load provenance details"
257257 },
258258+ "security_downgrade": {
259259+ "title": "Trust downgrade",
260260+ "description_to_none_provenance": "This version was published without {provenance}.",
261261+ "description_to_none_trustedPublisher": "This version was published without {trustedPublishing}.",
262262+ "description_to_provenance_trustedPublisher": "This version uses {provenance} but not {trustedPublishing}.",
263263+ "fallback_install_provenance": "Install commands are pinned to {version}, the last version with provenance.",
264264+ "fallback_install_trustedPublisher": "Install commands are pinned to {version}, the last version with trusted publishing.",
265265+ "provenance_link_text": "provenance",
266266+ "trusted_publishing_link_text": "trusted publishing"
267267+ },
258268 "keywords_title": "Keywords",
259269 "compatibility": "Compatibility",
260270 "card": {
+23-5
shared/types/npm-registry.ts
···66 * @see https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md
77 */
8899-import type { Packument as PackumentWithoutLicenseObjects, PackumentVersion } from '@npm/types'
99+import type {
1010+ Packument as PackumentWithoutLicenseObjects,
1111+ PackumentVersion as PackumentVersionWithoutAttestations,
1212+ Contact,
1313+} from '@npm/types'
1014import type { ReadmeResponse } from './readme'
11151216// Re-export official npm types for packument/manifest
1313-export type { PackumentVersion, Manifest, ManifestVersion, PackageJSON } from '@npm/types'
1717+export type { Manifest, ManifestVersion, PackageJSON } from '@npm/types'
14181515-// TODO: Remove this type override when @npm/types fixes the license field typing
1616-export type Packument = Omit<PackumentWithoutLicenseObjects, 'license'> & {
1919+type NpmTrustedPublisherEvidence = NpmSearchTrustedPublisher | NpmTrustedPublisher | true
2020+2121+export interface PackumentVersion extends PackumentVersionWithoutAttestations {
2222+ _npmUser?: Contact & { trustedPublisher?: NpmTrustedPublisherEvidence }
2323+ dist: PackumentVersionWithoutAttestations['dist'] & { attestations?: NpmVersionAttestations }
2424+}
2525+2626+export type Packument = Omit<PackumentWithoutLicenseObjects, 'license' | 'versions'> & {
1727 // Fix for license field being incorrectly typed in @npm/types
2828+ // TODO: Remove this type override when @npm/types fixes the license field typing
1829 license?: string | { type: string; url?: string }
3030+ versions: Record<string, PackumentVersion>
1931}
20322133/** Install scripts info (preinstall, install, postinstall) */
···3042 installScripts?: InstallScriptsInfo
3143}
32444545+export type PublishTrustLevel = 'none' | 'trustedPublisher' | 'provenance'
4646+3347export type SlimVersion = Pick<SlimPackumentVersion, 'version' | 'deprecated' | 'tags'> & {
3434- hasProvenance?: true
4848+ hasProvenance?: boolean
4949+ trustLevel?: PublishTrustLevel
3550}
36513752/**
···7287 'requestedVersion': SlimPackumentVersion | null
7388 /** Only includes dist-tag versions (with installScripts info added per version) */
7489 'versions': Record<string, SlimVersion>
9090+ /** Lightweight security metadata for all versions */
9191+ 'securityVersions'?: PackageVersionInfo[]
7592}
76937794/**
···8198 version: string
8299 time?: string
83100 hasProvenance: boolean
101101+ trustLevel?: PublishTrustLevel
84102 deprecated?: string
85103}
86104