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<{