From 91d3b99c2cb4808811e0e2c2003e51ae4965122d Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 18:57:19 +0100
Subject: [PATCH 01/32] feat: add `AppPopover` component
---
app/components/AppPopover.vue | 79 +++++++++++++++++++++++++++++++++++
1 file changed, 79 insertions(+)
create mode 100644 app/components/AppPopover.vue
diff --git a/app/components/AppPopover.vue b/app/components/AppPopover.vue
new file mode 100644
index 000000000..28a4f4926
--- /dev/null
+++ b/app/components/AppPopover.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
From c9cc10925c1eee30828e2603b698b2b4e5445d98 Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 18:58:42 +0100
Subject: [PATCH 02/32] feat(constants): add error constant for provenance
fetch failure
---
shared/utils/constants.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index e6dbb68df..b902c731d 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -16,6 +16,7 @@ export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.
export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!'
export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry.'
export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'
+export const ERROR_PROVENANCE_FETCH_FAILED = 'Failed to fetch provenance.'
/** @public */
export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.'
From c19f8ef0d576c94e040586a392eadef654d1657b Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 18:59:47 +0100
Subject: [PATCH 03/32] feat(i18n): add provenance section to english
---
i18n/locales/en.json | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 8a6cc33a5..ba147094e 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -170,6 +170,16 @@
"no_readme": "No README available.",
"view_on_github": "View on GitHub"
},
+ "provenance_section": {
+ "title": "Provenance",
+ "built_and_signed_on": "Built and signed on {provider}",
+ "view_build_summary": "View build summary",
+ "source_commit": "Source Commit",
+ "build_file": "Build File",
+ "public_ledger": "Public Ledger",
+ "transparency_log_entry": "Transparency log entry",
+ "view_more_details": "View more details"
+ },
"keywords_title": "Keywords",
"compatibility": "Compatibility",
"card": {
@@ -561,7 +571,8 @@
"provenance": {
"verified": "verified",
"verified_title": "Verified provenance",
- "verified_via": "Verified: published via {provider}"
+ "verified_via": "Verified: published via {provider}",
+ "view_more_details": "View more details"
},
"jsr": {
"title": "also available on JSR",
From 4b142f435b8fe698019784f61633eaf6fbd3b00f Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 19:01:37 +0100
Subject: [PATCH 04/32] feat: add `PackageProvenanceSection` component
---
app/components/PackageProvenanceSection.vue | 112 ++++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 app/components/PackageProvenanceSection.vue
diff --git a/app/components/PackageProvenanceSection.vue b/app/components/PackageProvenanceSection.vue
new file mode 100644
index 000000000..aabdcfbd5
--- /dev/null
+++ b/app/components/PackageProvenanceSection.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
From 64f960186c2c882c382ef0d5c15871e4bbf0b820 Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 19:03:44 +0100
Subject: [PATCH 05/32] feat(types): add `ProvenanceDetails` type
---
shared/types/npm-registry.ts | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts
index 8722201f6..9dfb21813 100644
--- a/shared/types/npm-registry.ts
+++ b/shared/types/npm-registry.ts
@@ -193,6 +193,30 @@ export interface NpmVersionDist {
attestations?: NpmVersionAttestations
}
+/**
+ * Parsed provenance details for display (from attestation bundle SLSA predicate).
+ * Used by the provenance API and PackageProvenanceSection.
+ * @public
+ */
+export interface ProvenanceDetails {
+ /** Provider ID (e.g. "github", "gitlab") */
+ provider: string
+ /** Human-readable provider label (e.g. "GitHub Actions") */
+ providerLabel: string
+ /** Link to build run summary (e.g. GitHub Actions run URL) */
+ buildSummaryUrl?: string
+ /** Link to source commit in repository */
+ sourceCommitUrl?: string
+ /** Source commit SHA (short or full) */
+ sourceCommitSha?: string
+ /** Link to workflow/build config file in repo */
+ buildFileUrl?: string
+ /** Workflow path (e.g. ".github/workflows/release.yml") */
+ buildFilePath?: string
+ /** Link to transparency log entry (e.g. Sigstore search) */
+ publicLedgerUrl?: string
+}
+
/**
* Download counts API response
* From https://api.npmjs.org/downloads/
From cc60590d24915387d0a3a6eaefd1a3c16a752162 Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 19:36:31 +0100
Subject: [PATCH 06/32] feat(provenance): add provenance utility functions
---
server/utils/provenance.ts | 143 +++++++++++++++++++++++++++++++++++++
1 file changed, 143 insertions(+)
create mode 100644 server/utils/provenance.ts
diff --git a/server/utils/provenance.ts b/server/utils/provenance.ts
new file mode 100644
index 000000000..cf9ca9394
--- /dev/null
+++ b/server/utils/provenance.ts
@@ -0,0 +1,143 @@
+import type { ProvenanceDetails } from '#shared/types'
+
+const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1'
+const SLSA_PROVENANCE_V0_2 = 'https://slsa.dev/provenance/v0.2'
+
+const PROVIDER_IDS: Record = {
+ 'https://github.com/actions/runner/github-hosted': {
+ provider: 'github',
+ providerLabel: 'GitHub Actions',
+ },
+ 'https://github.com/actions/runner': { provider: 'github', providerLabel: 'GitHub Actions' },
+}
+
+/** GitLab uses project-specific builder IDs: https://gitlab.com//-/runners/ */
+function getProviderInfo(builderId: string): { provider: string; providerLabel: string } {
+ const exact = PROVIDER_IDS[builderId]
+ if (exact) return exact
+ if (builderId.includes('gitlab.com') && builderId.includes('/runners/'))
+ return { provider: 'gitlab', providerLabel: 'GitLab CI' }
+ return { provider: 'unknown', providerLabel: builderId ? 'CI' : 'Unknown' }
+}
+
+const SIGSTORE_SEARCH_BASE = 'https://search.sigstore.dev'
+
+/** SLSA provenance v1 predicate; optional v0.2 fields for fallback */
+interface SlsaPredicate {
+ buildDefinition?: {
+ externalParameters?: {
+ workflow?: {
+ repository?: string
+ path?: string
+ ref?: string
+ }
+ }
+ resolvedDependencies?: Array<{
+ uri?: string
+ digest?: { gitCommit?: string }
+ }>
+ }
+ runDetails?: {
+ builder?: { id?: string }
+ metadata?: { invocationId?: string }
+ }
+ /** v0.2 */
+ builder?: { id?: string }
+ /** v0.2 */
+ metadata?: { buildInvocationId?: string }
+}
+
+interface AttestationItem {
+ predicateType?: string
+ bundle?: {
+ dsseEnvelope?: { payload?: string }
+ verificationMaterial?: {
+ tlogEntries?: Array<{ logIndex?: string }>
+ }
+ }
+}
+
+export interface NpmAttestationsResponse {
+ attestations?: AttestationItem[]
+}
+
+function decodePayload(
+ payloadBase64: string | undefined,
+): { predicateType?: string; predicate?: SlsaPredicate } | null {
+ if (!payloadBase64 || typeof payloadBase64 !== 'string') return null
+ try {
+ const decoded = Buffer.from(payloadBase64, 'base64').toString('utf-8')
+ return JSON.parse(decoded) as { predicateType?: string; predicate?: SlsaPredicate }
+ } catch {
+ return null
+ }
+}
+
+function repoUrlToCommitUrl(repository: string, sha: string): string {
+ const normalized = repository.replace(/\/$/, '').replace(/\.git$/, '')
+ if (normalized.includes('github.com')) return `${normalized}/commit/${sha}`
+ if (normalized.includes('gitlab.com')) return `${normalized}/-/commit/${sha}`
+ return `${normalized}/commit/${sha}`
+}
+
+function repoUrlToBlobUrl(repository: string, path: string, ref = 'main'): string {
+ const normalized = repository.replace(/\/$/, '').replace(/\.git$/, '')
+ if (normalized.includes('github.com')) return `${normalized}/blob/${ref}/${path}`
+ if (normalized.includes('gitlab.com')) return `${normalized}/-/blob/${ref}/${path}`
+ return `${normalized}/blob/${ref}/${path}`
+}
+
+/**
+ * Parse npm attestations API response into ProvenanceDetails.
+ * Prefers SLSA provenance v1; falls back to v0.2 for provider label and ledger only (no source commit/build file from v0.2).
+ * @public
+ */
+export function parseAttestationToProvenanceDetails(response: unknown): ProvenanceDetails | null {
+ const body = response as NpmAttestationsResponse
+ const list = body?.attestations
+ if (!Array.isArray(list)) return null
+
+ const slsaAttestation =
+ list.find(a => a.predicateType === SLSA_PROVENANCE_V1) ??
+ list.find(a => a.predicateType === SLSA_PROVENANCE_V0_2)
+ if (!slsaAttestation?.bundle?.dsseEnvelope) return null
+
+ const payload = decodePayload(slsaAttestation.bundle.dsseEnvelope.payload)
+ if (!payload?.predicate) return null
+
+ const pred = payload.predicate as SlsaPredicate
+ const builderId = pred.runDetails?.builder?.id ?? pred.builder?.id ?? ''
+ const providerInfo = getProviderInfo(builderId)
+
+ const workflow = pred.buildDefinition?.externalParameters?.workflow
+ const repo = workflow?.repository?.replace(/\/$/, '').replace(/\.git$/, '') ?? ''
+ const workflowPath = workflow?.path ?? ''
+ const ref = workflow?.ref?.replace(/^refs\/heads\//, '') ?? 'main'
+
+ const resolved = pred.buildDefinition?.resolvedDependencies?.[0]
+ const commitSha = resolved?.digest?.gitCommit ?? ''
+
+ const rawInvocationId =
+ pred.runDetails?.metadata?.invocationId ?? pred.metadata?.buildInvocationId
+ const buildSummaryUrl =
+ rawInvocationId?.startsWith('http://') || rawInvocationId?.startsWith('https://')
+ ? rawInvocationId
+ : undefined
+ const sourceCommitUrl = repo && commitSha ? repoUrlToCommitUrl(repo, commitSha) : undefined
+ const buildFileUrl = repo && workflowPath ? repoUrlToBlobUrl(repo, workflowPath, ref) : undefined
+
+ const tlogEntries = slsaAttestation.bundle.verificationMaterial?.tlogEntries
+ const logIndex = tlogEntries?.[0]?.logIndex
+ const publicLedgerUrl = logIndex ? `${SIGSTORE_SEARCH_BASE}/?logIndex=${logIndex}` : undefined
+
+ return {
+ provider: providerInfo.provider,
+ providerLabel: providerInfo.providerLabel,
+ buildSummaryUrl,
+ sourceCommitUrl,
+ sourceCommitSha: commitSha || undefined,
+ buildFileUrl,
+ buildFilePath: workflowPath || undefined,
+ publicLedgerUrl,
+ }
+}
From 09275692e7f82c826a5e52aaa56ec03fe3acc9ed Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 19:39:02 +0100
Subject: [PATCH 07/32] style(PackageProvenanceSection): add mt spacing
---
app/components/PackageProvenanceSection.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/components/PackageProvenanceSection.vue b/app/components/PackageProvenanceSection.vue
index aabdcfbd5..878168cbb 100644
--- a/app/components/PackageProvenanceSection.vue
+++ b/app/components/PackageProvenanceSection.vue
@@ -45,7 +45,7 @@ defineProps<{
{{ $t('package.provenance_section.view_build_summary') }}
-
+
-
{{ $t('package.provenance_section.source_commit') }}
From 2c99239e434a84be595b39b70de6726d0ce6bddd Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 19:40:36 +0100
Subject: [PATCH 08/32] feat(provenance): add api endpoint for provenance
details
---
.../api/registry/provenance/[...pkg].get.ts | 69 +++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 server/api/registry/provenance/[...pkg].get.ts
diff --git a/server/api/registry/provenance/[...pkg].get.ts b/server/api/registry/provenance/[...pkg].get.ts
new file mode 100644
index 000000000..be1ab481b
--- /dev/null
+++ b/server/api/registry/provenance/[...pkg].get.ts
@@ -0,0 +1,69 @@
+import * as v from 'valibot'
+import { PackageRouteParamsSchema } from '#shared/schemas/package'
+import type { NpmVersionDist } from '#shared/types'
+import { CACHE_MAX_AGE_ONE_HOUR, ERROR_PROVENANCE_FETCH_FAILED } from '#shared/utils/constants'
+import {
+ parseAttestationToProvenanceDetails,
+ type NpmAttestationsResponse,
+} from '#server/utils/provenance'
+
+/**
+ * GET /api/registry/provenance/:name/v/:version
+ *
+ * Returns parsed provenance details for a package version (build summary, source commit, build file, public ledger).
+ * Version is required. Returns null when the version has no attestations or parsing fails.
+ */
+export default defineCachedEventHandler(
+ async event => {
+ const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
+
+ const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
+
+ if (!rawVersion) {
+ throw createError({
+ statusCode: 400,
+ message: 'Version is required for provenance.',
+ })
+ }
+
+ try {
+ const parsed = v.parse(PackageRouteParamsSchema, {
+ packageName: rawPackageName,
+ version: rawVersion,
+ })
+ const { packageName, version } = parsed
+ if (!version) {
+ throw createError({
+ statusCode: 400,
+ message: 'Version is required for provenance.',
+ })
+ }
+
+ const packument = await fetchNpmPackage(packageName)
+ const versionData = packument.versions[version]
+ const dist = versionData?.dist as NpmVersionDist | undefined
+ const attestationsUrl = dist?.attestations?.url
+
+ if (!attestationsUrl) {
+ return null
+ }
+
+ const response = await $fetch(attestationsUrl)
+ const details = parseAttestationToProvenanceDetails(response)
+ return details
+ } catch (error: unknown) {
+ handleApiError(error, {
+ statusCode: 502,
+ message: ERROR_PROVENANCE_FETCH_FAILED,
+ })
+ }
+ },
+ {
+ maxAge: CACHE_MAX_AGE_ONE_HOUR,
+ swr: true,
+ getKey: event => {
+ const pkg = getRouterParam(event, 'pkg') ?? ''
+ return `provenance:v1:${pkg.replace(/\/+$/, '').trim()}`
+ },
+ },
+)
From e6bb3e2f00b5e7e1efb39cc1fc2ea50fb2784549 Mon Sep 17 00:00:00 2001
From: AscaL
Date: Fri, 30 Jan 2026 19:44:26 +0100
Subject: [PATCH 09/32] feat(package): add provenance and popover to package
---
app/pages/[...package].vue | 102 ++++++++++++++++++++++++++++++++-----
1 file changed, 88 insertions(+), 14 deletions(-)
diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue
index 0be158403..f612ae995 100644
--- a/app/pages/[...package].vue
+++ b/app/pages/[...package].vue
@@ -1,5 +1,10 @@
@@ -101,16 +97,5 @@ defineProps<{
-
-
-
- {{ $t('common.view_on_npm') }}
-
-
From fed4af51fba9949192bbef74d0cce96e178188b7 Mon Sep 17 00:00:00 2001
From: AscaL
Date: Mon, 2 Feb 2026 19:57:05 +0100
Subject: [PATCH 23/32] feat(a11y): add accessible label to AppPopover
---
app/components/AppPopover.vue | 3 +++
app/pages/package/[...package].vue | 2 +-
test/nuxt/a11y.spec.ts | 1 +
3 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/components/AppPopover.vue b/app/components/AppPopover.vue
index fe79c8e59..8b6956d6a 100644
--- a/app/components/AppPopover.vue
+++ b/app/components/AppPopover.vue
@@ -1,5 +1,7 @@
-
-
-
-
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index fd0fcc258..05c1f4c45 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -500,44 +500,27 @@ defineOgImageComponent('Package', {
v{{ resolvedVersion }}
-
+
+
+
+
+
{
import {
AppFooter,
AppHeader,
- AppPopover,
BaseCard,
BuildEnvironment,
CallToAction,
@@ -224,44 +223,6 @@ describe('component accessibility audits', () => {
})
})
- describe('AppPopover', () => {
- it('should have no accessibility violations when closed', async () => {
- const component = await mountSuspended(AppPopover, {
- slots: {
- default: '',
- content: 'Popover content
',
- },
- })
- const results = await runAxe(component)
- expect(results.violations).toEqual([])
- })
-
- it('should have no accessibility violations when open', async () => {
- const component = await mountSuspended(AppPopover, {
- props: { label: 'Popover' },
- slots: {
- default: '',
- content: 'Popover content
',
- },
- })
- await component.find('.relative').trigger('focusin')
- const results = await runAxe(component)
- expect(results.violations).toEqual([])
- })
-
- it('should have no accessibility violations with position prop', async () => {
- const component = await mountSuspended(AppPopover, {
- props: { position: 'top' },
- slots: {
- default: '',
- content: 'Popover content
',
- },
- })
- const results = await runAxe(component)
- expect(results.violations).toEqual([])
- })
- })
-
describe('BaseCard', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(BaseCard, {
From 392c73eac3f534868ddf4944e17e42076dedd109 Mon Sep 17 00:00:00 2001
From: AscaL
Date: Tue, 3 Feb 2026 22:23:18 +0100
Subject: [PATCH 26/32] refactor: update provenance section with i18n keypath
---
app/components/PackageProvenanceSection.vue | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
diff --git a/app/components/PackageProvenanceSection.vue b/app/components/PackageProvenanceSection.vue
index 480ada29b..b371bffc4 100644
--- a/app/components/PackageProvenanceSection.vue
+++ b/app/components/PackageProvenanceSection.vue
@@ -7,7 +7,7 @@ defineProps<{
-
Date: Tue, 3 Feb 2026 22:24:04 +0100
Subject: [PATCH 27/32] fix: 404 on missing pkg version
---
server/api/registry/provenance/[...pkg].get.ts | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/server/api/registry/provenance/[...pkg].get.ts b/server/api/registry/provenance/[...pkg].get.ts
index be1ab481b..9cb705920 100644
--- a/server/api/registry/provenance/[...pkg].get.ts
+++ b/server/api/registry/provenance/[...pkg].get.ts
@@ -30,18 +30,18 @@ export default defineCachedEventHandler(
const parsed = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
- })
+ }) as { packageName: string; version: string }
const { packageName, version } = parsed
- if (!version) {
- throw createError({
- statusCode: 400,
- message: 'Version is required for provenance.',
- })
- }
const packument = await fetchNpmPackage(packageName)
const versionData = packument.versions[version]
- const dist = versionData?.dist as NpmVersionDist | undefined
+ if (!versionData) {
+ throw createError({
+ statusCode: 404,
+ message: `Version ${version} not found for package ${packageName}.`,
+ })
+ }
+ const dist = versionData.dist as NpmVersionDist | undefined
const attestationsUrl = dist?.attestations?.url
if (!attestationsUrl) {
From c77a40039bb64b3df1c226f3736d55c0e67aaa66 Mon Sep 17 00:00:00 2001
From: Daniel Roe
Date: Wed, 4 Feb 2026 11:51:14 +0000
Subject: [PATCH 28/32] fix: update icons
---
app/components/PackageProvenanceSection.vue | 5 +----
app/pages/package/[...package].vue | 5 +----
2 files changed, 2 insertions(+), 8 deletions(-)
diff --git a/app/components/PackageProvenanceSection.vue b/app/components/PackageProvenanceSection.vue
index b371bffc4..73f316536 100644
--- a/app/components/PackageProvenanceSection.vue
+++ b/app/components/PackageProvenanceSection.vue
@@ -23,10 +23,7 @@ defineProps<{
-
+
{{ details.providerLabel }}
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index 533cad1dd..7fd68580f 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -569,10 +569,7 @@ defineOgImageComponent('Package', {
:aria-label="$t('package.provenance_section.view_more_details')"
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"
>
-
+
From 4f22aea496eca86a6751d48e4143dc03676ee82b Mon Sep 17 00:00:00 2001
From: Daniel Roe
Date: Wed, 4 Feb 2026 11:51:31 +0000
Subject: [PATCH 29/32] fix: error handling
---
app/pages/package/[...package].vue | 22 +++++++++++++++++-----
i18n/locales/en.json | 3 ++-
lunaria/files/en-GB.json | 3 ++-
lunaria/files/en-US.json | 3 ++-
4 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index 7fd68580f..8161d0f06 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -176,11 +176,15 @@ const {
},
)
if (import.meta.client) {
- watchEffect(() => {
- if (displayVersion.value && hasProvenance(displayVersion.value)) {
- fetchProvenance()
- }
- })
+ watch(
+ displayVersion,
+ v => {
+ if (v && hasProvenance(v) && provenanceStatus.value === 'idle') {
+ fetchProvenance()
+ }
+ },
+ { immediate: true },
+ )
}
const provenanceBadgeMounted = shallowRef(false)
@@ -1148,6 +1152,14 @@ defineOgImageComponent('Package', {
+
+
+
+ {{ $t('package.provenance_section.error_loading') }}
+