Skip to content

Commit bf0c8f7

Browse files
AscaLautofix-ci[bot]OrbisKokineadevdanielroe
authored
feat: add provenance to end of README and provenance badge (#436)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Robin <robin.kehl@singular-it.de> Co-authored-by: Okinea Dev <hi@okinea.dev> Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 7605ffc commit bf0c8f7

File tree

12 files changed

+493
-15
lines changed

12 files changed

+493
-15
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ What npmx offers:
4444
- **Fast search** &ndash; quick package search with instant results
4545
- **Package details** &ndash; READMEs, versions, dependencies, and metadata
4646
- **Code viewer** &ndash; browse package source code with syntax highlighting and permalink to specific lines
47-
- **Provenance indicators** &ndash; verified build badges for packages with npm provenance
47+
- **Provenance indicators** &ndash; verified build badges and provenance section below the README
4848
- **Multi-provider repository support** &ndash; stars/forks from GitHub, GitLab, Bitbucket, Codeberg, Gitee, Sourcehut, Forgejo, Gitea, Radicle, and Tangled
4949
- **JSR availability** &ndash; see if scoped packages are also available on JSR
5050
- **Package badges** &ndash; module format (ESM/CJS/dual), TypeScript types (with `@types/*` links), and engine constraints
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<script setup lang="ts">
2+
import type { ProvenanceDetails } from '#shared/types'
3+
4+
defineProps<{
5+
details: ProvenanceDetails
6+
}>()
7+
</script>
8+
9+
<template>
10+
<section aria-labelledby="provenance-heading" class="scroll-mt-20">
11+
<h2 id="provenance-heading" class="group text-xs text-fg-subtle uppercase tracking-wider mb-3">
12+
<a
13+
href="#provenance"
14+
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
15+
>
16+
{{ $t('package.provenance_section.title') }}
17+
<span
18+
class="i-carbon-link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
19+
aria-hidden="true"
20+
/>
21+
</a>
22+
</h2>
23+
24+
<div class="space-y-3 border border-border rounded-lg p-5">
25+
<p class="flex items-center gap-2 text-sm text-fg m-0">
26+
<span class="i-lucide-shield-check w-4 h-4 shrink-0 text-emerald-500" aria-hidden="true" />
27+
<i18n-t keypath="package.provenance_section.built_and_signed_on" tag="span">
28+
<template #provider>
29+
<strong>{{ details.providerLabel }}</strong>
30+
</template>
31+
</i18n-t>
32+
</p>
33+
<a
34+
v-if="details.buildSummaryUrl"
35+
:href="details.buildSummaryUrl"
36+
target="_blank"
37+
rel="noopener noreferrer"
38+
class="link text-sm text-fg-muted block mt-1"
39+
>
40+
{{ $t('package.provenance_section.view_build_summary') }}
41+
</a>
42+
43+
<dl class="m-0 mt-4 flex justify-between">
44+
<div v-if="details.sourceCommitUrl" class="flex flex-col gap-0.5">
45+
<dt class="font-mono text-xs text-fg-muted m-0">
46+
{{ $t('package.provenance_section.source_commit') }}
47+
</dt>
48+
<dd class="m-0">
49+
<a
50+
:href="details.sourceCommitUrl"
51+
target="_blank"
52+
rel="noopener noreferrer"
53+
class="link font-mono text-sm break-all"
54+
>
55+
{{
56+
details.sourceCommitSha
57+
? `${details.sourceCommitSha.slice(0, 12)}`
58+
: details.sourceCommitUrl
59+
}}
60+
</a>
61+
</dd>
62+
</div>
63+
<div v-if="details.buildFileUrl" class="flex flex-col gap-0.5">
64+
<dt class="font-mono text-xs text-fg-muted m-0">
65+
{{ $t('package.provenance_section.build_file') }}
66+
</dt>
67+
<dd class="m-0">
68+
<a
69+
:href="details.buildFileUrl"
70+
target="_blank"
71+
rel="noopener noreferrer"
72+
class="link font-mono text-sm break-all"
73+
>
74+
{{ details.buildFilePath ?? details.buildFileUrl }}
75+
</a>
76+
</dd>
77+
</div>
78+
<div v-if="details.publicLedgerUrl" class="flex flex-col gap-0.5">
79+
<dt class="font-mono text-xs text-fg-muted m-0">
80+
{{ $t('package.provenance_section.public_ledger') }}
81+
</dt>
82+
<dd class="m-0">
83+
<a
84+
:href="details.publicLedgerUrl"
85+
target="_blank"
86+
rel="noopener noreferrer"
87+
class="link text-sm"
88+
>
89+
{{ $t('package.provenance_section.transparency_log_entry') }}
90+
</a>
91+
</dd>
92+
</div>
93+
</dl>
94+
</div>
95+
</section>
96+
</template>

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

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type {
33
NpmVersionDist,
44
PackumentVersion,
5+
ProvenanceDetails,
56
ReadmeResponse,
67
SkillsListResponse,
78
} from '#shared/types'
@@ -158,6 +159,39 @@ const { data: vulnTree, status: vulnTreeStatus } = useDependencyAnalysis(
158159
() => resolvedVersion.value ?? '',
159160
)
160161
162+
const {
163+
data: provenanceData,
164+
status: provenanceStatus,
165+
execute: fetchProvenance,
166+
} = useLazyFetch<ProvenanceDetails | null>(
167+
() => {
168+
const v = displayVersion.value
169+
if (!v || !hasProvenance(v)) return ''
170+
return `/api/registry/provenance/${packageName.value}/v/${v.version}`
171+
},
172+
{
173+
default: () => null,
174+
server: false,
175+
immediate: false,
176+
},
177+
)
178+
if (import.meta.client) {
179+
watch(
180+
displayVersion,
181+
v => {
182+
if (v && hasProvenance(v) && provenanceStatus.value === 'idle') {
183+
fetchProvenance()
184+
}
185+
},
186+
{ immediate: true },
187+
)
188+
}
189+
190+
const provenanceBadgeMounted = shallowRef(false)
191+
onMounted(() => {
192+
provenanceBadgeMounted.value = true
193+
})
194+
161195
// Keep latestVersion for comparison (to show "(latest)" badge)
162196
const latestVersion = computed(() => {
163197
if (!pkg.value) return null
@@ -523,16 +557,26 @@ defineOgImageComponent('Package', {
523557
>
524558
<span v-else>v{{ resolvedVersion }}</span>
525559

526-
<a
527-
v-if="hasProvenance(displayVersion)"
528-
:href="`https://www.npmjs.com/package/${pkg.name}/v/${resolvedVersion}#provenance`"
529-
target="_blank"
530-
rel="noopener noreferrer"
531-
class="inline-flex items-center justify-center gap-1.5 text-fg-muted hover:text-fg transition-colors duration-200 min-w-6 min-h-6"
532-
:title="$t('package.verified_provenance')"
533-
>
534-
<span class="i-lucide-shield-check w-3.5 h-3.5 shrink-0" aria-hidden="true" />
535-
</a>
560+
<template v-if="hasProvenance(displayVersion) && provenanceBadgeMounted">
561+
<TooltipApp
562+
:text="
563+
provenanceData && provenanceStatus !== 'pending'
564+
? $t('package.provenance_section.built_and_signed_on', {
565+
provider: provenanceData.providerLabel,
566+
})
567+
: $t('package.verified_provenance')
568+
"
569+
position="bottom"
570+
>
571+
<a
572+
href="#provenance"
573+
:aria-label="$t('package.provenance_section.view_more_details')"
574+
class="inline-flex items-center justify-center gap-1.5 text-fg-muted hover:text-emerald-500 transition-colors duration-200 min-w-6 min-h-6"
575+
>
576+
<span class="i-lucide-shield-check w-3.5 h-3.5 shrink-0" aria-hidden="true" />
577+
</a>
578+
</TooltipApp>
579+
</template>
536580
<span
537581
v-if="requestedVersion && latestVersion && resolvedVersion !== latestVersion.version"
538582
class="text-fg-subtle text-sm shrink-0"
@@ -1084,8 +1128,37 @@ defineOgImageComponent('Package', {
10841128
>{{ $t('package.readme.view_on_github') }}</a
10851129
>
10861130
</p>
1087-
</section>
10881131

1132+
<section
1133+
v-if="hasProvenance(displayVersion) && provenanceBadgeMounted"
1134+
id="provenance"
1135+
class="scroll-mt-20"
1136+
>
1137+
<div
1138+
v-if="provenanceStatus === 'pending'"
1139+
class="mt-8 flex items-center gap-2 text-fg-subtle text-sm"
1140+
>
1141+
<span
1142+
class="i-carbon-circle-dash w-4 h-4 motion-safe:animate-spin"
1143+
aria-hidden="true"
1144+
/>
1145+
<span>{{ $t('package.provenance_section.title') }}…</span>
1146+
</div>
1147+
<PackageProvenanceSection
1148+
v-else-if="provenanceData"
1149+
:details="provenanceData"
1150+
class="mt-8"
1151+
/>
1152+
<!-- Error state: provenance exists but details failed to load -->
1153+
<div
1154+
v-else-if="provenanceStatus === 'error'"
1155+
class="mt-8 flex items-center gap-2 text-fg-subtle text-sm"
1156+
>
1157+
<span class="i-carbon:warning w-4 h-4" aria-hidden="true" />
1158+
<span>{{ $t('package.provenance_section.error_loading') }}</span>
1159+
</div>
1160+
</section>
1161+
</section>
10891162
<div class="area-sidebar">
10901163
<!-- Sidebar -->
10911164
<div

i18n/locales/en.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,17 @@
210210
"view_on_github": "View on GitHub",
211211
"toc_title": "Outline"
212212
},
213+
"provenance_section": {
214+
"title": "Provenance",
215+
"built_and_signed_on": "Built and signed on {provider}",
216+
"view_build_summary": "View build summary",
217+
"source_commit": "Source Commit",
218+
"build_file": "Build File",
219+
"public_ledger": "Public Ledger",
220+
"transparency_log_entry": "Transparency log entry",
221+
"view_more_details": "View more details",
222+
"error_loading": "Failed to load provenance details"
223+
},
213224
"keywords_title": "Keywords",
214225
"compatibility": "Compatibility",
215226
"card": {
@@ -610,7 +621,8 @@
610621
"provenance": {
611622
"verified": "verified",
612623
"verified_title": "Verified provenance",
613-
"verified_via": "Verified: published via {provider}"
624+
"verified_via": "Verified: published via {provider}",
625+
"view_more_details": "View more details"
614626
},
615627
"jsr": {
616628
"title": "also available on JSR",

lunaria/files/en-GB.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,17 @@
210210
"view_on_github": "View on GitHub",
211211
"toc_title": "Outline"
212212
},
213+
"provenance_section": {
214+
"title": "Provenance",
215+
"built_and_signed_on": "Built and signed on {provider}",
216+
"view_build_summary": "View build summary",
217+
"source_commit": "Source Commit",
218+
"build_file": "Build File",
219+
"public_ledger": "Public Ledger",
220+
"transparency_log_entry": "Transparency log entry",
221+
"view_more_details": "View more details",
222+
"error_loading": "Failed to load provenance details"
223+
},
213224
"keywords_title": "Keywords",
214225
"compatibility": "Compatibility",
215226
"card": {
@@ -610,7 +621,8 @@
610621
"provenance": {
611622
"verified": "verified",
612623
"verified_title": "Verified provenance",
613-
"verified_via": "Verified: published via {provider}"
624+
"verified_via": "Verified: published via {provider}",
625+
"view_more_details": "View more details"
614626
},
615627
"jsr": {
616628
"title": "also available on JSR",

lunaria/files/en-US.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,17 @@
210210
"view_on_github": "View on GitHub",
211211
"toc_title": "Outline"
212212
},
213+
"provenance_section": {
214+
"title": "Provenance",
215+
"built_and_signed_on": "Built and signed on {provider}",
216+
"view_build_summary": "View build summary",
217+
"source_commit": "Source Commit",
218+
"build_file": "Build File",
219+
"public_ledger": "Public Ledger",
220+
"transparency_log_entry": "Transparency log entry",
221+
"view_more_details": "View more details",
222+
"error_loading": "Failed to load provenance details"
223+
},
213224
"keywords_title": "Keywords",
214225
"compatibility": "Compatibility",
215226
"card": {
@@ -610,7 +621,8 @@
610621
"provenance": {
611622
"verified": "verified",
612623
"verified_title": "Verified provenance",
613-
"verified_via": "Verified: published via {provider}"
624+
"verified_via": "Verified: published via {provider}",
625+
"view_more_details": "View more details"
614626
},
615627
"jsr": {
616628
"title": "also available on JSR",

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export default defineNuxtConfig({
102102
'/package-docs/:scope/:pkg/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
103103
'/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
104104
'/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
105+
'/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
105106
'/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
106107
'/_avatar/**': {
107108
isr: 3600,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import * as v from 'valibot'
2+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
3+
import type { NpmVersionDist } from '#shared/types'
4+
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_PROVENANCE_FETCH_FAILED } from '#shared/utils/constants'
5+
import {
6+
parseAttestationToProvenanceDetails,
7+
type NpmAttestationsResponse,
8+
} from '#server/utils/provenance'
9+
10+
/**
11+
* GET /api/registry/provenance/:name/v/:version
12+
*
13+
* Returns parsed provenance details for a package version (build summary, source commit, build file, public ledger).
14+
* Version is required. Returns null when the version has no attestations or parsing fails.
15+
*/
16+
export default defineCachedEventHandler(
17+
async event => {
18+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
19+
20+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
21+
22+
if (!rawVersion) {
23+
throw createError({
24+
statusCode: 400,
25+
message: 'Version is required for provenance.',
26+
})
27+
}
28+
29+
try {
30+
const parsed = v.parse(PackageRouteParamsSchema, {
31+
packageName: rawPackageName,
32+
version: rawVersion,
33+
}) as { packageName: string; version: string }
34+
const { packageName, version } = parsed
35+
36+
const packument = await fetchNpmPackage(packageName)
37+
const versionData = packument.versions[version]
38+
if (!versionData) {
39+
throw createError({
40+
statusCode: 404,
41+
message: `Version ${version} not found for package ${packageName}.`,
42+
})
43+
}
44+
const dist = versionData.dist as NpmVersionDist | undefined
45+
const attestationsUrl = dist?.attestations?.url
46+
47+
if (!attestationsUrl) {
48+
return null
49+
}
50+
51+
const response = await $fetch<NpmAttestationsResponse>(attestationsUrl)
52+
const details = parseAttestationToProvenanceDetails(response)
53+
return details
54+
} catch (error: unknown) {
55+
handleApiError(error, {
56+
statusCode: 502,
57+
message: ERROR_PROVENANCE_FETCH_FAILED,
58+
})
59+
}
60+
},
61+
{
62+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
63+
swr: true,
64+
getKey: event => {
65+
const pkg = getRouterParam(event, 'pkg') ?? ''
66+
return `provenance:v1:${pkg.replace(/\/+$/, '').trim()}`
67+
},
68+
},
69+
)

0 commit comments

Comments
 (0)