Skip to content

Commit 9fe53bd

Browse files
committed
feat: add provenance badges
1 parent 0fbd7f9 commit 9fe53bd

8 files changed

Lines changed: 200 additions & 33 deletions

File tree

app/components/ProvenanceBadge.vue

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
/** Provider ID (e.g., "github", "gitlab") */
4+
provider?: string
5+
/** Package name for linking to npmjs.com provenance page */
6+
packageName?: string
7+
/** Package version for linking */
8+
version?: string
9+
/** Whether to show as compact (icon only) or full (with text) */
10+
compact?: boolean
11+
}>()
12+
13+
const providerLabels: Record<string, string> = {
14+
github: 'GitHub Actions',
15+
gitlab: 'GitLab CI',
16+
}
17+
</script>
18+
19+
<template>
20+
<a
21+
v-if="packageName && version"
22+
:href="`https://www.npmjs.com/package/${packageName}/v/${version}#provenance`"
23+
target="_blank"
24+
rel="noopener noreferrer"
25+
class="inline-flex items-center gap-1 text-xs font-mono text-fg-muted hover:text-fg transition-colors duration-200"
26+
:title="provider ? `Verified: published via ${providerLabels[provider] ?? provider}` : 'Verified provenance'"
27+
>
28+
<span
29+
class="i-solar-shield-check-outline shrink-0"
30+
:class="compact ? 'w-3.5 h-3.5' : 'w-4 h-4'"
31+
/>
32+
<span
33+
v-if="!compact"
34+
class="sr-only sm:not-sr-only"
35+
>verified</span>
36+
</a>
37+
<span
38+
v-else
39+
class="inline-flex items-center gap-1 text-xs font-mono text-fg-muted"
40+
:title="provider ? `Verified: published via ${providerLabels[provider] ?? provider}` : 'Verified provenance'"
41+
>
42+
<span
43+
class="i-solar-shield-check-outline shrink-0"
44+
:class="compact ? 'w-3.5 h-3.5' : 'w-4 h-4'"
45+
/>
46+
<span
47+
v-if="!compact"
48+
class="sr-only sm:not-sr-only"
49+
>verified</span>
50+
</span>
51+
</template>

app/pages/package/[...name].vue

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import type { PackumentVersion } from '#shared/types'
2+
import type { PackumentVersion, NpmVersionDist } from '#shared/types'
33
44
const route = useRoute('package-name')
55
@@ -124,6 +124,14 @@ function getDependencyCount(version: PackumentVersion | null): number {
124124
return Object.keys(version.dependencies).length
125125
}
126126
127+
// Check if a version has provenance/attestations
128+
// The dist object may have attestations that aren't in the base type
129+
function hasProvenance(version: PackumentVersion | null): boolean {
130+
if (!version?.dist) return false
131+
const dist = version.dist as NpmVersionDist
132+
return !!dist.attestations
133+
}
134+
127135
// Package manager install commands
128136
const packageManagers = [
129137
{ id: 'npm', label: 'npm', action: 'install' },
@@ -229,16 +237,26 @@ defineOgImageComponent('Package', {
229237
<h1 class="font-mono text-2xl sm:text-3xl font-medium">
230238
{{ pkg.name }}
231239
</h1>
232-
<span
240+
<a
233241
v-if="displayVersion"
234-
class="shrink-0 px-3 py-1 font-mono text-sm bg-bg-muted border border-border rounded-md"
242+
:href="hasProvenance(displayVersion) ? `https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance` : undefined"
243+
:target="hasProvenance(displayVersion) ? '_blank' : undefined"
244+
:rel="hasProvenance(displayVersion) ? 'noopener noreferrer' : undefined"
245+
class="shrink-0 inline-flex items-center gap-1.5 px-3 py-1 font-mono text-sm bg-bg-muted border border-border rounded-md transition-colors duration-200"
246+
:class="hasProvenance(displayVersion) ? 'hover:border-border-hover cursor-pointer' : 'cursor-default'"
247+
:title="hasProvenance(displayVersion) ? 'Verified provenance' : undefined"
235248
>
236249
v{{ displayVersion.version }}
237250
<span
238251
v-if="requestedVersion && latestVersion && displayVersion.version !== latestVersion.version"
239252
class="text-fg-subtle"
240253
>(not latest)</span>
241-
</span>
254+
<span
255+
v-if="hasProvenance(displayVersion)"
256+
class="i-solar-shield-check-outline w-4 h-4 text-fg-muted"
257+
aria-label="Verified provenance"
258+
/>
259+
</a>
242260
</div>
243261
<!-- Fixed height description container to prevent CLS -->
244262
<div
@@ -479,7 +497,7 @@ defineOgImageComponent('Package', {
479497
</div>
480498
<div class="flex items-center gap-2 px-4 pt-3 pb-4">
481499
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
482-
<code class="font-mono text-sm"><ClientOnly><span class="text-fg">{{ selectedPMLabel }}</span> <span class="text-fg-muted">{{ selectedPMAction }}</span><span
500+
<code class="font-mono text-sm"><ClientOnly><span class="text-fg">{{ selectedPMLabel }}</span> <span class="text-fg-muted">{{ selectedPMAction }}</span> <span
483501
v-if="selectedPM !== 'deno'"
484502
class="text-fg-muted"
485503
> {{ pkg.name }}</span><span
@@ -609,25 +627,33 @@ defineOgImageComponent('Package', {
609627
<div
610628
v-for="version in sortedVersions.slice(0, 10)"
611629
:key="version"
612-
class="flex items-center justify-between py-1.5 text-sm"
630+
class="flex items-center justify-between py-1.5 text-sm gap-2"
613631
>
614632
<NuxtLink
615633
:to="`/package/${pkg.name}/v/${version}`"
616-
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200"
634+
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 min-w-0"
617635
>
618636
{{ version }}
619637
<span
620638
v-if="pkg['dist-tags']?.latest === version"
621639
class="ml-1 text-xs text-fg-subtle"
622640
>(latest)</span>
623641
</NuxtLink>
624-
<time
625-
v-if="pkg.time[version]"
626-
:datetime="pkg.time[version]"
627-
class="text-xs text-fg-subtle"
628-
>
629-
{{ formatDate(pkg.time[version]) }}
630-
</time>
642+
<div class="flex items-center gap-2 shrink-0">
643+
<time
644+
v-if="pkg.time[version]"
645+
:datetime="pkg.time[version]"
646+
class="text-xs text-fg-subtle"
647+
>
648+
{{ formatDate(pkg.time[version]) }}
649+
</time>
650+
<ProvenanceBadge
651+
v-if="pkg.versions[version] && hasProvenance(pkg.versions[version])"
652+
:package-name="pkg.name"
653+
:version="version"
654+
compact
655+
/>
656+
</div>
631657
</div>
632658
</div>
633659
</section>
@@ -648,11 +674,13 @@ defineOgImageComponent('Package', {
648674
:href="`https://npmgraph.js.org/?q=${pkg.name}`"
649675
target="_blank"
650676
rel="noopener noreferrer"
651-
class="link-subtle"
677+
class="link-subtle text-fg-subtle"
652678
aria-label="View dependency graph"
653679
title="View dependency graph"
654680
>
655-
<span class="i-carbon-network-3 w-4 h-4 inline-block" />
681+
<span class="text-xs uppercase tracking-wider">
682+
Graph
683+
</span>
656684
</a>
657685
</div>
658686
<ul class="space-y-1 list-none m-0 p-0">

app/pages/search.vue

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,21 @@ defineOgImageComponent('Default', {
320320
<h2 class="font-mono text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all">
321321
{{ result.package.name }}
322322
</h2>
323-
<span
324-
v-if="result.package.version"
325-
class="font-mono text-xs text-fg-subtle shrink-0"
326-
>
327-
v{{ result.package.version }}
328-
</span>
323+
<div class="flex items-center gap-1.5 shrink-0">
324+
<span
325+
v-if="result.package.version"
326+
class="font-mono text-xs text-fg-subtle"
327+
>
328+
v{{ result.package.version }}
329+
</span>
330+
<ProvenanceBadge
331+
v-if="result.package.publisher?.trustedPublisher"
332+
:provider="result.package.publisher.trustedPublisher.id"
333+
:package-name="result.package.name"
334+
:version="result.package.version"
335+
compact
336+
/>
337+
</div>
329338
</header>
330339

331340
<p

app/pages/~[username].vue

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,21 @@ defineOgImageComponent('Default', {
142142
<h3 class="font-mono text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all">
143143
{{ result.package.name }}
144144
</h3>
145-
<span
146-
v-if="result.package.version"
147-
class="font-mono text-xs text-fg-subtle shrink-0"
148-
>
149-
v{{ result.package.version }}
150-
</span>
145+
<div class="flex items-center gap-1.5 shrink-0">
146+
<span
147+
v-if="result.package.version"
148+
class="font-mono text-xs text-fg-subtle"
149+
>
150+
v{{ result.package.version }}
151+
</span>
152+
<ProvenanceBadge
153+
v-if="result.package.publisher?.trustedPublisher"
154+
:provider="result.package.publisher.trustedPublisher.id"
155+
:package-name="result.package.name"
156+
:version="result.package.version"
157+
compact
158+
/>
159+
</div>
151160
</header>
152161

153162
<p

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default defineNuxtConfig({
4040
routeRules: {
4141
'/': { prerender: true },
4242
'/package/**': { isr: 60 },
43+
'/~**': { isr: 60 },
4344
'/api/**': { isr: 60 },
4445
'/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
4546
'/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
},
4343
"devDependencies": {
4444
"@iconify-json/carbon": "^1.2.18",
45+
"@iconify-json/solar": "^1.2.5",
4546
"@npm/types": "^2.1.0",
4647
"@nuxt/test-utils": "3.23.0",
4748
"@playwright/test": "1.57.0",

pnpm-lock.yaml

Lines changed: 14 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shared/types/npm-registry.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@ export interface NpmSearchResult {
4141
}
4242
}
4343

44+
/**
45+
* Trusted publisher info from search API
46+
* Present when package was published via OIDC (e.g., GitHub Actions)
47+
*/
48+
export interface NpmSearchTrustedPublisher {
49+
/** OIDC provider identifier (e.g., "github", "gitlab") */
50+
id: string
51+
/** OIDC config ID */
52+
oidcConfigId?: string
53+
}
54+
55+
/**
56+
* Publisher info with optional trusted publisher and actor details
57+
*/
58+
export interface NpmSearchPublisher extends NpmPerson {
59+
/** Trusted publisher info (present if published via OIDC) */
60+
trustedPublisher?: NpmSearchTrustedPublisher
61+
/** Actor who triggered the publish (for trusted publishing) */
62+
actor?: {
63+
name: string
64+
type: 'user' | 'team'
65+
email?: string
66+
}
67+
}
68+
4469
export interface NpmSearchPackage {
4570
name: string
4671
scope?: string
@@ -55,7 +80,7 @@ export interface NpmSearchPackage {
5580
bugs?: string
5681
}
5782
author?: NpmPerson
58-
publisher?: NpmPerson
83+
publisher?: NpmSearchPublisher
5984
maintainers?: NpmPerson[]
6085
}
6186

@@ -68,6 +93,39 @@ export interface NpmSearchScore {
6893
}
6994
}
7095

96+
/**
97+
* Attestations/provenance info on package version dist
98+
* Present when package was published with provenance
99+
* Note: Not covered by @npm/types
100+
*/
101+
export interface NpmVersionAttestations {
102+
/** URL to fetch full attestation details */
103+
url: string
104+
/** Provenance info */
105+
provenance: {
106+
/** SLSA predicate type URL */
107+
predicateType: string
108+
}
109+
}
110+
111+
/**
112+
* Extended dist info that may include attestations
113+
* The base PackumentVersion.dist doesn't include attestations
114+
*/
115+
export interface NpmVersionDist {
116+
shasum: string
117+
tarball: string
118+
integrity?: string
119+
fileCount?: number
120+
unpackedSize?: number
121+
signatures?: Array<{
122+
keyid: string
123+
sig: string
124+
}>
125+
/** Attestations/provenance (present if published with provenance) */
126+
attestations?: NpmVersionAttestations
127+
}
128+
71129
/**
72130
* Download counts API response
73131
* From https://api.npmjs.org/downloads/

0 commit comments

Comments
 (0)