From 40af7afccf31d31eb194e85c9e86774cd8f84436 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 28 May 2026 16:32:20 -0400 Subject: [PATCH 01/54] Add ATTESTATION-INTEGRATION.md: plan for surfacing attestations in MVR Records the design decisions and build plan for displaying package attestations (from a curated set of trusted attestor packages) on the MVR web app, read over JSON-RPC from a localnet-backed simulated network. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 202 +++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 ATTESTATION-INTEGRATION.md diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md new file mode 100644 index 00000000..11d7a85f --- /dev/null +++ b/ATTESTATION-INTEGRATION.md @@ -0,0 +1,202 @@ +# Attestation Integration — Surfacing Package Attestations in MVR + +This document is the implementation plan for displaying package attestations on +the MVR web app, sourced from a Sui attestation registry. It records the +decisions reached during design exploration and the steps to build from. + +> **Two repos.** Paths under `app/` and `crates/` are in **this (mvr) repo**. +> Paths like `DESIGN.md`, `CONVENTIONS.md`, `ts/`, `packages/`, `scripts/` +> refer to the **attestation-registry repo** (`sui-attestation-registry`), +> which defines the on-chain registry, the Display-field conventions, and the +> TypeScript read library this builds on. See that repo's `DESIGN.md` for +> on-chain rationale and `CONVENTIONS.md` for the Display conventions. + +## Goal + +When browsing a subject package on the MVR web app, show the attestations made +about it by a curated, hardcoded set of **trusted attestor packages** — +rendered from each attestation's on-chain Display. Stand the whole thing up +against a simulated (localnet) network, faithfully enough that the same code +path runs in production. + +**Primary outcome: an upstreamable integration** (fits MVR's architecture and +conventions), not a throwaway demo. + +## How MVR fetches data (findings) + +MVR's frontend (`app/`, Next.js + dapp-kit + react-query) reads from two +independent sources: + +1. **`mvr-api` (REST)** — name resolution and search. `useResolveMvrName` → + `GET {mvrEndpoint}/v1/names/{name}` returns a `ResolvedName` + (`package_address`, `package_info`, `version`, …). Backed by Postgres, which + `mvr-indexer` normally fills from checkpoints. +2. **dapp-kit `SuiClient` (JSON-RPC)** — on-chain reads (versions, deps, + package-info objects). `DefaultClients` (`app/src/components/providers/client-provider.tsx`) + hardcodes per-network URLs and already includes a `localnet` client plus + `SuiGraphQLClient`s. + +**Reading attestations does not touch the mvr-api backend** — it is a pure +chain read (derive the Box address from the subject, list its owned +`Attestation` objects). So the attestation surface is fundamentally a +frontend feature pointed at whatever chain the `SuiClient` uses. + +## Locked decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| D1 | **Approach A**: seed Postgres + localnet | Faithful to how the app fetches; isolates us from MVR's on-chain name-registration contracts. The `crates/mvr-api/tests/mvr_test_cluster.rs` `setup_dummy_data` pattern inserts directly into `name_records`/`packages`/`package_infos` — no indexer, no live chain needed for resolution. | +| D2 | **JSON-RPC**, not gRPC | MVR is pinned to `@mysten/sui@1.39.0`, which has no `/grpc` export. Adding `SuiGrpcClient` forces a 1.x→2.x SDK upgrade dragging dapp-kit `0.19→1.0`, kiosk, suins — a repo-wide modernization that dwarfs (and destabilizes) this feature. gRPC stays a separate, future MVR initiative; the attestation-registry repo's `ts/lib/queries.ts` (gRPC) ports back trivially when it lands. | +| D3 | **Subject = `package_address`** (resolved version), not original ID | Directly available on `ResolvedName`; the demo seeds the on-chain attestation against the same value. | +| D4 | **Trusted set = original package IDs** | Mirrors on-chain `attester_of() = type_name::original_id()`. Any type from any version of a trusted package counts. | +| D5 | **Option 2**: server-side exact-type `MatchAny`, trusted set = `Attestation` types each trusted attester **registered a Display for** | The JSON-RPC `StructType` filter matches type params all-or-nothing (`sui-json-rpc-types` `SuiObjectDataFilter::matches`) — no inner-package prefix. Spam-resistance therefore requires the exact trusted-type list. "Registered a Display" is the deliberate, finite, evolution-friendly definition, and Display registration carries the same `internal::Permit` bytecode identity as `attest`, so it can't be forged. | +| D6 | **Display-gate**: only count `Attestation` with a registered Display | Legibility/trust signal; already implied by D5. | +| D7 | **Identity from config; content host-constrained** | Attester brand icon/name come from the trusted-list config, never from on-chain data (defeats within-whitelist impersonation). Per-attestation `image_url`/`link` may come from Display, constrained to the attester's declared `domains`. | +| D8 | **Show revoked/expired, de-emphasized** | A revoked audit is itself information; a trust surface should be transparent. | +| D9 | **Demo == production code path** | `sui start --with-graphql` serves GraphQL on localnet, so lineage/Display enumeration runs identically locally and in prod. Only mvr-api's name→address rows are synthetic; all chain state the read touches is real. | + +## Architecture + +``` +Browser (MVR app, @mysten/sui 1.39 JSON-RPC) + │ + ├─(REST)──► mvr-api ──► Postgres (seeded: name_records → package_address) [resolution only] + │ + ├─(JSON-RPC)──► localnet fullnode :9000 + │ • getOwnedObjects(boxAddr, MatchAny[Attestation]) [the attestations] + │ + └─(GraphQL)───► localnet graphql (--with-graphql) + • packageVersions(address) → trusted lineage / original id + • objects(type: Display<…Attestation>) → trusted type set [cached per attester] +``` + +`boxAddr = deriveObjectID(registryId, '0x2::object::ID', subjectBytes)` — the +client-agnostic derivation in the attestation-registry repo's `ts/lib/boxes.ts`, +ports to MVR as-is. + +## Implementation sections + +### Section 1 — Demo environment (the simulated network) + +1. `sui start --with-faucet --with-graphql` (localnet + faucet + GraphQL/indexer). +2. Publish on localnet: `attestation_registry` (creates the shared `Registry` + in `init`), `audit_example`/`vuln_example` attestors, and subject package(s). + **Exercise evolution**: upgrade an attestor to add a second schema type + (e.g. `AuditV2`) and register its Display, so both surface under one attester. + Create `Attestation`/`Attestation`/`Attestation` + about the subjects, and revoke one (to show the de-emphasized state). Extend + the attestation-registry repo's `scripts/run-demo.sh` + `ts/demo.ts`; emit the + published IDs for step 3. +3. Seed Postgres (test-cluster style): run `mvr-schema` `MIGRATIONS`, insert + `name_records` + `packages` + `package_infos` so a demo name (`@demo/subject`) + resolves to the **same `package_address`** published in step 2. Lift + `mvr_test_cluster.rs::setup_dummy_data` into a standalone seeding binary. +4. Run `mvr-api` against that Postgres (`--network mainnet`; cosmetic, resolution + is a DB read). +5. Point the frontend locally: override the `mainnet` slot of `DefaultClients` + (SuiClient URL → localnet, `mvrEndpoints.mainnet` → local mvr-api, graphql → + local). Keep using the `mainnet` slot rather than adding a UI network — the + feature stays network-generic; the demo is "mainnet, repointed." + +**Invariant:** seeded `name_records.package_address` == published subject ID on +localnet, so resolving the name lands on a chain object whose Box has attestations. + +### Section 2 — Attestation read layer (JSON-RPC) + +New code in MVR (`app/src`): + +- **`lib/constants.ts`**: `attestationRegistryPkg`, `attestationRegistryId` + (per network; demo fills `mainnet` with the localnet registry id), and + `trustedAttestors: { originalId, name, iconUrl, domains? }[]`. +- **`boxAddress` helper**: port the attestation-registry repo's `ts/lib/boxes.ts` + (pure `deriveObjectID`). +- **Trusted-type resolver** (cached per attester, GraphQL): + 1. For each trusted `originalId`, get its lineage via `packageVersions`. + 2. Enumerate `Attestation` types the lineage registered Displays for — + either `objects(filter:{type:"0x2::display_registry::Display<…attestation_registry::Attestation>"})` + filtered to trusted lineages, or per-attester datatypes → derived + `Display>` existence check. **Spike: confirm GraphQL generic + type-filter matching; pick the mechanism.** + 3. Result: exact `trustedAttestationTypes: string[]`. +- **`hooks/useGetAttestations.ts`**: `getOwnedObjects(boxAddr, { MatchAny: + trustedAttestationTypes.map(StructType) }, { showType, showDisplay, showContent })`, + paginated → map each `SuiObjectResponse` into the `AttestationInfo` shape + (`{ id, version, digest, type, display, content }`; JSON-RPC nests Display + under `data.display.data`). +- **Effectiveness**: port the attestation-registry repo's `ts/lib/conventions.ts` + (`isEffective`, `active`/`expires_at`/`requires`) — operates purely on + `display` + `id`, so it drops in unchanged. + +### Section 3 — UI surface + +- **New "Attestations" tab** in `SinglePackage.tsx`'s `Tabs` array + (`key`/`title`/`icon`/`component` + a `label` count badge like `DependencyCount`; + the non-zero count is the at-a-glance trust signal). +- **`SinglePackageAttestations`** (Dependencies-tab idiom: `Accordion` + + `LoadingState` + `EmptyState`), data via `useGetAttestations(name.package_address, network)`: + - **Group by trusted attester** — section per attester, headed by config + `name` + `originalId` + config `iconUrl`. Evolution shows here (`Audit` and + `AuditV2` under one attester). + - **Row**: Display `name` + `description`; the **exact `T`** (monospace, + truncated + tooltip); effectiveness badge (active / **revoked** / expired / + requires-unmet) from `conventions.ts`, ineffective shown de-emphasized. + - `image_url` / `link` rendered per Section 4 hygiene rules. +- **(Phase 2) Sidebar trust badge** in `SinglePackageSidebar` — compact + "✓ Attested by N trusted attestors"; same hook (react-query dedupes). + +### Section 4 — Convention additions (attestation-registry repo: `CONVENTIONS.md` / `ts/lib/conventions.ts`) + +Add two **optional** conventions using the standard Sui Display keys (so +attestations render in any Display-aware tool, not just MVR): + +- **`image_url`** — per-attestation content (badge/grade/report thumbnail). +- **`link`** — URL to the full report/detail. + +Security (D7): identity icon/name come from config, never Display. For +`image_url`/`link` from Display: https-only, `referrerPolicy="no-referrer"`, +`rel="noopener noreferrer"`, render destination host visibly, and **constrain +the host to the attester's configured `domains`** (soft allowlist; default to +hygiene-only if an attester declares no domains). + +## Build order (milestones) + +- **M0** — Localnet env up; packages published (incl. an upgraded attester); + attestations created + one revoked; IDs emitted. +- **M1** — Postgres seeded + mvr-api running; frontend repointed; `@demo/subject` + resolves and the package page renders against localnet. +- **M2** — Read hook with a **client-side lineage filter** (proves the + end-to-end pipeline; not yet spam-proof) + the Attestations tab rendering + Display fields and effectiveness. +- **M3** — Swap in the Option 2 **server-side `MatchAny`** over the + Display-registered trusted-type set (spam-resistance); finalize the + enumeration mechanism from the M2 spike. +- **M4** — `image_url`/`link` conventions + host-allowlist policing; sidebar + trust badge. + +## Task checklist + +- [ ] Extend the attestation-registry repo's `scripts/run-demo.sh` / `ts/demo.ts`: + publish + upgrade attester (`AuditV2`), create + revoke attestations, emit + published IDs. +- [ ] Standalone Postgres seeder (lift `setup_dummy_data`) → `name_records` + pointing at published subject IDs. +- [ ] Local run recipe: localnet + mvr-api + frontend env overrides. +- [ ] `lib/constants.ts`: registry ids + `trustedAttestors` config. +- [ ] Port `boxAddress`; add JSON-RPC `AttestationInfo` mapper. +- [ ] Spike: GraphQL generic type-filter for `Display>`; choose + enumeration mechanism. +- [ ] Trusted-type resolver (lineage + Display enumeration), cached per attester. +- [ ] `useGetAttestations` hook + port `conventions.ts`. +- [ ] Attestations tab + count label; group-by-attester; row with exact `T`, + effectiveness, de-emphasized ineffective. +- [ ] `image_url`/`link` conventions in `CONVENTIONS.md` + `conventions.ts`; + host-allowlist rendering in the tab. +- [ ] Sidebar trust badge (phase 2). + +## Out of scope / follow-ups + +- gRPC read path (revisit when MVR moves to `@mysten/sui` 2.x). +- Full `mvr-indexer`-on-localnet stack (D1 seeds Postgres directly instead). +- Adding a first-class `localnet` network to the MVR UI (the demo repoints + `mainnet`). +- Upstream PR: trusted-attestor list as real config vs. hardcoded constant. From 51549763d97056ffc5791a1bcc4ae4b38d81371b Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 08:01:10 -0400 Subject: [PATCH 02/54] M1: mvr-api demo server + frontend localnet repoint - crates/mvr-api/examples/demo_server.rs: ephemeral-Postgres mvr-api that seeds name_records/packages/package_infos from the attestation demo's demo-ids.json (lifting the test-cluster setup_dummy_data pattern) and runs the real run_server, so @demo/subject resolves to the localnet package address. - app: env-driven mainnet RPC + MVR endpoint overrides (NEXT_PUBLIC_MAINNET_RPC_URL / NEXT_PUBLIC_MAINNET_MVR_ENDPOINT), unset in production; documented in .env.example. - ATTESTATION-INTEGRATION.md: local run recipe; M0/M1 marked done. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 42 ++++- app/.env.example | 5 + .../components/providers/client-provider.tsx | 12 +- crates/mvr-api/examples/demo_server.rs | 150 ++++++++++++++++++ 4 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 crates/mvr-api/examples/demo_server.rs diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index 11d7a85f..6c22edf9 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -160,10 +160,13 @@ hygiene-only if an attester declares no domains). ## Build order (milestones) -- **M0** — Localnet env up; packages published (incl. an upgraded attester); - attestations created + one revoked; IDs emitted. -- **M1** — Postgres seeded + mvr-api running; frontend repointed; `@demo/subject` - resolves and the package page renders against localnet. +- **M0 ✅** — Localnet env up; packages published (incl. an upgraded attester); + attestations created + one revoked; IDs emitted (attestation-registry repo, + commit `ff6845b`). +- **M1 ✅ (data path)** — Postgres seeded + mvr-api running + frontend repointed; + `@demo/subject` resolves to the localnet package address (verified). Visual + page render is confirmed alongside M2 (the Attestations tab), which is where + there's something attestation-specific to see. - **M2** — Read hook with a **client-side lineage filter** (proves the end-to-end pipeline; not yet spam-proof) + the Attestations tab rendering Display fields and effectiveness. @@ -173,14 +176,37 @@ hygiene-only if an attester declares no domains). - **M4** — `image_url`/`link` conventions + host-allowlist policing; sidebar trust badge. +## Running locally (M0–M1) + +Three terminals; the first holds the localnet + published packages + attestations. + +```bash +# 1) attestation-registry repo: localnet + publish + upgrade + attest, kept up +KEEP_ALIVE=1 bash scripts/run-demo.sh # writes demo-ids.json, holds :9000 + +# 2) mvr repo: real mvr-api over an ephemeral Postgres, seeded from demo-ids.json +cargo run -p mvr-api --example demo_server -- \ + --demo-ids ~/Mysten/sui-attestation-registry/demo-ids.json --port 8000 + +# 3) mvr repo: the frontend, mainnet repointed at the local stack via app/.env +# (NEXT_PUBLIC_MAINNET_RPC_URL=http://127.0.0.1:9000, +# NEXT_PUBLIC_MAINNET_MVR_ENDPOINT=http://127.0.0.1:8000) +pnpm --dir app dev +``` + +Resolution check: `curl http://127.0.0.1:8000/v1/names/@demo/subject` returns +the localnet `package_address`. The demo server lives at +`crates/mvr-api/examples/demo_server.rs`; the frontend override is in +`app/src/components/providers/client-provider.tsx` (see `app/.env.example`). + ## Task checklist -- [ ] Extend the attestation-registry repo's `scripts/run-demo.sh` / `ts/demo.ts`: +- [x] Extend the attestation-registry repo's `scripts/run-demo.sh` / `ts/demo.ts`: publish + upgrade attester (`AuditV2`), create + revoke attestations, emit published IDs. -- [ ] Standalone Postgres seeder (lift `setup_dummy_data`) → `name_records` - pointing at published subject IDs. -- [ ] Local run recipe: localnet + mvr-api + frontend env overrides. +- [x] Standalone Postgres seeder (lift `setup_dummy_data`) → `name_records` + pointing at published subject IDs. (`examples/demo_server.rs`) +- [x] Local run recipe: localnet + mvr-api + frontend env overrides. - [ ] `lib/constants.ts`: registry ids + `trustedAttestors` config. - [ ] Port `boxAddress`; add JSON-RPC `AttestationInfo` mapper. - [ ] Spike: GraphQL generic type-filter for `Display>`; choose diff --git a/app/.env.example b/app/.env.example index adfe8367..0c8fd5d3 100644 --- a/app/.env.example +++ b/app/.env.example @@ -12,3 +12,8 @@ # Example: # SERVERVAR="foo" # NEXT_PUBLIC_CLIENTVAR="bar" + +# Optional: point the "mainnet" RPC + MVR endpoint at a local stack (a +# localnet + the attestation demo server). Leave unset for production. +# NEXT_PUBLIC_MAINNET_RPC_URL="http://127.0.0.1:9000" +# NEXT_PUBLIC_MAINNET_MVR_ENDPOINT="http://127.0.0.1:8000" diff --git a/app/src/components/providers/client-provider.tsx b/app/src/components/providers/client-provider.tsx index 015f8442..471805a7 100644 --- a/app/src/components/providers/client-provider.tsx +++ b/app/src/components/providers/client-provider.tsx @@ -33,8 +33,16 @@ export type Clients = { }; }; +// The mainnet RPC and MVR endpoint can be overridden via env so the app can +// be pointed at a local stack (e.g. a localnet + the attestation demo server) +// without code changes. Unset in production, where the defaults apply. +const MAINNET_RPC_URL = + process.env.NEXT_PUBLIC_MAINNET_RPC_URL ?? "https://suins-rpc.mainnet.sui.io:443"; +const MAINNET_MVR_ENDPOINT = + process.env.NEXT_PUBLIC_MAINNET_MVR_ENDPOINT ?? "https://mainnet.mvr.mystenlabs.com"; + const mainnet = new SuiClient({ - url: "https://suins-rpc.mainnet.sui.io:443", + url: MAINNET_RPC_URL, network: "mainnet", }); @@ -64,7 +72,7 @@ export const DefaultClients: Clients = { }), }, mvrEndpoints: { - mainnet: "https://mainnet.mvr.mystenlabs.com", + mainnet: MAINNET_MVR_ENDPOINT, testnet: "https://testnet.mvr.mystenlabs.com", }, mvrExperimentalEndpoints: { diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs new file mode 100644 index 00000000..339108e4 --- /dev/null +++ b/crates/mvr-api/examples/demo_server.rs @@ -0,0 +1,150 @@ +//! Demo server for the MVR attestation integration (see ATTESTATION-INTEGRATION.md). +//! +//! Spins up an ephemeral Postgres (`TempDb`), seeds it so the localnet-published +//! demo subjects resolve by MVR name, and runs the real mvr-api `run_server` +//! against it. Reads the published package ids from the attestation-registry +//! repo's `demo-ids.json` (produced by its `scripts/run-demo.sh`). +//! +//! This mirrors the seeding pattern in `tests/mvr_test_cluster.rs` +//! (`setup_dummy_data`): mvr-api resolution is a pure Postgres read, so we +//! insert `packages` / `package_infos` / `name_records` rows directly rather +//! than running the indexer. Neither resolution loader reads `move_package` +//! or filters by `chain_id`, so empty/placeholder values suffice there. +//! +//! Run (from the mvr repo), with a localnet up and the demo already run: +//! cargo run -p mvr-api --example demo_server -- \ +//! --demo-ids ~/Mysten/sui-attestation-registry/demo-ids.json --port 8000 +//! +//! Then point the MVR frontend's mainnet `mvrEndpoint` at http://localhost:8000. + +use std::{net::SocketAddr, path::PathBuf, str::FromStr}; + +use chrono::NaiveDateTime; +use clap::Parser; +use diesel::insert_into; +use diesel_async::RunQueryDsl; +use mvr_api::{run_server, Network}; +use mvr_schema::{ + models::{NameRecord, Package, PackageInfo}, + schema::{name_records, package_infos, packages}, + MIGRATIONS, +}; +use serde_json::json; +use sui_pg_db::{temp::TempDb, Db, DbArgs}; +use tokio_util::sync::CancellationToken; + +// Synthetic PackageInfo object ids for the seeded names. On a real network +// these would be MVR PackageInfo objects; the demo subjects have none, and +// resolution only uses these as join keys, so any distinct addresses work. +const PKG_INFO_SUBJECT: &str = + "0x00000000000000000000000000000000000000000000000000000000dee00001"; +const PKG_INFO_DEPENDENCY: &str = + "0x00000000000000000000000000000000000000000000000000000000dee00002"; + +#[derive(Parser)] +struct Args { + /// Path to the attestation-registry `demo-ids.json`. + #[clap(long, env = "DEMO_IDS")] + demo_ids: PathBuf, + /// Port for the mvr-api server. + #[clap(long, default_value_t = 8000)] + port: u16, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let ids: serde_json::Value = serde_json::from_slice(&std::fs::read(&args.demo_ids)?)?; + let subject = field(&ids, "subject"); + let dependency = field(&ids, "dependency"); + + // Ephemeral Postgres, alive for the lifetime of this process. + let temp_db = TempDb::new()?; + let url = temp_db.database().url().clone(); + + let mut db = Db::for_write(url.clone(), DbArgs::default()).await?; + db.run_migrations(Some(&MIGRATIONS)).await?; + + seed( + &mut db, + "@demo/subject", + &subject, + PKG_INFO_SUBJECT, + "The example subject package browsed in the MVR attestation demo.", + ) + .await?; + seed( + &mut db, + "@demo/dependency", + &dependency, + PKG_INFO_DEPENDENCY, + "A dependency of @demo/subject.", + ) + .await?; + + println!("seeded @demo/subject -> {subject}"); + println!("seeded @demo/dependency -> {dependency}"); + println!("point the frontend's mainnet mvrEndpoint at http://127.0.0.1:{}", args.port); + + run_server( + url, + DbArgs::default(), + Network::Mainnet, + args.port, + CancellationToken::new(), + SocketAddr::from_str("0.0.0.0:9184")?, + ) + .await +} + +/// Read `subjects.` from demo-ids.json, or panic with a clear message. +fn field(ids: &serde_json::Value, key: &str) -> String { + ids["subjects"][key] + .as_str() + .unwrap_or_else(|| panic!("demo-ids.json missing subjects.{key}")) + .to_string() +} + +/// Insert the `packages` / `package_infos` / `name_records` rows that make +/// `name` resolve to `package_id` (version 1, not upgraded) on mainnet. +async fn seed( + db: &mut Db, + name: &str, + package_id: &str, + pkg_info_id: &str, + description: &str, +) -> anyhow::Result<()> { + let package = Package { + package_id: package_id.to_string(), + original_id: package_id.to_string(), + package_version: 1, + move_package: vec![], + chain_id: "localnet".to_string(), + tx_hash: String::new(), + sender: String::new(), + timestamp: NaiveDateTime::MAX, + deps: vec![], + }; + let package_info = PackageInfo { + id: pkg_info_id.to_string(), + object_version: 0, + package_id: package_id.to_string(), + git_table_id: String::new(), + chain_id: "localnet".to_string(), + default_name: Some(name.to_string()), + metadata: serde_json::Value::Null, + }; + let name_record = NameRecord { + name: name.to_string(), + object_version: 0, + mainnet_id: Some(pkg_info_id.to_string()), + testnet_id: None, + metadata: json!({ "description": description }), + }; + + let mut conn = db.connect().await?; + insert_into(packages::table).values(vec![package]).execute(&mut *conn).await?; + insert_into(package_infos::table).values(vec![package_info]).execute(&mut *conn).await?; + insert_into(name_records::table).values(vec![name_record]).execute(&mut *conn).await?; + Ok(()) +} From 00b84b8a5b905c0eb62917526e10a6603a630a93 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 08:35:33 -0400 Subject: [PATCH 03/54] demo: repoint all networks (not just mainnet) at the local stack Rename the override env vars to NEXT_PUBLIC_LOCAL_RPC_URL / NEXT_PUBLIC_LOCAL_MVR_ENDPOINT and apply them to both networks plus the experimental/analytics endpoints, so the demo never touches live Sui infra. Fixes a crash where the analytics call still hit real QA mainnet. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 9 ++++--- app/.env.example | 9 ++++--- .../components/providers/client-provider.tsx | 25 +++++++++---------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index 6c22edf9..a0f96676 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -188,10 +188,11 @@ KEEP_ALIVE=1 bash scripts/run-demo.sh # writes demo-ids.json, holds :900 cargo run -p mvr-api --example demo_server -- \ --demo-ids ~/Mysten/sui-attestation-registry/demo-ids.json --port 8000 -# 3) mvr repo: the frontend, mainnet repointed at the local stack via app/.env -# (NEXT_PUBLIC_MAINNET_RPC_URL=http://127.0.0.1:9000, -# NEXT_PUBLIC_MAINNET_MVR_ENDPOINT=http://127.0.0.1:8000) -pnpm --dir app dev +# 3) mvr repo: the frontend, all networks repointed at the local stack via +# app/.env (NEXT_PUBLIC_LOCAL_RPC_URL=http://127.0.0.1:9000, +# NEXT_PUBLIC_LOCAL_MVR_ENDPOINT=http://127.0.0.1:8000) so it never touches +# live Sui infra. Browse http://localhost:3000/package/@demo/subject +pnpm --dir app install && pnpm --dir app dev ``` Resolution check: `curl http://127.0.0.1:8000/v1/names/@demo/subject` returns diff --git a/app/.env.example b/app/.env.example index 0c8fd5d3..78881a68 100644 --- a/app/.env.example +++ b/app/.env.example @@ -13,7 +13,8 @@ # SERVERVAR="foo" # NEXT_PUBLIC_CLIENTVAR="bar" -# Optional: point the "mainnet" RPC + MVR endpoint at a local stack (a -# localnet + the attestation demo server). Leave unset for production. -# NEXT_PUBLIC_MAINNET_RPC_URL="http://127.0.0.1:9000" -# NEXT_PUBLIC_MAINNET_MVR_ENDPOINT="http://127.0.0.1:8000" +# Optional: point ALL networks at a local stack (a localnet + the attestation +# demo server) so the app doesn't depend on live Sui infra. Leave unset for +# production. See ATTESTATION-INTEGRATION.md. +# NEXT_PUBLIC_LOCAL_RPC_URL="http://127.0.0.1:9000" +# NEXT_PUBLIC_LOCAL_MVR_ENDPOINT="http://127.0.0.1:8000" diff --git a/app/src/components/providers/client-provider.tsx b/app/src/components/providers/client-provider.tsx index 471805a7..b6c14981 100644 --- a/app/src/components/providers/client-provider.tsx +++ b/app/src/components/providers/client-provider.tsx @@ -33,23 +33,22 @@ export type Clients = { }; }; -// The mainnet RPC and MVR endpoint can be overridden via env so the app can -// be pointed at a local stack (e.g. a localnet + the attestation demo server) -// without code changes. Unset in production, where the defaults apply. -const MAINNET_RPC_URL = - process.env.NEXT_PUBLIC_MAINNET_RPC_URL ?? "https://suins-rpc.mainnet.sui.io:443"; -const MAINNET_MVR_ENDPOINT = - process.env.NEXT_PUBLIC_MAINNET_MVR_ENDPOINT ?? "https://mainnet.mvr.mystenlabs.com"; +// When set (the local demo stack — a localnet + the attestation demo server), +// ALL networks are pointed at it so the app does not depend on live Sui infra. +// Unset in production, where the per-network defaults apply. +// See ATTESTATION-INTEGRATION.md. +const LOCAL_RPC = process.env.NEXT_PUBLIC_LOCAL_RPC_URL; +const LOCAL_MVR = process.env.NEXT_PUBLIC_LOCAL_MVR_ENDPOINT; const mainnet = new SuiClient({ - url: MAINNET_RPC_URL, + url: LOCAL_RPC ?? "https://suins-rpc.mainnet.sui.io:443", network: "mainnet", }); export const DefaultClients: Clients = { mainnet, testnet: new SuiClient({ - url: "https://suins-rpc.testnet.sui.io", + url: LOCAL_RPC ?? "https://suins-rpc.testnet.sui.io", network: "testnet", }), devnet: new SuiClient({ url: getFullnodeUrl("devnet"), network: "devnet" }), @@ -72,12 +71,12 @@ export const DefaultClients: Clients = { }), }, mvrEndpoints: { - mainnet: MAINNET_MVR_ENDPOINT, - testnet: "https://testnet.mvr.mystenlabs.com", + mainnet: LOCAL_MVR ?? "https://mainnet.mvr.mystenlabs.com", + testnet: LOCAL_MVR ?? "https://testnet.mvr.mystenlabs.com", }, mvrExperimentalEndpoints: { - mainnet: "https://qa.mainnet.mvr.mystenlabs.com", - testnet: "https://qa.testnet.mvr.mystenlabs.com", + mainnet: LOCAL_MVR ?? "https://qa.mainnet.mvr.mystenlabs.com", + testnet: LOCAL_MVR ?? "https://qa.testnet.mvr.mystenlabs.com", }, }; From 738f49b6b928f66510a202db9b3a75bbe26430c7 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 08:45:25 -0400 Subject: [PATCH 04/54] M2: Attestations tab reading from the registry over JSON-RPC - lib/attestations.ts: env-driven config, boxAddress (deriveObjectID), Attestation JSON-RPC mapper, trusted-lineage helpers, and the ported conventions effectiveness interpreter (active/expires/requires with cycle guard). - useGetAttestations: getOwnedObjects on the per-subject Box, client-side trusted-lineage filter + Display-gate, per-attestation effectiveness. - SinglePackageAttestations tab: grouped by attester, exact inner type, effectiveness badge, https report link; count label of effective attestations. Registered in the SinglePackage Tabs. - scripts/write-demo-env.sh: generate app/.env (endpoints + attestation config incl. attester lineage) from demo-ids.json. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 19 +- .../single-package/SinglePackage.tsx | 22 +++ .../SinglePackageAttestations.tsx | 168 +++++++++++++++++ app/src/hooks/useGetAttestations.ts | 98 ++++++++++ app/src/lib/attestations.ts | 174 ++++++++++++++++++ app/src/utils/types.ts | 1 + scripts/write-demo-env.sh | 47 +++++ 7 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 app/src/components/single-package/SinglePackageAttestations.tsx create mode 100644 app/src/hooks/useGetAttestations.ts create mode 100644 app/src/lib/attestations.ts create mode 100644 scripts/write-demo-env.sh diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index a0f96676..cd553e97 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -167,9 +167,11 @@ hygiene-only if an attester declares no domains). `@demo/subject` resolves to the localnet package address (verified). Visual page render is confirmed alongside M2 (the Attestations tab), which is where there's something attestation-specific to see. -- **M2** — Read hook with a **client-side lineage filter** (proves the +- **M2 ✅** — Read hook with a **client-side lineage filter** (proves the end-to-end pipeline; not yet spam-proof) + the Attestations tab rendering - Display fields and effectiveness. + Display fields and effectiveness. Verified: the tab shows the AuditV2 + (matched via the upgraded lineage) as ineffective post-revoke, and the + Vulnerability as effective. - **M3** — Swap in the Option 2 **server-side `MatchAny`** over the Display-registered trusted-type set (spam-resistance); finalize the enumeration mechanism from the M2 spike. @@ -208,13 +210,14 @@ the localnet `package_address`. The demo server lives at - [x] Standalone Postgres seeder (lift `setup_dummy_data`) → `name_records` pointing at published subject IDs. (`examples/demo_server.rs`) - [x] Local run recipe: localnet + mvr-api + frontend env overrides. -- [ ] `lib/constants.ts`: registry ids + `trustedAttestors` config. -- [ ] Port `boxAddress`; add JSON-RPC `AttestationInfo` mapper. +- [x] Attestation config (`lib/attestations.ts`, env `NEXT_PUBLIC_ATTESTATION_CONFIG` + generated from `demo-ids.json` by `scripts/write-demo-env.sh`). +- [x] Port `boxAddress`; add JSON-RPC `AttestationInfo` mapper. - [ ] Spike: GraphQL generic type-filter for `Display>`; choose - enumeration mechanism. -- [ ] Trusted-type resolver (lineage + Display enumeration), cached per attester. -- [ ] `useGetAttestations` hook + port `conventions.ts`. -- [ ] Attestations tab + count label; group-by-attester; row with exact `T`, + enumeration mechanism. (M3) +- [ ] Trusted-type resolver (lineage + Display enumeration), cached per attester. (M3) +- [x] `useGetAttestations` hook + port `conventions.ts`. +- [x] Attestations tab + count label; group-by-attester; row with exact `T`, effectiveness, de-emphasized ineffective. - [ ] `image_url`/`link` conventions in `CONVENTIONS.md` + `conventions.ts`; host-allowlist rendering in the tab. diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 15334ddd..a953b461 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -12,6 +12,10 @@ import { ReadMeRenderer } from "./ReadMeRenderer"; import { SinglePackageDependencies } from "./SinglePackageDependencies"; import { SinglePackageDependents } from "./SinglePackageDependents"; import { SinglePackageVersions } from "./SinglePackageVersions"; +import { + SinglePackageAttestations, + AttestationCount, +} from "./SinglePackageAttestations"; import { DependenciesIconSelected } from "@/icons/single-package/DependenciesIcon"; import { DependendsIconSelected } from "@/icons/single-package/DependendsIcon"; import { DependenciesIconUnselected } from "@/icons/single-package/DependenciesIcon"; @@ -24,6 +28,14 @@ import { SinglePackageTab } from "@/utils/types"; import { AnalyticsIconUnselected } from "@/icons/single-package/AnalyticsIcon"; import { AnalyticsIconSelected } from "@/icons/single-package/AnalyticsIcon"; +// Simple shield-check glyph for the Attestations tab; reused for both states. +const AttestationsIcon = () => ( + + + + +); + export const Tabs: SinglePackageTab[] = [ { key: "readme", @@ -68,6 +80,16 @@ export const Tabs: SinglePackageTab[] = [ ) => , component: (name: ResolvedName) => , }, + { + key: "attestations", + title: "Attestations", + selectedIcon: , + unselectedIcon: , + label: (address: string, network: "mainnet" | "testnet") => ( + + ), + component: (name: ResolvedName) => , + }, { key: "analytics", title: "Analytics", diff --git a/app/src/components/single-package/SinglePackageAttestations.tsx b/app/src/components/single-package/SinglePackageAttestations.tsx new file mode 100644 index 00000000..f616ed71 --- /dev/null +++ b/app/src/components/single-package/SinglePackageAttestations.tsx @@ -0,0 +1,168 @@ +import { ResolvedName } from "@/hooks/mvrResolution"; +import { usePackagesNetwork } from "../providers/packages-provider"; +import { + useGetAttestations, + type DisplayedAttestation, +} from "@/hooks/useGetAttestations"; +import { Text } from "../ui/Text"; +import LoadingState from "../LoadingState"; +import { DependentsCountLabel } from "./SinglePackageTabs"; +import type { TrustedAttestor } from "@/lib/attestations"; + +/** Tab label: count of effective attestations from trusted attesters. */ +export function AttestationCount({ + address, + network, +}: { + address: string; + network: "mainnet" | "testnet"; +}) { + const { data } = useGetAttestations(address, network); + const count = (data ?? []).filter((a) => a.effective).length; + if (!count) return null; + return ; +} + +export function SinglePackageAttestations({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { data, isLoading } = useGetAttestations(name.package_address, network); + + const groups = groupByAttestor(data ?? []); + + return ( + <> + +

Attestations

+
+ + {isLoading && ( + + )} + + {!isLoading && groups.length === 0 && ( + + No attestations from trusted attestors. + + )} + +
+ {groups.map((g) => ( +
+
+ + {g.attestor.name} + + + {g.attestor.originalId} + +
+ {g.items.map((item) => ( + + ))} +
+ ))} +
+ + ); +} + +function AttestationRow({ item }: { item: DisplayedAttestation }) { + const { display, innerType } = item.info; + const title = str(display["name"]) ?? "Attestation"; + const description = str(display["description"]); + const link = httpsLink(display["link"]); + + return ( +
+
+ + {title} + + +
+ {description && ( + + {description} + + )} + + {innerType} + + {link && ( + + + {hostOf(link)} + + + )} +
+ ); +} + +function StatusBadge({ item }: { item: DisplayedAttestation }) { + const revoked = item.info.display["active"] === "false"; + const label = revoked ? "Revoked" : item.effective ? "Effective" : "Ineffective"; + const tone = item.effective + ? "text-content-positive" + : "text-content-negative opacity-80"; + return ( + + {label} + + ); +} + +interface AttestorGroup { + attestor: TrustedAttestor; + items: DisplayedAttestation[]; +} + +/** Group attestations by their trusted attester, preserving discovery order. */ +function groupByAttestor(items: DisplayedAttestation[]): AttestorGroup[] { + const groups: AttestorGroup[] = []; + for (const item of items) { + let group = groups.find((g) => g.attestor.originalId === item.attestor.originalId); + if (!group) { + group = { attestor: item.attestor, items: [] }; + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +/** Only surface https links (basic hygiene; full host-allowlisting is M4). */ +function httpsLink(v: unknown): string | undefined { + const s = str(v); + return s && s.startsWith("https://") ? s : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts new file mode 100644 index 00000000..a286da4b --- /dev/null +++ b/app/src/hooks/useGetAttestations.ts @@ -0,0 +1,98 @@ +import { useSuiClientsContext } from "@/components/providers/client-provider"; +import { AppQueryKeys } from "@/utils/types"; +import { useQuery } from "@tanstack/react-query"; +import type { SuiClient } from "@mysten/sui/client"; +import { + attestationConfig, + attestorFor, + boxAddress, + isEffective, + toAttestationInfo, + type AttestationInfo, + type ConventionsContext, + type TrustedAttestor, +} from "@/lib/attestations"; + +export interface DisplayedAttestation { + info: AttestationInfo; + /** The trusted attester this attestation's type belongs to. */ + attestor: TrustedAttestor; + /** Effectiveness per the conventions (active + unexpired + requires met). */ + effective: boolean; +} + +/** Resolve an attestation by id from chain (for `requires` traversal). */ +function fetchByIdFor(client: SuiClient): ConventionsContext["fetchById"] { + return async (id: string) => { + const resp = await client.getObject({ + id, + options: { showType: true, showDisplay: true }, + }); + const info = toAttestationInfo(resp); + if (!info) throw new Error(`required attestation ${id} is not an Attestation`); + return info; + }; +} + +/** + * List the attestations about `subject` from the configured trusted attesters. + * + * Reads the per-subject Box directly over JSON-RPC (no MVR backend), keeps only + * `Attestation` whose inner-type package is in a trusted lineage and that + * carry a registered Display, and computes each one's effectiveness. M2 filters + * the lineage client-side; M3 will scope it server-side via `MatchAny` over the + * exact Display-registered trusted types. + */ +export function useGetAttestations( + subject: string | undefined, + network: "mainnet" | "testnet", +) { + const client = useSuiClientsContext()[network]; + const cfg = attestationConfig(); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, network, subject], + enabled: !!subject && !!cfg, + queryFn: async (): Promise => { + const owner = boxAddress(cfg!.registryId, subject!); + const structType = `${cfg!.registryPkg}::attestation_registry::Attestation`; + + // 1. List every Attestation<*> on the box (empty type params match all + // instantiations server-side). + const infos: AttestationInfo[] = []; + let cursor: string | null | undefined = null; + do { + const page = await client.getOwnedObjects({ + owner, + filter: { StructType: structType }, + options: { showType: true, showDisplay: true }, + cursor, + }); + for (const r of page.data) { + const info = toAttestationInfo(r); + if (info) infos.push(info); + } + cursor = page.hasNextPage ? page.nextCursor : null; + } while (cursor); + + // 2. Keep trusted attesters (inner-type package in a trusted lineage) + // that registered a Display (non-empty display fields). + const trusted = infos + .map((info) => ({ info, attestor: attestorFor(cfg!, info.innerType) })) + .filter( + (x): x is { info: AttestationInfo; attestor: TrustedAttestor } => + !!x.attestor && Object.keys(x.info.display).length > 0, + ); + + // 3. Effectiveness honours the transitive `requires` convention. + const ctx: ConventionsContext = { fetchById: fetchByIdFor(client) }; + return Promise.all( + trusted.map(async ({ info, attestor }) => ({ + info, + attestor, + effective: await isEffective(info, ctx), + })), + ); + }, + }); +} diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts new file mode 100644 index 00000000..9e8ffda5 --- /dev/null +++ b/app/src/lib/attestations.ts @@ -0,0 +1,174 @@ +// Reading package attestations from the attestation registry. This is a pure +// chain read (no MVR backend): derive the per-subject Box address and list the +// `Attestation` objects it owns, keeping only those from trusted attesters. +// See ATTESTATION-INTEGRATION.md. + +import { deriveObjectID, fromHex, normalizeSuiAddress } from "@mysten/sui/utils"; +import type { SuiObjectResponse } from "@mysten/sui/client"; + +// === Config (NEXT_PUBLIC_ATTESTATION_CONFIG, JSON) === + +export interface TrustedAttestor { + /** Human-readable label, shown as the attester heading. */ + name: string; + /** Original publish id of the attester package — the trust anchor. */ + originalId: string; + /** Every package-version id in the attester's lineage. Matching an + * attestation's inner-type package against this set is equivalent to + * resolving that package's original id (the on-chain `attester_of` rule). + * M2 seeds this directly; M3 derives it via GraphQL `packageVersions`. */ + lineage: string[]; +} + +export interface AttestationConfig { + /** attestation_registry package id (the `Attestation<>` wrapper type). */ + registryPkg: string; + /** The shared Registry object id — parent for per-subject Box derivation. */ + registryId: string; + trustedAttestors: TrustedAttestor[]; +} + +let cached: AttestationConfig | null | undefined; + +/** Parse the attestation config from env, or null if unset (production). */ +export function attestationConfig(): AttestationConfig | null { + if (cached === undefined) { + const raw = process.env.NEXT_PUBLIC_ATTESTATION_CONFIG; + cached = raw ? (JSON.parse(raw) as AttestationConfig) : null; + } + return cached; +} + +// === Box address === + +/** Address of the per-subject `Box`, mirroring `derived_object::derive_address`. */ +export function boxAddress(registryId: string, subject: string): string { + const subjectBytes = fromHex(normalizeSuiAddress(subject).slice(2)); + return deriveObjectID(registryId, "0x2::object::ID", subjectBytes); +} + +// === Attestation info + mapping === + +export interface AttestationInfo { + id: string; + /** Full object type, `…::attestation_registry::Attestation`. */ + type: string; + /** The inner type `T`, e.g. `0xAUD::audit::Audit`. */ + innerType: string; + /** Server-rendered Display v2 fields (all values are strings). */ + display: Record; +} + +const ATTESTATION_RE = /::attestation_registry::Attestation<(.+)>$/; + +/** + * Map a `getOwnedObjects`/`getObject` response into `AttestationInfo`, or null + * if it isn't a well-formed `Attestation`. JSON-RPC nests Display fields + * under `data.display.data`. + */ +export function toAttestationInfo(resp: SuiObjectResponse): AttestationInfo | null { + const d = resp.data; + if (!d?.type) return null; + const m = d.type.match(ATTESTATION_RE); + if (!m) return null; + return { + id: d.objectId, + type: d.type, + innerType: m[1]!, + display: (d.display?.data ?? {}) as Record, + }; +} + +/** The defining (origin) package id of an inner type string. */ +export function innerTypePackage(innerType: string): string { + return normalizeSuiAddress(innerType.split("::")[0]!); +} + +/** The trusted attester whose lineage defines `innerType`, if any. */ +export function attestorFor( + cfg: AttestationConfig, + innerType: string, +): TrustedAttestor | undefined { + const pkg = innerTypePackage(innerType); + return cfg.trustedAttestors.find((a) => + a.lineage.some((id) => normalizeSuiAddress(id) === pkg), + ); +} + +// === Conventions (effectiveness) — ported from the attestation-registry repo's +// ts/lib/conventions.ts. Operates purely on Display fields + ids. === + +export interface ConventionsContext { + fetchById: (id: string) => Promise; + now?: () => number; +} + +/** `active` renders as "true"/"false"; absent defaults to active. */ +function readActive(att: AttestationInfo): boolean { + const raw = att.display["active"]; + if (raw === true || raw === "true") return true; + if (raw === false || raw === "false") return false; + return true; +} + +/** `expires_at` as Unix-ms, or null if absent/unparseable. */ +function readExpiresAt(att: AttestationInfo): number | null { + const raw = att.display["expires_at"]; + if (raw == null) return null; + if (typeof raw === "number") return raw; + if (typeof raw === "string") { + const asNum = Number(raw); + if (!Number.isNaN(asNum) && asNum > 0) return asNum; + const asDate = Date.parse(raw); + if (!Number.isNaN(asDate)) return asDate; + } + return null; +} + +/** `requires` as a list of attestation ids (a JSON-array string over JSON-RPC). */ +function readRequires(att: AttestationInfo): string[] { + const raw = att.display["requires"]; + if (raw == null) return []; + if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string"); + if (typeof raw === "string") { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter((x): x is string => typeof x === "string"); + } + } catch { + // not JSON; ignore + } + } + return []; +} + +/** + * An attestation is effective iff it is active, unexpired, and every + * attestation it `requires` is (transitively) effective. Cycles — which can't + * occur on-chain but could in malformed Display data — are treated as + * ineffective. + */ +export async function isEffective( + att: AttestationInfo, + ctx: ConventionsContext, + visited: Set = new Set(), +): Promise { + if (!readActive(att)) return false; + + const expiresAt = readExpiresAt(att); + const now = (ctx.now ?? Date.now)(); + if (expiresAt !== null && now >= expiresAt) return false; + + const required = readRequires(att); + if (required.length > 0) { + if (visited.has(att.id)) return false; // cycle + const next = new Set(visited); + next.add(att.id); + for (const reqId of required) { + const req = await ctx.fetchById(reqId); + if (!(await isEffective(req, ctx, next))) return false; + } + } + return true; +} diff --git a/app/src/utils/types.ts b/app/src/utils/types.ts index 51355fbc..9cba5b02 100644 --- a/app/src/utils/types.ts +++ b/app/src/utils/types.ts @@ -75,4 +75,5 @@ export enum AppQueryKeys { MVR_VERSION_ADDRESSES = "mvr-version-addresses", SUINS_NAME_RESOLUTION = "suins-name-resolution", NAME_ANALYTICS = "name-analytics", + ATTESTATIONS = "attestations", } diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh new file mode 100644 index 00000000..0b0ac826 --- /dev/null +++ b/scripts/write-demo-env.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Generate app/.env for the local attestation demo from the attestation-registry +# repo's demo-ids.json: repoint all networks at the local stack and inject the +# attestation config (registry id/pkg + trusted attesters with their lineage). +# +# Usage: +# bash scripts/write-demo-env.sh [path/to/demo-ids.json] +# Env overrides: RPC_URL, MVR_ENDPOINT. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ENV="$SCRIPT_DIR/../app/.env" +DEMO_IDS="${1:-$HOME/Mysten/sui-attestation-registry/demo-ids.json}" +RPC="${RPC_URL:-http://127.0.0.1:9000}" +MVR="${MVR_ENDPOINT:-http://127.0.0.1:8000}" + +if [[ ! -f "$DEMO_IDS" ]]; then + echo "demo-ids.json not found at $DEMO_IDS (run the attestation demo first)" >&2 + exit 1 +fi + +# Build the attestation config. Each attester's lineage is its original id plus +# any later version ids (deduped) — the M2 client-side trusted set. +CONFIG=$(python3 - "$DEMO_IDS" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +attestors = [] +for a in d["trustedAttestors"]: + lineage = list(dict.fromkeys([a["originalId"], a.get("latestId", a["originalId"])])) + attestors.append({"name": a["name"], "originalId": a["originalId"], "lineage": lineage}) +print(json.dumps({ + "registryPkg": d["attestationRegistryPkg"], + "registryId": d["registryId"], + "trustedAttestors": attestors, +}, separators=(",", ":"))) +PY +) + +{ + echo "NEXT_PUBLIC_LOCAL_RPC_URL=\"$RPC\"" + echo "NEXT_PUBLIC_LOCAL_MVR_ENDPOINT=\"$MVR\"" + echo "NEXT_PUBLIC_ATTESTATION_CONFIG='$CONFIG'" +} > "$APP_ENV" + +echo "wrote $APP_ENV" +cat "$APP_ENV" From 43da69abb3d3a7da6e3f07aca5e1e6570caf2a3e Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 09:45:11 -0400 Subject: [PATCH 05/54] Trust Signals tab: polarity sections, count pills, attester avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the package tab to Trust Signals with Attestations as a section; split into Vulnerabilities and Audits sections (by polarity), each grouped by attester with an initials avatar. - Tab badge: two pills — effective positive count (check) and effective negative count (warning) — as real SVG icons (no more orange-on-orange). - lib/attestations.ts: isNegative() polarity helper, optional iconUrl on TrustedAttestor. - Doc: pass plan + web-of-trust whitelist and summary-field follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 27 ++ .../single-package/SinglePackage.tsx | 22 +- .../SinglePackageAttestations.tsx | 168 --------- .../SinglePackageTrustSignals.tsx | 344 ++++++++++++++++++ app/src/lib/attestations.ts | 9 + 5 files changed, 391 insertions(+), 179 deletions(-) delete mode 100644 app/src/components/single-package/SinglePackageAttestations.tsx create mode 100644 app/src/components/single-package/SinglePackageTrustSignals.tsx diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index cd553e97..5b79bfa9 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -223,8 +223,35 @@ the localnet `package_address`. The demo server lives at host-allowlist rendering in the tab. - [ ] Sidebar trust badge (phase 2). +## Later passes (post-M2 UI feedback) + +- **Pass 1 ✅** — polarity convention (positive/negative), Trust Signals tab + with separate Vulnerabilities/Audits sections + per-kind count pills + + attester avatars; negative test data (untrusted attester + undisplayed type) + proving both filters. +- **Pass 2** — negative **propagation**: surface a dependency's effective vulns + on its dependents (walk the dep graph; seed the `subject → dependency` edge). + Plus auditor MVR-page links (seed auditor names; link attester → its MVR + page). +- **Pass 3** — `requires`/propagation provenance + an attestation detail view + ("why ineffective", which required attestation was revoked). +- **Pass 4** — reverse "audits issued by this auditor" tab (needs a reverse + query over events/indexer). + ## Out of scope / follow-ups +- **Web-of-trust whitelist bootstrap.** Replace the hardcoded `trustedAttestors` + with on-chain meta-attestations: MVR defines a `TrustedAuditor` schema and + issues `Attestation` about auditor packages; the only + hardcoded value becomes MVR's own attester package id (the trust root). Per + attestation, check whether its attester package carries an effective + `TrustedAuditor` attestation from MVR (a per-attester lookup, dynamic and + revocable). Non-transitive to start. +- **`summary` vs `description` convention.** A short `summary` field for list + rows, separate from a fuller `description`, if on-chain description size + becomes a concern. Undecided. +- `image_url`/`link` host-allowlisting (constrain to the attester's declared + domains) — currently https-only. - gRPC read path (revisit when MVR moves to `@mysten/sui` 2.x). - Full `mvr-indexer`-on-localnet stack (D1 seeds Postgres directly instead). - Adding a first-class `localnet` network to the MVR UI (the demo repoints diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index a953b461..6ee2e86f 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -13,9 +13,9 @@ import { SinglePackageDependencies } from "./SinglePackageDependencies"; import { SinglePackageDependents } from "./SinglePackageDependents"; import { SinglePackageVersions } from "./SinglePackageVersions"; import { - SinglePackageAttestations, - AttestationCount, -} from "./SinglePackageAttestations"; + SinglePackageTrustSignals, + TrustSignalCount, +} from "./SinglePackageTrustSignals"; import { DependenciesIconSelected } from "@/icons/single-package/DependenciesIcon"; import { DependendsIconSelected } from "@/icons/single-package/DependendsIcon"; import { DependenciesIconUnselected } from "@/icons/single-package/DependenciesIcon"; @@ -28,8 +28,8 @@ import { SinglePackageTab } from "@/utils/types"; import { AnalyticsIconUnselected } from "@/icons/single-package/AnalyticsIcon"; import { AnalyticsIconSelected } from "@/icons/single-package/AnalyticsIcon"; -// Simple shield-check glyph for the Attestations tab; reused for both states. -const AttestationsIcon = () => ( +// Simple shield-check glyph for the Trust Signals tab; reused for both states. +const TrustSignalsIcon = () => ( @@ -81,14 +81,14 @@ export const Tabs: SinglePackageTab[] = [ component: (name: ResolvedName) => , }, { - key: "attestations", - title: "Attestations", - selectedIcon: , - unselectedIcon: , + key: "trust-signals", + title: "Trust Signals", + selectedIcon: , + unselectedIcon: , label: (address: string, network: "mainnet" | "testnet") => ( - + ), - component: (name: ResolvedName) => , + component: (name: ResolvedName) => , }, { key: "analytics", diff --git a/app/src/components/single-package/SinglePackageAttestations.tsx b/app/src/components/single-package/SinglePackageAttestations.tsx deleted file mode 100644 index f616ed71..00000000 --- a/app/src/components/single-package/SinglePackageAttestations.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { ResolvedName } from "@/hooks/mvrResolution"; -import { usePackagesNetwork } from "../providers/packages-provider"; -import { - useGetAttestations, - type DisplayedAttestation, -} from "@/hooks/useGetAttestations"; -import { Text } from "../ui/Text"; -import LoadingState from "../LoadingState"; -import { DependentsCountLabel } from "./SinglePackageTabs"; -import type { TrustedAttestor } from "@/lib/attestations"; - -/** Tab label: count of effective attestations from trusted attesters. */ -export function AttestationCount({ - address, - network, -}: { - address: string; - network: "mainnet" | "testnet"; -}) { - const { data } = useGetAttestations(address, network); - const count = (data ?? []).filter((a) => a.effective).length; - if (!count) return null; - return ; -} - -export function SinglePackageAttestations({ name }: { name: ResolvedName }) { - const network = usePackagesNetwork() as "mainnet" | "testnet"; - const { data, isLoading } = useGetAttestations(name.package_address, network); - - const groups = groupByAttestor(data ?? []); - - return ( - <> - -

Attestations

- - - {isLoading && ( - - )} - - {!isLoading && groups.length === 0 && ( - - No attestations from trusted attestors. - - )} - -
- {groups.map((g) => ( -
-
- - {g.attestor.name} - - - {g.attestor.originalId} - -
- {g.items.map((item) => ( - - ))} -
- ))} -
- - ); -} - -function AttestationRow({ item }: { item: DisplayedAttestation }) { - const { display, innerType } = item.info; - const title = str(display["name"]) ?? "Attestation"; - const description = str(display["description"]); - const link = httpsLink(display["link"]); - - return ( -
-
- - {title} - - -
- {description && ( - - {description} - - )} - - {innerType} - - {link && ( - - - {hostOf(link)} - - - )} -
- ); -} - -function StatusBadge({ item }: { item: DisplayedAttestation }) { - const revoked = item.info.display["active"] === "false"; - const label = revoked ? "Revoked" : item.effective ? "Effective" : "Ineffective"; - const tone = item.effective - ? "text-content-positive" - : "text-content-negative opacity-80"; - return ( - - {label} - - ); -} - -interface AttestorGroup { - attestor: TrustedAttestor; - items: DisplayedAttestation[]; -} - -/** Group attestations by their trusted attester, preserving discovery order. */ -function groupByAttestor(items: DisplayedAttestation[]): AttestorGroup[] { - const groups: AttestorGroup[] = []; - for (const item of items) { - let group = groups.find((g) => g.attestor.originalId === item.attestor.originalId); - if (!group) { - group = { attestor: item.attestor, items: [] }; - groups.push(group); - } - group.items.push(item); - } - return groups; -} - -function str(v: unknown): string | undefined { - return typeof v === "string" && v.length > 0 ? v : undefined; -} - -/** Only surface https links (basic hygiene; full host-allowlisting is M4). */ -function httpsLink(v: unknown): string | undefined { - const s = str(v); - return s && s.startsWith("https://") ? s : undefined; -} - -function hostOf(url: string): string { - try { - return new URL(url).host; - } catch { - return url; - } -} diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx new file mode 100644 index 00000000..3877ce1e --- /dev/null +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -0,0 +1,344 @@ +import { ResolvedName } from "@/hooks/mvrResolution"; +import { usePackagesNetwork } from "../providers/packages-provider"; +import { + useGetAttestations, + type DisplayedAttestation, +} from "@/hooks/useGetAttestations"; +import { Text } from "../ui/Text"; +import LoadingState from "../LoadingState"; +import { isNegative, type TrustedAttestor } from "@/lib/attestations"; + +/** A triangle-exclamation glyph; color comes from the text color (currentColor). */ +function WarningIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +/** A check glyph; color comes from the text color (currentColor). */ +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +/** Tab label: two pills — effective positive attestations (check) and + * effective negative ones (warning). Each shown only when non-zero. */ +export function TrustSignalCount({ + address, + network, +}: { + address: string; + network: "mainnet" | "testnet"; +}) { + const { data } = useGetAttestations(address, network); + const effective = (data ?? []).filter((a) => a.effective); + const positives = effective.filter((a) => !isNegative(a.info)).length; + const negatives = effective.length - positives; + + if (!positives && !negatives) return null; + return ( +
+ {positives > 0 && ( + } + count={positives} + tone="text-content-positive" + /> + )} + {negatives > 0 && ( + } + count={negatives} + tone="text-content-warning" + /> + )} +
+ ); +} + +function CountPill({ + icon, + count, + tone, +}: { + icon: React.ReactNode; + count: number; + tone: string; +}) { + return ( +
+ {icon} + + {count} + +
+ ); +} + +export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { data, isLoading } = useGetAttestations(name.package_address, network); + + const all = data ?? []; + const negatives = all.filter((a) => isNegative(a.info)); + const positives = all.filter((a) => !isNegative(a.info)); + const activeVulns = negatives.filter((a) => a.effective).length; + + return ( +
+ +

Trust Signals

+
+ + {isLoading && ( + + )} + + {!isLoading && all.length === 0 && ( + + No attestations from trusted attestors. + + )} + + {negatives.length > 0 && ( +
} + note={activeVulns > 0 ? `${activeVulns} active` : "none active"} + noteTone={activeVulns > 0 ? "text-content-warning" : "text-content-tertiary"} + groups={groupByAttestor(negatives)} + /> + )} + + {positives.length > 0 && ( +
} + groups={groupByAttestor(positives)} + /> + )} +
+ ); +} + +function Section({ + title, + icon, + note, + noteTone, + groups, +}: { + title: string; + icon: React.ReactNode; + note?: string; + noteTone?: string; + groups: AttestorGroup[]; +}) { + return ( +
+
+ {icon} + + {title} + + {note && ( + + · {note} + + )} +
+ {groups.map((g) => ( + + ))} +
+ ); +} + +function AttestorGroupCard({ group }: { group: AttestorGroup }) { + return ( +
+
+ +
+ + {group.attestor.name} + + + {group.attestor.originalId} + +
+
+ {group.items.map((item) => ( + + ))} +
+ ); +} + +function AttesterAvatar({ attestor }: { attestor: TrustedAttestor }) { + const base = + "flex h-9 w-9 shrink-0 items-center justify-center rounded-full overflow-hidden"; + if (attestor.iconUrl) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ); + } + return ( + + + {initials(attestor.name)} + + + ); +} + +function AttestationRow({ item }: { item: DisplayedAttestation }) { + const { display, innerType } = item.info; + const negative = isNegative(item.info); + const title = str(display["name"]) ?? "Attestation"; + const description = str(display["description"]); + const link = httpsLink(display["link"]); + + return ( +
+
+ + {title} + + +
+ {description && ( + + {description} + + )} + + {innerType} + + {link && ( + + + {hostOf(link)} ↗ + + + )} +
+ ); +} + +function StatusBadge({ + item, + negative, +}: { + item: DisplayedAttestation; + negative: boolean; +}) { + const revoked = item.info.display["active"] === "false"; + const label = revoked ? "Revoked" : item.effective ? "Active" : "Ineffective"; + // For an effective *negative* attestation, "active" is bad news → warning tone. + const tone = !item.effective + ? "text-content-tertiary" + : negative + ? "text-content-warning" + : "text-content-positive"; + return ( + + {label} + + ); +} + +interface AttestorGroup { + attestor: TrustedAttestor; + items: DisplayedAttestation[]; +} + +/** Group attestations by their trusted attester, preserving discovery order. */ +function groupByAttestor(items: DisplayedAttestation[]): AttestorGroup[] { + const groups: AttestorGroup[] = []; + for (const item of items) { + let group = groups.find( + (g) => g.attestor.originalId === item.attestor.originalId, + ); + if (!group) { + group = { attestor: item.attestor, items: [] }; + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +const AVATAR_COLORS = ["bg-pastel-blue", "bg-pastel-green", "bg-pastel-purple", "bg-pastel-orange"]; + +/** Deterministic avatar background from the attester name. */ +function avatarColor(name: string): string { + let h = 0; + for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return AVATAR_COLORS[h % AVATAR_COLORS.length]!; +} + +/** Up to two uppercase initials from the (often `snake_case`) attester name. */ +function initials(name: string): string { + const parts = name.split(/[\s_-]+/).filter(Boolean); + const letters = parts.length >= 2 ? parts[0]![0]! + parts[1]![0]! : name.slice(0, 2); + return letters.toUpperCase(); +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +/** Only surface https links (basic hygiene; full host-allowlisting is M4). */ +function httpsLink(v: unknown): string | undefined { + const s = str(v); + return s && s.startsWith("https://") ? s : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 9e8ffda5..e60c7646 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -11,6 +11,9 @@ import type { SuiObjectResponse } from "@mysten/sui/client"; export interface TrustedAttestor { /** Human-readable label, shown as the attester heading. */ name: string; + /** Optional brand icon URL (from trust config, never from on-chain data). + * Absent → the UI renders an initials avatar. */ + iconUrl?: string; /** Original publish id of the attester package — the trust anchor. */ originalId: string; /** Every package-version id in the attester's lineage. Matching an @@ -84,6 +87,12 @@ export function innerTypePackage(innerType: string): string { return normalizeSuiAddress(innerType.split("::")[0]!); } +/** A negative attestation (e.g. a vulnerability) per the `polarity` convention. + * Absence of the field defaults to positive. */ +export function isNegative(att: AttestationInfo): boolean { + return att.display["polarity"] === "negative"; +} + /** The trusted attester whose lineage defines `innerType`, if any. */ export function attestorFor( cfg: AttestationConfig, From 7bddb9f96665674e119eee6fb7c3a6fe01803b62 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 10:27:34 -0400 Subject: [PATCH 06/54] Pass 2: vuln propagation, severity sort/chips, Security tab polish - demo_server seeds the subject -> dependency edge (powers the Dependencies tab and propagation). - useGetAttestations: extract fetchTrustedAttestations(); add useInheritedVulns() that surfaces a dependency's effective vulns on its dependents (negative-polarity dual of `requires`). - Security tab: Vulnerabilities (one severity-sorted list of own + inherited, CVSS band chips, max-severity-colored warning) and Audits sections; ineffective collapsed to a compact 'Inactive' line; friendly attester names (write-demo-env.sh) + attester avatars; count-pill numbers default-colored, icons colored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackage.tsx | 2 +- .../SinglePackageTrustSignals.tsx | 301 +++++++++++++----- app/src/hooks/useGetAttestations.ts | 164 +++++++--- app/src/lib/attestations.ts | 22 ++ crates/mvr-api/examples/demo_server.rs | 20 +- scripts/write-demo-env.sh | 12 +- 6 files changed, 399 insertions(+), 122 deletions(-) diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 6ee2e86f..4e61f58e 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -82,7 +82,7 @@ export const Tabs: SinglePackageTab[] = [ }, { key: "trust-signals", - title: "Trust Signals", + title: "Security", selectedIcon: , unselectedIcon: , label: (address: string, network: "mainnet" | "testnet") => ( diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 3877ce1e..d005c34f 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -2,11 +2,18 @@ import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; import { useGetAttestations, + useInheritedVulns, type DisplayedAttestation, + type InheritedVuln, } from "@/hooks/useGetAttestations"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; -import { isNegative, type TrustedAttestor } from "@/lib/attestations"; +import { + isNegative, + readSeverity, + severityBand, + type TrustedAttestor, +} from "@/lib/attestations"; /** A triangle-exclamation glyph; color comes from the text color (currentColor). */ function WarningIcon({ className }: { className?: string }) { @@ -45,7 +52,7 @@ function CheckIcon({ className }: { className?: string }) { } /** Tab label: two pills — effective positive attestations (check) and - * effective negative ones (warning). Each shown only when non-zero. */ + * effective negative ones (warning, incl. inherited). Each shown if non-zero. */ export function TrustSignalCount({ address, network, @@ -54,44 +61,44 @@ export function TrustSignalCount({ network: "mainnet" | "testnet"; }) { const { data } = useGetAttestations(address, network); + const { data: inherited } = useInheritedVulns(address, network); const effective = (data ?? []).filter((a) => a.effective); const positives = effective.filter((a) => !isNegative(a.info)).length; - const negatives = effective.length - positives; - if (!positives && !negatives) return null; + // Vulnerability scores (own effective negatives + inherited); the warning + // pill is colored by the most severe one. + const vulnScores = effective + .filter((a) => isNegative(a.info)) + .map((a) => readSeverity(a.info) ?? 0) + .concat((inherited ?? []).map((v) => readSeverity(v.attestation.info) ?? 0)); + const warnTone = severityBand(vulnScores.length ? Math.max(...vulnScores) : 0).tone; + + if (!positives && !vulnScores.length) return null; return (
{positives > 0 && ( } count={positives} - tone="text-content-positive" /> )} - {negatives > 0 && ( + {vulnScores.length > 0 && ( } - count={negatives} - tone="text-content-warning" + icon={} + count={vulnScores.length} /> )}
); } -function CountPill({ - icon, - count, - tone, -}: { - icon: React.ReactNode; - count: number; - tone: string; -}) { +/** A count pill: only the icon is colored; the number uses the default color + * to match the app's other count chips. */ +function CountPill({ icon, count }: { icon: React.ReactNode; count: number }) { return (
{icon} - + {count}
@@ -101,82 +108,180 @@ function CountPill({ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { data, isLoading } = useGetAttestations(name.package_address, network); + const { data: inheritedData } = useInheritedVulns(name.package_address, network); + const inherited = inheritedData ?? []; const all = data ?? []; const negatives = all.filter((a) => isNegative(a.info)); const positives = all.filter((a) => !isNegative(a.info)); - const activeVulns = negatives.filter((a) => a.effective).length; + const hasVulnSection = negatives.length > 0 || inherited.length > 0; return (
-

Trust Signals

+

Security

{isLoading && ( )} - {!isLoading && all.length === 0 && ( + {!isLoading && all.length === 0 && inherited.length === 0 && ( No attestations from trusted attestors. )} - {negatives.length > 0 && ( -
} - note={activeVulns > 0 ? `${activeVulns} active` : "none active"} - noteTone={activeVulns > 0 ? "text-content-warning" : "text-content-tertiary"} - groups={groupByAttestor(negatives)} - /> + {hasVulnSection && ( + )} - {positives.length > 0 && ( -
} - groups={groupByAttestor(positives)} - /> - )} + {positives.length > 0 && }
); } -function Section({ - title, - icon, - note, - noteTone, - groups, +/** A single vulnerability to render — own or inherited from a dependency. */ +interface VulnEntry { + attestation: DisplayedAttestation; + /** Set when the vulnerability is inherited from a dependency. */ + via?: { id: string; name?: string }; +} + +/** All vulnerabilities (own + inherited) in one severity-sorted list. */ +function VulnerabilitiesSection({ + own, + inherited, }: { - title: string; - icon: React.ReactNode; - note?: string; - noteTone?: string; - groups: AttestorGroup[]; + own: DisplayedAttestation[]; + inherited: InheritedVuln[]; }) { + const ineffective = own.filter((a) => !a.effective); + const entries: VulnEntry[] = [ + ...own.filter((a) => a.effective).map((a) => ({ attestation: a })), + ...inherited.map((v) => ({ + attestation: v.attestation, + via: { id: v.viaPackageId, name: v.viaName }, + })), + ].sort((a, b) => severityOf(b.attestation.info) - severityOf(a.attestation.info)); + + // Header is colored and summarized by the active vulnerabilities' severities. + const scores = entries.map((e) => readSeverity(e.attestation.info) ?? 0); + const maxTone = severityBand(scores.length ? Math.max(...scores) : 0).tone; + return (
- {icon} - + + + Vulnerabilities + + + · {scores.length ? severityBreakdown(scores) : "none active"} + +
+ {entries.map((e) => ( + + ))} + {ineffective.length > 0 && } +
+ ); +} + +/** Summarize scores by band, e.g. "1 high, 1 medium" (most severe first). */ +function severityBreakdown(scores: number[]): string { + const order = ["Critical", "High", "Medium", "Low", "None"]; + const counts: Record = {}; + for (const s of scores) { + const band = severityBand(s).label; + counts[band] = (counts[band] ?? 0) + 1; + } + return order + .filter((b) => counts[b]) + .map((b) => `${counts[b]} ${b.toLowerCase()}`) + .join(", "); +} + +function VulnRow({ entry }: { entry: VulnEntry }) { + const { attestation, via } = entry; + const { display, innerType } = attestation.info; + const title = str(display["name"]) ?? "Vulnerability"; + const description = str(display["description"]); + const link = httpsLink(display["link"]); + const severity = readSeverity(attestation.info); + + return ( +
+
+ {title} - {note && ( - - · {note} +
+ {severity !== null && } + +
+
+ {description && ( + + {description} + + )} +
+ + + {attestation.attestor.name} + {via && ( + <> + {" · in dependency "} + + + )} + +
+ + {innerType} + + {link && ( + + + {hostOf(link)} ↗ - )} + + )} +
+ ); +} + +/** Audits (positive attestations), grouped by attester. */ +function AuditsSection({ items }: { items: DisplayedAttestation[] }) { + const ineffective = items.filter((a) => !a.effective); + const groups = groupByAttestor(items.filter((a) => a.effective)); + return ( +
+
+ + + Audits +
{groups.map((g) => ( ))} + {ineffective.length > 0 && }
); } +/** Effective attestations from one trusted attester. */ function AttestorGroupCard({ group }: { group: AttestorGroup }) { return (
@@ -186,13 +291,8 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) { {group.attestor.name} - - {group.attestor.originalId} + + {truncateId(group.attestor.originalId)}
@@ -203,9 +303,30 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) { ); } -function AttesterAvatar({ attestor }: { attestor: TrustedAttestor }) { - const base = - "flex h-9 w-9 shrink-0 items-center justify-center rounded-full overflow-hidden"; +/** A compact, de-emphasized list of ineffective (revoked/superseded) ones. */ +function InactiveList({ items }: { items: DisplayedAttestation[] }) { + return ( + + Inactive:{" "} + {items.map((it, i) => ( + + {i > 0 ? ", " : ""} + {it.attestor.name} ({str(it.info.display["name"]) ?? "Attestation"}) + + ))} + + ); +} + +function AttesterAvatar({ + attestor, + size = "md", +}: { + attestor: TrustedAttestor; + size?: "sm" | "md"; +}) { + const dim = size === "sm" ? "h-5 w-5" : "h-9 w-9"; + const base = `flex ${dim} shrink-0 items-center justify-center rounded-full overflow-hidden`; if (attestor.iconUrl) { return ( @@ -216,31 +337,31 @@ function AttesterAvatar({ attestor }: { attestor: TrustedAttestor }) { } return ( - + {initials(attestor.name)} ); } +/** A positive attestation row (audits). */ function AttestationRow({ item }: { item: DisplayedAttestation }) { const { display, innerType } = item.info; - const negative = isNegative(item.info); const title = str(display["name"]) ?? "Attestation"; const description = str(display["description"]); const link = httpsLink(display["link"]); return ( -
+
{title} - +
{description && ( @@ -251,7 +372,7 @@ function AttestationRow({ item }: { item: DisplayedAttestation }) { as="p" kind="paragraph" size="paragraph-xs" - className="break-all font-mono opacity-60" + className="break-all font-mono opacity-50" > {innerType} @@ -266,6 +387,15 @@ function AttestationRow({ item }: { item: DisplayedAttestation }) { ); } +function SeverityChip({ score }: { score: number }) { + const band = severityBand(score); + return ( + + {band.label} ({score.toFixed(1)}) + + ); +} + function StatusBadge({ item, negative, @@ -275,7 +405,6 @@ function StatusBadge({ }) { const revoked = item.info.display["active"] === "false"; const label = revoked ? "Revoked" : item.effective ? "Active" : "Ineffective"; - // For an effective *negative* attestation, "active" is bad news → warning tone. const tone = !item.effective ? "text-content-tertiary" : negative @@ -288,6 +417,19 @@ function StatusBadge({ ); } +/** Link to a dependency's MVR page by name, or show its id when unresolved + * (the package route resolves by name, so a raw id isn't linkable). */ +function DepLink({ id, name }: { id: string; name?: string }) { + if (name) { + return ( + + {name} + + ); + } + return {truncateId(id)}; +} + interface AttestorGroup { attestor: TrustedAttestor; items: DisplayedAttestation[]; @@ -318,13 +460,22 @@ function avatarColor(name: string): string { return AVATAR_COLORS[h % AVATAR_COLORS.length]!; } -/** Up to two uppercase initials from the (often `snake_case`) attester name. */ +/** Up to two uppercase initials from the attester name. */ function initials(name: string): string { const parts = name.split(/[\s_-]+/).filter(Boolean); const letters = parts.length >= 2 ? parts[0]![0]! + parts[1]![0]! : name.slice(0, 2); return letters.toUpperCase(); } +/** CVSS score for sorting; unscored attestations sort last. */ +function severityOf(info: DisplayedAttestation["info"]): number { + return readSeverity(info) ?? -1; +} + +function truncateId(id: string): string { + return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id; +} + function str(v: unknown): string | undefined { return typeof v === "string" && v.length > 0 ? v : undefined; } diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index a286da4b..c770f9a1 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -2,12 +2,16 @@ import { useSuiClientsContext } from "@/components/providers/client-provider"; import { AppQueryKeys } from "@/utils/types"; import { useQuery } from "@tanstack/react-query"; import type { SuiClient } from "@mysten/sui/client"; +import { normalizeSuiAddress } from "@mysten/sui/utils"; +import { MvrHeader } from "@/lib/utils"; import { attestationConfig, attestorFor, boxAddress, isEffective, + isNegative, toAttestationInfo, + type AttestationConfig, type AttestationInfo, type ConventionsContext, type TrustedAttestor, @@ -21,6 +25,15 @@ export interface DisplayedAttestation { effective: boolean; } +/** A dependency's effective vulnerability, surfaced on a dependent package. */ +export interface InheritedVuln { + attestation: DisplayedAttestation; + /** The dependency package the vulnerability is attested about. */ + viaPackageId: string; + /** That dependency's MVR name, if it resolves. */ + viaName?: string; +} + /** Resolve an attestation by id from chain (for `requires` traversal). */ function fetchByIdFor(client: SuiClient): ConventionsContext["fetchById"] { return async (id: string) => { @@ -35,14 +48,58 @@ function fetchByIdFor(client: SuiClient): ConventionsContext["fetchById"] { } /** - * List the attestations about `subject` from the configured trusted attesters. - * - * Reads the per-subject Box directly over JSON-RPC (no MVR backend), keeps only - * `Attestation` whose inner-type package is in a trusted lineage and that - * carry a registered Display, and computes each one's effectiveness. M2 filters - * the lineage client-side; M3 will scope it server-side via `MatchAny` over the - * exact Display-registered trusted types. + * Core read: the attestations about `subject` from the configured trusted + * attesters, with effectiveness. Reads the per-subject Box directly over + * JSON-RPC (no MVR backend), keeps only `Attestation` whose inner-type + * package is in a trusted lineage and that carry a registered Display. + * M2 filters the lineage client-side; M3 will scope it server-side via + * `MatchAny` over the exact Display-registered trusted types. */ +export async function fetchTrustedAttestations( + client: SuiClient, + cfg: AttestationConfig, + subject: string, +): Promise { + const owner = boxAddress(cfg.registryId, subject); + const structType = `${cfg.registryPkg}::attestation_registry::Attestation`; + + // 1. List every Attestation<*> on the box (empty type params match all). + const infos: AttestationInfo[] = []; + let cursor: string | null | undefined = null; + do { + const page = await client.getOwnedObjects({ + owner, + filter: { StructType: structType }, + options: { showType: true, showDisplay: true }, + cursor, + }); + for (const r of page.data) { + const info = toAttestationInfo(r); + if (info) infos.push(info); + } + cursor = page.hasNextPage ? page.nextCursor : null; + } while (cursor); + + // 2. Keep trusted attesters (lineage) that registered a Display. + const trusted = infos + .map((info) => ({ info, attestor: attestorFor(cfg, info.innerType) })) + .filter( + (x): x is { info: AttestationInfo; attestor: TrustedAttestor } => + !!x.attestor && Object.keys(x.info.display).length > 0, + ); + + // 3. Effectiveness honours the transitive `requires` convention. + const ctx: ConventionsContext = { fetchById: fetchByIdFor(client) }; + return Promise.all( + trusted.map(async ({ info, attestor }) => ({ + info, + attestor, + effective: await isEffective(info, ctx), + })), + ); +} + +/** The attestations about `subject` from the configured trusted attesters. */ export function useGetAttestations( subject: string | undefined, network: "mainnet" | "testnet", @@ -53,46 +110,67 @@ export function useGetAttestations( return useQuery({ queryKey: [AppQueryKeys.ATTESTATIONS, network, subject], enabled: !!subject && !!cfg, - queryFn: async (): Promise => { - const owner = boxAddress(cfg!.registryId, subject!); - const structType = `${cfg!.registryPkg}::attestation_registry::Attestation`; + queryFn: () => fetchTrustedAttestations(client, cfg!, subject!), + }); +} - // 1. List every Attestation<*> on the box (empty type params match all - // instantiations server-side). - const infos: AttestationInfo[] = []; - let cursor: string | null | undefined = null; - do { - const page = await client.getOwnedObjects({ - owner, - filter: { StructType: structType }, - options: { showType: true, showDisplay: true }, - cursor, - }); - for (const r of page.data) { - const info = toAttestationInfo(r); - if (info) infos.push(info); - } - cursor = page.hasNextPage ? page.nextCursor : null; - } while (cursor); +/** + * Vulnerabilities inherited from `subject`'s dependencies: a dependency's + * effective negative attestations surface on its dependents (the negative dual + * of `requires` — see CONVENTIONS.md `polarity`). Dependencies come from MVR; + * the per-dependency reads are the same trusted-attestation reads as above. + */ +export function useInheritedVulns( + subject: string | undefined, + network: "mainnet" | "testnet", +) { + const clients = useSuiClientsContext(); + const client = clients[network]; + const endpoint = clients.mvrEndpoints[network]; + const cfg = attestationConfig(); - // 2. Keep trusted attesters (inner-type package in a trusted lineage) - // that registered a Display (non-empty display fields). - const trusted = infos - .map((info) => ({ info, attestor: attestorFor(cfg!, info.innerType) })) - .filter( - (x): x is { info: AttestationInfo; attestor: TrustedAttestor } => - !!x.attestor && Object.keys(x.info.display).length > 0, - ); + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "inherited", network, subject], + enabled: !!subject && !!cfg, + queryFn: async (): Promise => { + // 1. Direct dependencies (from MVR). + const res = await fetch( + `${endpoint}/v1/package-address/${subject}/dependencies`, + MvrHeader(), + ); + const deps: string[] = res.ok ? ((await res.json()).dependencies ?? []) : []; + if (!deps.length) return []; - // 3. Effectiveness honours the transitive `requires` convention. - const ctx: ConventionsContext = { fetchById: fetchByIdFor(client) }; - return Promise.all( - trusted.map(async ({ info, attestor }) => ({ - info, - attestor, - effective: await isEffective(info, ctx), - })), + // 2. Each dependency's effective negative attestations. + const perDep = await Promise.all( + deps.map(async (dep) => { + const atts = await fetchTrustedAttestations(client, cfg!, dep); + return atts + .filter((a) => a.effective && isNegative(a.info)) + .map((a) => ({ attestation: a, viaPackageId: dep })); + }), ); + const inherited = perDep.flat(); + if (!inherited.length) return inherited; + + // 3. Best-effort dependency names for the provenance note. + try { + const body = await fetch(`${endpoint}/v1/reverse-resolution/bulk`, { + method: "POST", + ...MvrHeader({ "Content-Type": "application/json" }), + body: JSON.stringify({ package_ids: deps }), + }).then((r) => r.json()); + const nameByAddr: Record = {}; + for (const [addr, r] of Object.entries(body.resolution ?? {})) { + nameByAddr[normalizeSuiAddress(addr)] = (r as { name?: string })?.name; + } + for (const v of inherited) { + v.viaName = nameByAddr[normalizeSuiAddress(v.viaPackageId)]; + } + } catch { + // names are optional; fall back to the package id in the UI + } + return inherited; }, }); } diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index e60c7646..6a53b54e 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -93,6 +93,28 @@ export function isNegative(att: AttestationInfo): boolean { return att.display["polarity"] === "negative"; } +/** The `severity` convention value (a CVSS base score 0–10), or null. */ +export function readSeverity(att: AttestationInfo): number | null { + const raw = att.display["severity"]; + const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN; + return Number.isFinite(n) ? n : null; +} + +export interface SeverityBand { + label: string; + /** A `text-…` color class for the band. */ + tone: string; +} + +/** Map a CVSS base score to its qualitative band (CVSS v3.1). */ +export function severityBand(score: number): SeverityBand { + if (score >= 9) return { label: "Critical", tone: "text-content-negative" }; + if (score >= 7) return { label: "High", tone: "text-content-negative" }; + if (score >= 4) return { label: "Medium", tone: "text-content-warning" }; + if (score > 0) return { label: "Low", tone: "text-content-tertiary" }; + return { label: "None", tone: "text-content-tertiary" }; +} + /** The trusted attester whose lineage defines `innerType`, if any. */ export function attestorFor( cfg: AttestationConfig, diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 339108e4..1a8ef3cd 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -25,8 +25,8 @@ use diesel::insert_into; use diesel_async::RunQueryDsl; use mvr_api::{run_server, Network}; use mvr_schema::{ - models::{NameRecord, Package, PackageInfo}, - schema::{name_records, package_infos, packages}, + models::{NameRecord, Package, PackageDependency, PackageInfo}, + schema::{name_records, package_dependencies, package_infos, packages}, MIGRATIONS, }; use serde_json::json; @@ -82,8 +82,24 @@ async fn main() -> anyhow::Result<()> { ) .await?; + // The real `subject -> dependency` edge, so the dependencies endpoint + // returns it (powering both the Dependencies tab and vuln propagation). + { + let mut conn = db.connect().await?; + insert_into(package_dependencies::table) + .values(vec![PackageDependency { + package_id: subject.clone(), + dependency_package_id: dependency.clone(), + chain_id: "localnet".to_string(), + immediate_dependency: true, + }]) + .execute(&mut *conn) + .await?; + } + println!("seeded @demo/subject -> {subject}"); println!("seeded @demo/dependency -> {dependency}"); + println!("seeded dependency edge {subject} -> {dependency}"); println!("point the frontend's mainnet mvrEndpoint at http://127.0.0.1:{}", args.port); run_server( diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 0b0ac826..527b5f7d 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -25,10 +25,20 @@ fi CONFIG=$(python3 - "$DEMO_IDS" <<'PY' import json, sys d = json.load(open(sys.argv[1])) +# Friendly display names for the demo attesters (presentation lives in the +# consumer's trust config, not on-chain). +NAMES = { + "audit_example": "Example Auditor", + "vuln_example": "Example Security Scanner", +} attestors = [] for a in d["trustedAttestors"]: lineage = list(dict.fromkeys([a["originalId"], a.get("latestId", a["originalId"])])) - attestors.append({"name": a["name"], "originalId": a["originalId"], "lineage": lineage}) + attestors.append({ + "name": NAMES.get(a["name"], a["name"]), + "originalId": a["originalId"], + "lineage": lineage, + }) print(json.dumps({ "registryPkg": d["attestationRegistryPkg"], "registryId": d["registryId"], From 271bb682431b2f416506b54e7c0d829132f7062a Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 10:34:37 -0400 Subject: [PATCH 07/54] Pass 2: link attesters to their MVR pages - demo_server seeds an MVR name for each trusted attester package (@demo/audit, @demo/vuln) so they browse to their own page. - write-demo-env.sh adds mvrName to the trust config (in sync). - Trust config gains optional mvrName; the Security tab links each attester name (on audit cards and disclosure rows) to its MVR page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 14 +++++++++-- app/src/lib/attestations.ts | 2 ++ crates/mvr-api/examples/demo_server.rs | 23 +++++++++++++++++++ scripts/write-demo-env.sh | 6 +++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index d005c34f..3fe1f864 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -233,7 +233,7 @@ function VulnRow({ entry }: { entry: VulnEntry }) {
- {attestation.attestor.name} + {attesterNameNode(attestation.attestor)} {via && ( <> {" · in dependency "} @@ -289,7 +289,7 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) {
- {group.attestor.name} + {attesterNameNode(group.attestor)} {truncateId(group.attestor.originalId)} @@ -430,6 +430,16 @@ function DepLink({ id, name }: { id: string; name?: string }) { return {truncateId(id)}; } +/** The attester name, linked to its MVR page when one is configured. */ +function attesterNameNode(attestor: TrustedAttestor): React.ReactNode { + if (!attestor.mvrName) return attestor.name; + return ( + + {attestor.name} + + ); +} + interface AttestorGroup { attestor: TrustedAttestor; items: DisplayedAttestation[]; diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 6a53b54e..b52e3c93 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -14,6 +14,8 @@ export interface TrustedAttestor { /** Optional brand icon URL (from trust config, never from on-chain data). * Absent → the UI renders an initials avatar. */ iconUrl?: string; + /** Optional MVR name of the attester package, for linking to its page. */ + mvrName?: string; /** Original publish id of the attester package — the trust anchor. */ originalId: string; /** Every package-version id in the attester's lineage. Matching an diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 1a8ef3cd..4fc6baa3 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -97,6 +97,19 @@ async fn main() -> anyhow::Result<()> { .await?; } + // Give each trusted attester package an MVR name, so the UI can link to + // its page. Names are kept in sync with scripts/write-demo-env.sh. + if let Some(attestors) = ids["trustedAttestors"].as_array() { + for (i, a) in attestors.iter().enumerate() { + let pkg_name = a["name"].as_str().unwrap_or_default(); + let latest = a["latestId"].as_str().unwrap_or_default().to_string(); + let mvr_name = auditor_mvr_name(pkg_name); + let pkg_info_id = format!("0x{:064x}", 0xdee0_0010u64 + i as u64); + seed(&mut db, &mvr_name, &latest, &pkg_info_id, "A trusted attester in the demo.").await?; + println!("seeded {mvr_name} -> {latest}"); + } + } + println!("seeded @demo/subject -> {subject}"); println!("seeded @demo/dependency -> {dependency}"); println!("seeded dependency edge {subject} -> {dependency}"); @@ -113,6 +126,16 @@ async fn main() -> anyhow::Result<()> { .await } +/// MVR name for a trusted attester package (kept in sync with the frontend +/// trust config in scripts/write-demo-env.sh). +fn auditor_mvr_name(pkg_name: &str) -> String { + match pkg_name { + "audit_example" => "@demo/audit".to_string(), + "vuln_example" => "@demo/vuln".to_string(), + other => format!("@demo/{other}"), + } +} + /// Read `subjects.` from demo-ids.json, or panic with a clear message. fn field(ids: &serde_json::Value, key: &str) -> String { ids["subjects"][key] diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 527b5f7d..f8d793e4 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -31,11 +31,17 @@ NAMES = { "audit_example": "Example Auditor", "vuln_example": "Example Security Scanner", } +# MVR names of the attester packages (kept in sync with demo_server.rs). +MVR_NAMES = { + "audit_example": "@demo/audit", + "vuln_example": "@demo/vuln", +} attestors = [] for a in d["trustedAttestors"]: lineage = list(dict.fromkeys([a["originalId"], a.get("latestId", a["originalId"])])) attestors.append({ "name": NAMES.get(a["name"], a["name"]), + "mvrName": MVR_NAMES.get(a["name"]), "originalId": a["originalId"], "lineage": lineage, }) From df7d10ecdc1ac096d46f88cc46a2509f66c143dd Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 10:36:11 -0400 Subject: [PATCH 08/54] doc: mark pass 2 complete Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index 5b79bfa9..7c853f9a 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -229,10 +229,10 @@ the localnet `package_address`. The demo server lives at with separate Vulnerabilities/Audits sections + per-kind count pills + attester avatars; negative test data (untrusted attester + undisplayed type) proving both filters. -- **Pass 2** — negative **propagation**: surface a dependency's effective vulns - on its dependents (walk the dep graph; seed the `subject → dependency` edge). - Plus auditor MVR-page links (seed auditor names; link attester → its MVR - page). +- **Pass 2 ✅** — negative **propagation** (a dependency's effective vulns + surface on its dependents; seeded `subject → dependency` edge); CVSS + `severity` convention with severity-sorted, band-colored vulnerabilities; + friendly attester names + MVR-page links for attesters. - **Pass 3** — `requires`/propagation provenance + an attestation detail view ("why ineffective", which required attestation was revoked). - **Pass 4** — reverse "audits issued by this auditor" tab (needs a reverse From fbfe10cbe9be8d8ba232bc6923e7c01401409d6e Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 10:44:08 -0400 Subject: [PATCH 09/54] Show attester MVR name instead of raw address; defer web-of-trust - Audit cards show the attester's MVR name (from the trust config, no extra reverse-resolution roundtrip) rather than the truncated original id. - Doc: web-of-trust whitelist deferred pending a team discussion on the RPC-roundtrip cost of per-attester on-chain lookups. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 4 +++- .../single-package/SinglePackageTrustSignals.tsx | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index 7c853f9a..68102d39 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -246,7 +246,9 @@ the localnet `package_address`. The demo server lives at hardcoded value becomes MVR's own attester package id (the trust root). Per attestation, check whether its attester package carries an effective `TrustedAuditor` attestation from MVR (a per-attester lookup, dynamic and - revocable). Non-transitive to start. + revocable). Non-transitive to start. **Deferred** pending a team discussion: + the per-attester on-chain lookups add RPC roundtrips on the read path, and we + want to scope that (batching/caching) before replacing the hardcoded list. - **`summary` vs `description` convention.** A short `summary` field for list rows, separate from a fuller `description`, if on-chain description size becomes a concern. Undecided. diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 3fe1f864..4b1b136b 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -291,9 +291,15 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) { {attesterNameNode(group.attestor)} - - {truncateId(group.attestor.originalId)} - + {group.attestor.mvrName ? ( + + {group.attestor.mvrName} + + ) : ( + + {truncateId(group.attestor.originalId)} + + )}
{group.items.map((item) => ( From 27aeeb0b900616f15daf07944eea98c5ad83a0ee Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 10:53:51 -0400 Subject: [PATCH 10/54] Show vuln attester MVR names; give auditors distinct namespaces - Disclosure rows show the attester's MVR name in parens (e.g. Example Security Scanner (@example-scanner/disclosures)). - Auditors get distinct MVR namespaces: @example-auditor/audits and @example-scanner/disclosures (underscores aren't valid MVR/SuiNS names, so hyphenated orgs). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/single-package/SinglePackageTrustSignals.tsx | 1 + crates/mvr-api/examples/demo_server.rs | 4 ++-- scripts/write-demo-env.sh | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 4b1b136b..b2df4870 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -234,6 +234,7 @@ function VulnRow({ entry }: { entry: VulnEntry }) { {attesterNameNode(attestation.attestor)} + {attestation.attestor.mvrName ? ` (${attestation.attestor.mvrName})` : ""} {via && ( <> {" · in dependency "} diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 4fc6baa3..1ff8d992 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -130,8 +130,8 @@ async fn main() -> anyhow::Result<()> { /// trust config in scripts/write-demo-env.sh). fn auditor_mvr_name(pkg_name: &str) -> String { match pkg_name { - "audit_example" => "@demo/audit".to_string(), - "vuln_example" => "@demo/vuln".to_string(), + "audit_example" => "@example-auditor/audits".to_string(), + "vuln_example" => "@example-scanner/disclosures".to_string(), other => format!("@demo/{other}"), } } diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index f8d793e4..717b67d1 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -33,8 +33,8 @@ NAMES = { } # MVR names of the attester packages (kept in sync with demo_server.rs). MVR_NAMES = { - "audit_example": "@demo/audit", - "vuln_example": "@demo/vuln", + "audit_example": "@example-auditor/audits", + "vuln_example": "@example-scanner/disclosures", } attestors = [] for a in d["trustedAttestors"]: From 4b3d2a40db3dd1952d3f51389af13c300334d0ea Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 11:02:40 -0400 Subject: [PATCH 11/54] demo_server: seed git_info for auditor packages (sample READMEs) Seed a git_infos row for each trusted attester pointing at the sui-attestation-registry repo's mvr-demo tag, so the auditor MVR pages render their sample README via MVR's git-backed README fetch. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 43 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 1ff8d992..6a2c5931 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -25,10 +25,14 @@ use diesel::insert_into; use diesel_async::RunQueryDsl; use mvr_api::{run_server, Network}; use mvr_schema::{ - models::{NameRecord, Package, PackageDependency, PackageInfo}, - schema::{name_records, package_dependencies, package_infos, packages}, + models::{GitInfo, NameRecord, Package, PackageDependency, PackageInfo}, + schema::{git_infos, name_records, package_dependencies, package_infos, packages}, MIGRATIONS, }; + +// Where the auditor package READMEs live, for MVR's git-backed README fetch. +const DEMO_REPO_URL: &str = "https://github.com/mdgeorge4153/sui-attestation-registry"; +const DEMO_GIT_TAG: &str = "mvr-demo"; use serde_json::json; use sui_pg_db::{temp::TempDb, Db, DbArgs}; use tokio_util::sync::CancellationToken; @@ -71,6 +75,7 @@ async fn main() -> anyhow::Result<()> { &subject, PKG_INFO_SUBJECT, "The example subject package browsed in the MVR attestation demo.", + None, ) .await?; seed( @@ -79,6 +84,7 @@ async fn main() -> anyhow::Result<()> { &dependency, PKG_INFO_DEPENDENCY, "A dependency of @demo/subject.", + None, ) .await?; @@ -105,7 +111,16 @@ async fn main() -> anyhow::Result<()> { let latest = a["latestId"].as_str().unwrap_or_default().to_string(); let mvr_name = auditor_mvr_name(pkg_name); let pkg_info_id = format!("0x{:064x}", 0xdee0_0010u64 + i as u64); - seed(&mut db, &mvr_name, &latest, &pkg_info_id, "A trusted attester in the demo.").await?; + let git_path = format!("packages/{pkg_name}"); + seed( + &mut db, + &mvr_name, + &latest, + &pkg_info_id, + "A trusted attester in the demo.", + Some(&git_path), + ) + .await?; println!("seeded {mvr_name} -> {latest}"); } } @@ -152,6 +167,7 @@ async fn seed( package_id: &str, pkg_info_id: &str, description: &str, + git_path: Option<&str>, ) -> anyhow::Result<()> { let package = Package { package_id: package_id.to_string(), @@ -168,7 +184,11 @@ async fn seed( id: pkg_info_id.to_string(), object_version: 0, package_id: package_id.to_string(), - git_table_id: String::new(), + git_table_id: if git_path.is_some() { + pkg_info_id.to_string() + } else { + String::new() + }, chain_id: "localnet".to_string(), default_name: Some(name.to_string()), metadata: serde_json::Value::Null, @@ -185,5 +205,20 @@ async fn seed( insert_into(packages::table).values(vec![package]).execute(&mut *conn).await?; insert_into(package_infos::table).values(vec![package_info]).execute(&mut *conn).await?; insert_into(name_records::table).values(vec![name_record]).execute(&mut *conn).await?; + if let Some(path) = git_path { + // git_table_id == pkg_info_id (set above); join key for the README. + insert_into(git_infos::table) + .values(vec![GitInfo { + table_id: pkg_info_id.to_string(), + object_version: 0, + version: 1, + chain_id: "localnet".to_string(), + repository: Some(DEMO_REPO_URL.to_string()), + path: Some(path.to_string()), + tag: Some(DEMO_GIT_TAG.to_string()), + }]) + .execute(&mut *conn) + .await?; + } Ok(()) } From 0bf9590451bdac5137dab67fb32a5bfa027ab3d1 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 11:06:35 -0400 Subject: [PATCH 12/54] Make all displayed MVR names clickable links - Audit cards: friendly name as a plain label, MVR name as the link. - Disclosure rows: drop the display name; show the attester avatar + the MVR name as a link. - (Dependency provenance names were already linked.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index b2df4870..93c05d2b 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -233,8 +233,9 @@ function VulnRow({ entry }: { entry: VulnEntry }) {
- {attesterNameNode(attestation.attestor)} - {attestation.attestor.mvrName ? ` (${attestation.attestor.mvrName})` : ""} + {attestation.attestor.mvrName + ? mvrLink(attestation.attestor.mvrName) + : attestation.attestor.name} {via && ( <> {" · in dependency "} @@ -290,11 +291,11 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) {
- {attesterNameNode(group.attestor)} + {group.attestor.name} {group.attestor.mvrName ? ( - - {group.attestor.mvrName} + + {mvrLink(group.attestor.mvrName)} ) : ( @@ -437,12 +438,11 @@ function DepLink({ id, name }: { id: string; name?: string }) { return {truncateId(id)}; } -/** The attester name, linked to its MVR page when one is configured. */ -function attesterNameNode(attestor: TrustedAttestor): React.ReactNode { - if (!attestor.mvrName) return attestor.name; +/** Render an MVR name as a link to its package page. */ +function mvrLink(name: string): React.ReactNode { return ( - - {attestor.name} + + {name} ); } From bb623248c25825f46cdb5696014d4e4c231725a3 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 11:12:51 -0400 Subject: [PATCH 13/54] Show MVR name in attestation types; consolidate the vuln row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the package address in displayed types with the attester's (linked) MVR name; vuln rows consolidate to avatar + linked @org/name::module::Type · in dependency . - Audit rows show module::Type (no raw address; attester is in the card). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 93c05d2b..4c1bb1a5 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -230,12 +230,17 @@ function VulnRow({ entry }: { entry: VulnEntry }) { {description} )} -
+
- {attestation.attestor.mvrName - ? mvrLink(attestation.attestor.mvrName) - : attestation.attestor.name} + {attestation.attestor.mvrName ? ( + <> + {mvrLink(attestation.attestor.mvrName)} + {`::${moduleAndType(innerType)}`} + + ) : ( + {innerType} + )} {via && ( <> {" · in dependency "} @@ -244,14 +249,6 @@ function VulnRow({ entry }: { entry: VulnEntry }) { )}
- - {innerType} - {link && ( @@ -382,7 +379,7 @@ function AttestationRow({ item }: { item: DisplayedAttestation }) { size="paragraph-xs" className="break-all font-mono opacity-50" > - {innerType} + {moduleAndType(innerType)} {link && ( @@ -493,6 +490,12 @@ function truncateId(id: string): string { return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id; } +/** The `module::Type` part of an inner type, dropping the package address. */ +function moduleAndType(innerType: string): string { + const parts = innerType.split("::"); + return parts.length > 1 ? parts.slice(1).join("::") : innerType; +} + function str(v: unknown): string | undefined { return typeof v === "string" && v.length > 0 ? v : undefined; } From 4a0d3c4b467033f80548a49d045fcfd1bf9bf98b Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 11:23:04 -0400 Subject: [PATCH 14/54] Trust tab: drop Active label, severity-colored vuln border, object explorer links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cards only show effective attestations, so the 'Active' badge is removed. - Vulnerability rows' left border is colored by severity band. - Attestation types show the object id truncated + linked to an explorer, e.g. audit::Audit (0x123…567). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 62 +++++++------------ app/src/lib/attestations.ts | 16 +++-- 2 files changed, 35 insertions(+), 43 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 4c1bb1a5..464a3adb 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -8,6 +8,7 @@ import { } from "@/hooks/useGetAttestations"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; +import ExplorerLink from "../ui/explorer-link"; import { isNegative, readSeverity, @@ -207,23 +208,24 @@ function severityBreakdown(scores: number[]): string { } function VulnRow({ entry }: { entry: VulnEntry }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; const { attestation, via } = entry; - const { display, innerType } = attestation.info; + const { display, innerType, id } = attestation.info; const title = str(display["name"]) ?? "Vulnerability"; const description = str(display["description"]); const link = httpsLink(display["link"]); const severity = readSeverity(attestation.info); + const band = severityBand(severity ?? 0); return ( -
+
{title} -
- {severity !== null && } - -
+ {severity !== null && }
{description && ( @@ -236,7 +238,9 @@ function VulnRow({ entry }: { entry: VulnEntry }) { {attestation.attestor.mvrName ? ( <> {mvrLink(attestation.attestor.mvrName)} - {`::${moduleAndType(innerType)}`} + {`::${moduleAndType(innerType)} (`} + {objectLink(id, network)} + {")"} ) : ( {innerType} @@ -355,31 +359,25 @@ function AttesterAvatar({ /** A positive attestation row (audits). */ function AttestationRow({ item }: { item: DisplayedAttestation }) { - const { display, innerType } = item.info; + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { display, innerType, id } = item.info; const title = str(display["name"]) ?? "Attestation"; const description = str(display["description"]); const link = httpsLink(display["link"]); return (
-
- - {title} - - -
+ + {title} + {description && ( {description} )} - - {moduleAndType(innerType)} + + {moduleAndType(innerType)} ( + {objectLink(id, network)}) {link && (
@@ -401,24 +399,12 @@ function SeverityChip({ score }: { score: number }) { ); } -function StatusBadge({ - item, - negative, -}: { - item: DisplayedAttestation; - negative: boolean; -}) { - const revoked = item.info.display["active"] === "false"; - const label = revoked ? "Revoked" : item.effective ? "Active" : "Ineffective"; - const tone = !item.effective - ? "text-content-tertiary" - : negative - ? "text-content-warning" - : "text-content-positive"; +/** The attestation object id, truncated and linked to an explorer. */ +function objectLink(id: string, network: "mainnet" | "testnet"): React.ReactNode { return ( - - {label} - + + {truncateId(id)} + ); } diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index b52e3c93..9cf128d6 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -106,15 +106,21 @@ export interface SeverityBand { label: string; /** A `text-…` color class for the band. */ tone: string; + /** A `border-…` color class for the band. */ + border: string; } /** Map a CVSS base score to its qualitative band (CVSS v3.1). */ export function severityBand(score: number): SeverityBand { - if (score >= 9) return { label: "Critical", tone: "text-content-negative" }; - if (score >= 7) return { label: "High", tone: "text-content-negative" }; - if (score >= 4) return { label: "Medium", tone: "text-content-warning" }; - if (score > 0) return { label: "Low", tone: "text-content-tertiary" }; - return { label: "None", tone: "text-content-tertiary" }; + if (score >= 9) + return { label: "Critical", tone: "text-content-negative", border: "border-content-negative" }; + if (score >= 7) + return { label: "High", tone: "text-content-negative", border: "border-content-negative" }; + if (score >= 4) + return { label: "Medium", tone: "text-content-warning", border: "border-content-warning" }; + if (score > 0) + return { label: "Low", tone: "text-content-tertiary", border: "border-content-tertiary" }; + return { label: "None", tone: "text-content-tertiary", border: "border-content-tertiary" }; } /** The trusted attester whose lineage defines `innerType`, if any. */ From 90109e27cf6c5d6397e703f29be3f736913e1850 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 11:33:28 -0400 Subject: [PATCH 15/54] Trust tab: fix severity border colors; per-band breakdown coloring - Severity left-border uses an arbitrary-value class bound to the CSS var (the content palette is text-only, so border-content-* didn't exist). - The 'N high, M medium' header summary colors each band segment by its own severity (not all by the max). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 27 ++++++++++--------- app/src/lib/attestations.ts | 13 ++++----- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 464a3adb..4bdd76c2 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -177,11 +177,7 @@ function VulnerabilitiesSection({ Vulnerabilities - + · {scores.length ? severityBreakdown(scores) : "none active"}
@@ -193,18 +189,23 @@ function VulnerabilitiesSection({ ); } -/** Summarize scores by band, e.g. "1 high, 1 medium" (most severe first). */ -function severityBreakdown(scores: number[]): string { +/** Summarize scores by band, e.g. "1 high, 1 medium" (most severe first), + * each segment colored by its own band. */ +function severityBreakdown(scores: number[]): React.ReactNode { const order = ["Critical", "High", "Medium", "Low", "None"]; - const counts: Record = {}; + const byBand: Record = {}; for (const s of scores) { - const band = severityBand(s).label; - counts[band] = (counts[band] ?? 0) + 1; + const b = severityBand(s); + byBand[b.label] = { count: (byBand[b.label]?.count ?? 0) + 1, tone: b.tone }; } return order - .filter((b) => counts[b]) - .map((b) => `${counts[b]} ${b.toLowerCase()}`) - .join(", "); + .filter((label) => byBand[label]) + .map((label, i) => ( + + {i > 0 ? ", " : ""} + {byBand[label]!.count} {label.toLowerCase()} + + )); } function VulnRow({ entry }: { entry: VulnEntry }) { diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 9cf128d6..67695f78 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -106,21 +106,22 @@ export interface SeverityBand { label: string; /** A `text-…` color class for the band. */ tone: string; - /** A `border-…` color class for the band. */ + /** A `border-…` color class for the band (the `content` palette is + * text-only, so this uses an arbitrary value bound to the CSS var). */ border: string; } /** Map a CVSS base score to its qualitative band (CVSS v3.1). */ export function severityBand(score: number): SeverityBand { if (score >= 9) - return { label: "Critical", tone: "text-content-negative", border: "border-content-negative" }; + return { label: "Critical", tone: "text-content-negative", border: "border-[color:var(--content-negative)]" }; if (score >= 7) - return { label: "High", tone: "text-content-negative", border: "border-content-negative" }; + return { label: "High", tone: "text-content-negative", border: "border-[color:var(--content-negative)]" }; if (score >= 4) - return { label: "Medium", tone: "text-content-warning", border: "border-content-warning" }; + return { label: "Medium", tone: "text-content-warning", border: "border-[color:var(--content-warning)]" }; if (score > 0) - return { label: "Low", tone: "text-content-tertiary", border: "border-content-tertiary" }; - return { label: "None", tone: "text-content-tertiary", border: "border-content-tertiary" }; + return { label: "Low", tone: "text-content-tertiary", border: "border-[color:var(--content-tertiary)]" }; + return { label: "None", tone: "text-content-tertiary", border: "border-[color:var(--content-tertiary)]" }; } /** The trusted attester whose lineage defines `innerType`, if any. */ From ab527d8285f61fda8237e2d473eb606e871ddcd8 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 11:46:56 -0400 Subject: [PATCH 16/54] Trust tab: inline-style severity colors + borders; report link in header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Color severity chips, the per-band header breakdown, and the warning icons via inline style bound to the band's CSS var. Tailwind only scans .tsx, so classes built in attestations.ts (.ts) were never generated — which silently dropped Medium (text-content-warning). Inline styles are generation-independent. - Severity left-border likewise via inline style. - Move the report/advisory link into the row header next to the headline (the description), e.g. 'Heap overflow in the dependency (scanner.example.com ↗)'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 86 +++++++++---------- app/src/lib/attestations.ts | 23 ++--- 2 files changed, 52 insertions(+), 57 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 4bdd76c2..51a64f76 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -17,7 +17,13 @@ import { } from "@/lib/attestations"; /** A triangle-exclamation glyph; color comes from the text color (currentColor). */ -function WarningIcon({ className }: { className?: string }) { +function WarningIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { return ( @@ -72,7 +79,7 @@ export function TrustSignalCount({ .filter((a) => isNegative(a.info)) .map((a) => readSeverity(a.info) ?? 0) .concat((inherited ?? []).map((v) => readSeverity(v.attestation.info) ?? 0)); - const warnTone = severityBand(vulnScores.length ? Math.max(...vulnScores) : 0).tone; + const warnColor = severityBand(vulnScores.length ? Math.max(...vulnScores) : 0).color; if (!positives && !vulnScores.length) return null; return ( @@ -85,7 +92,7 @@ export function TrustSignalCount({ )} {vulnScores.length > 0 && ( } + icon={} count={vulnScores.length} /> )} @@ -168,12 +175,12 @@ function VulnerabilitiesSection({ // Header is colored and summarized by the active vulnerabilities' severities. const scores = entries.map((e) => readSeverity(e.attestation.info) ?? 0); - const maxTone = severityBand(scores.length ? Math.max(...scores) : 0).tone; + const maxColor = severityBand(scores.length ? Math.max(...scores) : 0).color; return (
- + Vulnerabilities @@ -193,15 +200,15 @@ function VulnerabilitiesSection({ * each segment colored by its own band. */ function severityBreakdown(scores: number[]): React.ReactNode { const order = ["Critical", "High", "Medium", "Low", "None"]; - const byBand: Record = {}; + const byBand: Record = {}; for (const s of scores) { const b = severityBand(s); - byBand[b.label] = { count: (byBand[b.label]?.count ?? 0) + 1, tone: b.tone }; + byBand[b.label] = { count: (byBand[b.label]?.count ?? 0) + 1, color: b.color }; } return order .filter((label) => byBand[label]) .map((label, i) => ( - + {i > 0 ? ", " : ""} {byBand[label]!.count} {label.toLowerCase()} @@ -212,27 +219,23 @@ function VulnRow({ entry }: { entry: VulnEntry }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { attestation, via } = entry; const { display, innerType, id } = attestation.info; - const title = str(display["name"]) ?? "Vulnerability"; - const description = str(display["description"]); + const headline = str(display["description"]) ?? str(display["name"]) ?? "Vulnerability"; const link = httpsLink(display["link"]); const severity = readSeverity(attestation.info); const band = severityBand(severity ?? 0); return (
- ); } @@ -362,31 +358,19 @@ function AttesterAvatar({ function AttestationRow({ item }: { item: DisplayedAttestation }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { display, innerType, id } = item.info; - const title = str(display["name"]) ?? "Attestation"; - const description = str(display["description"]); + const headline = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; const link = httpsLink(display["link"]); return (
- {title} + {headline} + {link && <> ({reportLink(link)})} - {description && ( - - {description} - - )} {moduleAndType(innerType)} ( {objectLink(id, network)}) - {link && ( - - - {hostOf(link)} ↗ - - - )}
); } @@ -394,9 +378,11 @@ function AttestationRow({ item }: { item: DisplayedAttestation }) { function SeverityChip({ score }: { score: number }) { const band = severityBand(score); return ( - - {band.label} ({score.toFixed(1)}) - + + + {band.label} ({score.toFixed(1)}) + + ); } @@ -409,6 +395,20 @@ function objectLink(id: string, network: "mainnet" | "testnet"): React.ReactNode ); } +/** The report/advisory URL as a link showing its host. */ +function reportLink(url: string): React.ReactNode { + return ( + + {hostOf(url)} ↗ + + ); +} + /** Link to a dependency's MVR page by name, or show its id when unresolved * (the package route resolves by name, so a raw id isn't linkable). */ function DepLink({ id, name }: { id: string; name?: string }) { diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 67695f78..0d277a6a 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -104,24 +104,19 @@ export function readSeverity(att: AttestationInfo): number | null { export interface SeverityBand { label: string; - /** A `text-…` color class for the band. */ - tone: string; - /** A `border-…` color class for the band (the `content` palette is - * text-only, so this uses an arbitrary value bound to the CSS var). */ - border: string; + /** A CSS color (var) for the band, for inline styling. The `content` + * palette is text-only (no `border-content-*`), and Tailwind only scans + * `.tsx`, so a class built here wouldn't be generated — hence a raw var. */ + color: string; } /** Map a CVSS base score to its qualitative band (CVSS v3.1). */ export function severityBand(score: number): SeverityBand { - if (score >= 9) - return { label: "Critical", tone: "text-content-negative", border: "border-[color:var(--content-negative)]" }; - if (score >= 7) - return { label: "High", tone: "text-content-negative", border: "border-[color:var(--content-negative)]" }; - if (score >= 4) - return { label: "Medium", tone: "text-content-warning", border: "border-[color:var(--content-warning)]" }; - if (score > 0) - return { label: "Low", tone: "text-content-tertiary", border: "border-[color:var(--content-tertiary)]" }; - return { label: "None", tone: "text-content-tertiary", border: "border-[color:var(--content-tertiary)]" }; + if (score >= 9) return { label: "Critical", color: "var(--content-negative)" }; + if (score >= 7) return { label: "High", color: "var(--content-negative)" }; + if (score >= 4) return { label: "Medium", color: "var(--content-warning)" }; + if (score > 0) return { label: "Low", color: "var(--content-tertiary)" }; + return { label: "None", color: "var(--content-tertiary)" }; } /** The trusted attester whose lineage defines `innerType`, if any. */ From ec435fd48bf60013827dfec24fba2d3c26e31bf1 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 29 May 2026 12:09:28 -0400 Subject: [PATCH 17/54] M3: spam-proof server-side MatchAny over trusted attester types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the client-side lineage filter with a server-side MatchAny: the trusted type set is the Attestation for every store type T defined by a trusted attester's lineage (resolveTrustedTypes via getNormalizedMoveModulesByPackage, cached). Untrusted attestations are never returned by the node; the read-time Display-gate still drops trusted-but-undisplayed types. JSON-RPC only — no GraphQL/indexer. Verified against localnet (Untrusted excluded server-side). Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 20 ++++++--- app/src/hooks/useGetAttestations.ts | 64 +++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index 68102d39..dfcd832d 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -172,9 +172,16 @@ hygiene-only if an attester declares no domains). Display fields and effectiveness. Verified: the tab shows the AuditV2 (matched via the upgraded lineage) as ineffective post-revoke, and the Vulnerability as effective. -- **M3** — Swap in the Option 2 **server-side `MatchAny`** over the - Display-registered trusted-type set (spam-resistance); finalize the - enumeration mechanism from the M2 spike. +- **M3 ✅** — Spam-proof **server-side `MatchAny`**. Spike outcome: GraphQL's + `objects` type filter only matches package/module/full-name/full-instantiation + (so `Display>` can't be matched as a prefix, and it needs + GraphQL infra anyway). Took a simpler **JSON-RPC-only** path instead: enumerate + each trusted attester lineage's `store` types via + `getNormalizedMoveModulesByPackage`, build the exact `Attestation` set, and + `getOwnedObjects(box, { MatchAny })`. Untrusted attestations are never + returned; trusted-but-undisplayed types (e.g. `InternalNote`) are returned but + dropped by the read-time Display-gate. No GraphQL, no localnet restart. + Verified against localnet (Untrusted excluded server-side). - **M4** — `image_url`/`link` conventions + host-allowlist policing; sidebar trust badge. @@ -213,9 +220,10 @@ the localnet `package_address`. The demo server lives at - [x] Attestation config (`lib/attestations.ts`, env `NEXT_PUBLIC_ATTESTATION_CONFIG` generated from `demo-ids.json` by `scripts/write-demo-env.sh`). - [x] Port `boxAddress`; add JSON-RPC `AttestationInfo` mapper. -- [ ] Spike: GraphQL generic type-filter for `Display>`; choose - enumeration mechanism. (M3) -- [ ] Trusted-type resolver (lineage + Display enumeration), cached per attester. (M3) +- [x] Spike: GraphQL type-filter — concluded JSON-RPC `MatchAny` over types + enumerated from `getNormalizedMoveModulesByPackage` is simpler (no GraphQL). +- [x] Trusted-type resolver (`resolveTrustedTypes`, cached per config); read via + server-side `MatchAny`. - [x] `useGetAttestations` hook + port `conventions.ts`. - [x] Attestations tab + count label; group-by-attester; row with exact `T`, effectiveness, de-emphasized ineffective. diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index c770f9a1..ec85003d 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -50,10 +50,14 @@ function fetchByIdFor(client: SuiClient): ConventionsContext["fetchById"] { /** * Core read: the attestations about `subject` from the configured trusted * attesters, with effectiveness. Reads the per-subject Box directly over - * JSON-RPC (no MVR backend), keeps only `Attestation` whose inner-type - * package is in a trusted lineage and that carry a registered Display. - * M2 filters the lineage client-side; M3 will scope it server-side via - * `MatchAny` over the exact Display-registered trusted types. + * JSON-RPC (no MVR backend). + * + * Spam-resistant (M3): rather than fetch every `Attestation<*>` on the box and + * filter client-side, it asks the node for only the exact trusted types via a + * `MatchAny` `StructType` filter — so attestations from untrusted attesters are + * never returned. The trusted type set is the `Attestation` for every store + * type `T` defined by a trusted attester's lineage (see `resolveTrustedTypes`). + * A read-time Display-gate still drops trusted-but-undisplayed types. */ export async function fetchTrustedAttestations( client: SuiClient, @@ -61,15 +65,16 @@ export async function fetchTrustedAttestations( subject: string, ): Promise { const owner = boxAddress(cfg.registryId, subject); - const structType = `${cfg.registryPkg}::attestation_registry::Attestation`; + const trustedTypes = await resolveTrustedTypes(client, cfg); + if (trustedTypes.length === 0) return []; - // 1. List every Attestation<*> on the box (empty type params match all). + // 1. Fetch only attestations of trusted types (server-side MatchAny). const infos: AttestationInfo[] = []; let cursor: string | null | undefined = null; do { const page = await client.getOwnedObjects({ owner, - filter: { StructType: structType }, + filter: { MatchAny: trustedTypes.map((StructType) => ({ StructType })) }, options: { showType: true, showDisplay: true }, cursor, }); @@ -80,7 +85,8 @@ export async function fetchTrustedAttestations( cursor = page.hasNextPage ? page.nextCursor : null; } while (cursor); - // 2. Keep trusted attesters (lineage) that registered a Display. + // 2. Display-gate (drop trusted-but-undisplayed types) and attribute each to + // its attester for grouping. const trusted = infos .map((info) => ({ info, attestor: attestorFor(cfg, info.innerType) })) .filter( @@ -99,6 +105,48 @@ export async function fetchTrustedAttestations( ); } +// Cache the trusted type set per config — the lineage is static, so this only +// changes when an attester upgrades (re-load the app to refresh). +const trustedTypesCache = new Map>(); + +/** + * The exact set of trusted `Attestation` type strings: for every package in + * a trusted attester's lineage, every `store` struct it defines becomes a + * candidate `T`. Querying each lineage version covers types by their defining + * (canonical) id; non-canonical combinations simply match no objects. + */ +export function resolveTrustedTypes( + client: SuiClient, + cfg: AttestationConfig, +): Promise { + const lineage = [ + ...new Set(cfg.trustedAttestors.flatMap((a) => a.lineage.map((id) => normalizeSuiAddress(id)))), + ]; + const key = `${cfg.registryPkg}|${lineage.join(",")}`; + const cached = trustedTypesCache.get(key); + if (cached) return cached; + + const promise = (async () => { + const types = new Set(); + for (const pkg of lineage) { + const modules = await client.getNormalizedMoveModulesByPackage({ package: pkg }); + for (const [moduleName, mod] of Object.entries(modules)) { + for (const [structName, struct] of Object.entries(mod.structs ?? {})) { + if (struct.abilities.abilities.includes("Store")) { + types.add( + `${cfg.registryPkg}::attestation_registry::Attestation<${pkg}::${moduleName}::${structName}>`, + ); + } + } + } + } + return [...types]; + })(); + + trustedTypesCache.set(key, promise); + return promise; +} + /** The attestations about `subject` from the configured trusted attesters. */ export function useGetAttestations( subject: string | undefined, From 7d61572451bcd3a914db3c9583ad9cb41e9b4ce9 Mon Sep 17 00:00:00 2001 From: Michael George Date: Mon, 1 Jun 2026 13:05:43 -0400 Subject: [PATCH 18/54] Attestations: Issued tab on attester pages + whitelist-gated tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 4: an 'Issued' tab on trusted-attester package pages, listing the attestations a package has issued (GraphQL objects() reverse query over the attester's Attestation types, grouped by subject, Display-gated, with revoked/expired entries shown inactive). The tab is gated on trusted-attestor whitelist membership — a free in-memory check (isConfiguredAttestor) — so the reverse query never runs on non-attester package pages. Repoints the GraphQL endpoint to the local stack alongside RPC/MVR, and the env generator emits NEXT_PUBLIC_LOCAL_GRAPHQL. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 24 ++- .../components/providers/client-provider.tsx | 5 +- .../single-package/SinglePackage.tsx | 40 ++++- .../single-package/SinglePackageIssued.tsx | 153 ++++++++++++++++++ app/src/hooks/useGetAttestations.ts | 121 +++++++++++--- app/src/lib/attestations.ts | 9 ++ scripts/write-demo-env.sh | 2 + 7 files changed, 326 insertions(+), 28 deletions(-) create mode 100644 app/src/components/single-package/SinglePackageIssued.tsx diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index dfcd832d..ab3272fc 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -243,8 +243,11 @@ the localnet `package_address`. The demo server lives at friendly attester names + MVR-page links for attesters. - **Pass 3** — `requires`/propagation provenance + an attestation detail view ("why ineffective", which required attestation was revoked). -- **Pass 4** — reverse "audits issued by this auditor" tab (needs a reverse - query over events/indexer). +- **Pass 4 ✅** — reverse "Issued" tab on attester pages: GraphQL + `objects(filter:{type})` over the attester's `Attestation` types → + issued attestations grouped by subject (linked to each subject's page), + Display-gated, with revoked/expired entries shown inactive. Tab gated on + whitelist membership (free in-memory check; no per-package probing). ## Out of scope / follow-ups @@ -262,6 +265,23 @@ the localnet `package_address`. The demo server lives at becomes a concern. Undecided. - `image_url`/`link` host-allowlisting (constrain to the attester's declared domains) — currently https-only. +- **Read-path round-trip reduction** (fine at local/demo scale; revisit for + real-network latency). All three are latency, not correctness: + - *Batch the sequential reads.* `fetchTrustedAttestations`, + `enumerateAttestationTypes`, and `useIssuedAttestations` issue their + `getObject`/`getOwnedObjects`/per-type GraphQL calls one at a time in + `for…await` loops, so round-trips ≈ latency. Use `multiGetObjects` for the + re-reads and a single aliased query (or an `Any` type filter) for the + per-type GraphQL. + - *Reuse the trusted-type cache on the Issued path.* `useIssuedAttestations` + calls `enumerateAttestationTypes` directly instead of going through the + `resolveTrustedTypes` module cache, so a cold Issued page re-runs + `getNormalizedMoveModulesByPackage` per lineage version. + - *Drop the redundant Display re-read on the Issued path.* The reverse query + already pulls `contents.json`; we then `getObject` each result again purely + for server-rendered Display. Fetching `display { key value }` in the same + GraphQL query removes the ~1-per-object JSON-RPC re-reads (Issued page would + go from ~7 JSON-RPC + 4 GraphQL to just the per-type GraphQL). - gRPC read path (revisit when MVR moves to `@mysten/sui` 2.x). - Full `mvr-indexer`-on-localnet stack (D1 seeds Postgres directly instead). - Adding a first-class `localnet` network to the MVR UI (the demo repoints diff --git a/app/src/components/providers/client-provider.tsx b/app/src/components/providers/client-provider.tsx index b6c14981..068bb5e9 100644 --- a/app/src/components/providers/client-provider.tsx +++ b/app/src/components/providers/client-provider.tsx @@ -39,6 +39,7 @@ export type Clients = { // See ATTESTATION-INTEGRATION.md. const LOCAL_RPC = process.env.NEXT_PUBLIC_LOCAL_RPC_URL; const LOCAL_MVR = process.env.NEXT_PUBLIC_LOCAL_MVR_ENDPOINT; +const LOCAL_GRAPHQL = process.env.NEXT_PUBLIC_LOCAL_GRAPHQL; const mainnet = new SuiClient({ url: LOCAL_RPC ?? "https://suins-rpc.mainnet.sui.io:443", @@ -64,10 +65,10 @@ export const DefaultClients: Clients = { }, graphql: { mainnet: new SuiGraphQLClient({ - url: "https://graphql.mainnet.sui.io/graphql", + url: LOCAL_GRAPHQL ?? "https://graphql.mainnet.sui.io/graphql", }), testnet: new SuiGraphQLClient({ - url: "https://graphql.testnet.sui.io/graphql", + url: LOCAL_GRAPHQL ?? "https://graphql.testnet.sui.io/graphql", }), }, mvrEndpoints: { diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 4e61f58e..88c12984 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -16,6 +16,10 @@ import { SinglePackageTrustSignals, TrustSignalCount, } from "./SinglePackageTrustSignals"; +import { + SinglePackageIssued, + IssuedCount, +} from "./SinglePackageIssued"; import { DependenciesIconSelected } from "@/icons/single-package/DependenciesIcon"; import { DependendsIconSelected } from "@/icons/single-package/DependendsIcon"; import { DependenciesIconUnselected } from "@/icons/single-package/DependenciesIcon"; @@ -27,6 +31,7 @@ import { ReadMeIconUnselected } from "@/icons/single-package/ReadMeIcon"; import { SinglePackageTab } from "@/utils/types"; import { AnalyticsIconUnselected } from "@/icons/single-package/AnalyticsIcon"; import { AnalyticsIconSelected } from "@/icons/single-package/AnalyticsIcon"; +import { attestationConfig, isConfiguredAttestor } from "@/lib/attestations"; // Simple shield-check glyph for the Trust Signals tab; reused for both states. const TrustSignalsIcon = () => ( @@ -36,6 +41,15 @@ const TrustSignalsIcon = () => ( ); +// Upload/outbox glyph for the Issued tab (attestations this package emits). +const IssuedIcon = () => ( + + + + + +); + export const Tabs: SinglePackageTab[] = [ { key: "readme", @@ -90,6 +104,16 @@ export const Tabs: SinglePackageTab[] = [ ), component: (name: ResolvedName) => , }, + { + key: "issued", + title: "Issued", + selectedIcon: , + unselectedIcon: , + label: (address: string, network: "mainnet" | "testnet") => ( + + ), + component: (name: ResolvedName) => , + }, { key: "analytics", title: "Analytics", @@ -111,11 +135,19 @@ export function SinglePackage({ const router = useRouter(); const searchParams = useSearchParams(); - const [activeTab, setActiveTab] = useState(Tabs[0]!.key); + // The "Issued" tab only applies to attesters — gate it on whitelist + // membership (a free in-memory check) rather than probing every package. + const cfg = attestationConfig(); + const visibleTabs = + cfg && isConfiguredAttestor(cfg, name.package_address) + ? Tabs + : Tabs.filter((t) => t.key !== "issued"); + + const [activeTab, setActiveTab] = useState(visibleTabs[0]!.key); useEffect(() => { const tab = searchParams.get("tab"); - if (tab && Tabs.some((t) => t.key === tab) && tab !== activeTab) { + if (tab && visibleTabs.some((t) => t.key === tab) && tab !== activeTab) { setActiveTab(tab as string); } }, [searchParams]); @@ -134,14 +166,14 @@ export function SinglePackage({
- {Tabs.find((t) => t.key === activeTab)?.component(name)} + {visibleTabs.find((t) => t.key === activeTab)?.component(name)}
diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx new file mode 100644 index 00000000..33b1507a --- /dev/null +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -0,0 +1,153 @@ +import { ResolvedName } from "@/hooks/mvrResolution"; +import { usePackagesNetwork } from "../providers/packages-provider"; +import { + useIssuedAttestations, + type IssuedAttestation, +} from "@/hooks/useGetAttestations"; +import { useReverseResolution } from "@/hooks/useReverseResolution"; +import { Text } from "../ui/Text"; +import LoadingState from "../LoadingState"; +import { isNegative, readSeverity, severityBand } from "@/lib/attestations"; + +/** Tab label: count of attestations this package has issued. */ +export function IssuedCount({ + address, + network, +}: { + address: string; + network: "mainnet" | "testnet"; +}) { + const { data } = useIssuedAttestations(address, network); + const count = (data ?? []).length; + if (!count) return null; + return ( +
+ + {count} + +
+ ); +} + +export function SinglePackageIssued({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { data, isLoading } = useIssuedAttestations(name.package_address, network); + const issued = data ?? []; + + const subjects = [...new Set(issued.map((i) => i.subject))]; + const { items: names } = useReverseResolution(subjects, network); + + // Group issued attestations by the subject they're about. + const groups: { subject: string; items: IssuedAttestation[] }[] = []; + for (const item of issued) { + let g = groups.find((x) => x.subject === item.subject); + if (!g) { + g = { subject: item.subject, items: [] }; + groups.push(g); + } + g.items.push(item); + } + + return ( +
+
+ +

Issued attestations

+
+ + On-chain claims this package has signed about other packages — audits, + vulnerability disclosures, and the like. Each one also appears on the + subject package's Security tab. Inactive entries have been revoked + or have expired. + +
+ + {isLoading && ( + + )} + + {!isLoading && issued.length === 0 && ( + + This package hasn't issued any attestations. + + )} + + {groups.map((g) => ( +
+ + about{" "} + + + {g.items.map((item) => ( + + ))} +
+ ))} +
+ ); +} + +function IssuedRow({ item }: { item: IssuedAttestation }) { + const { display, innerType } = item.info; + const negative = isNegative(item.info); + const title = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; + const severity = negative ? readSeverity(item.info) : null; + const band = severity !== null ? severityBand(severity) : null; + + return ( +
+
+ + {title} + {!item.effective && ( + (inactive) + )} + + {band && ( + + + {band.label} ({severity!.toFixed(1)}) + + + )} +
+ + {innerType.split("::").slice(1).join("::")} + +
+ ); +} + +/** Link a subject (attested package) to its MVR page by name, or show its id. */ +function SubjectLink({ id, name }: { id: string; name?: string }) { + if (name) { + return ( + + {name} + + ); + } + return ( + + {id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id} + + ); +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index ec85003d..364e792f 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -119,32 +119,40 @@ export function resolveTrustedTypes( client: SuiClient, cfg: AttestationConfig, ): Promise { - const lineage = [ - ...new Set(cfg.trustedAttestors.flatMap((a) => a.lineage.map((id) => normalizeSuiAddress(id)))), - ]; - const key = `${cfg.registryPkg}|${lineage.join(",")}`; + const lineage = cfg.trustedAttestors.flatMap((a) => a.lineage); + const key = `${cfg.registryPkg}|${[...new Set(lineage.map((id) => normalizeSuiAddress(id)))].join(",")}`; const cached = trustedTypesCache.get(key); if (cached) return cached; + const promise = enumerateAttestationTypes(client, lineage, cfg.registryPkg); + trustedTypesCache.set(key, promise); + return promise; +} - const promise = (async () => { - const types = new Set(); - for (const pkg of lineage) { - const modules = await client.getNormalizedMoveModulesByPackage({ package: pkg }); - for (const [moduleName, mod] of Object.entries(modules)) { - for (const [structName, struct] of Object.entries(mod.structs ?? {})) { - if (struct.abilities.abilities.includes("Store")) { - types.add( - `${cfg.registryPkg}::attestation_registry::Attestation<${pkg}::${moduleName}::${structName}>`, - ); - } +/** + * The `Attestation` type strings for every `store` type `T` defined across + * the given package lineage. Querying each version covers types by their + * defining (canonical) id; non-canonical combinations match no objects. + */ +export async function enumerateAttestationTypes( + client: SuiClient, + lineageIds: string[], + registryPkg: string, +): Promise { + const lineage = [...new Set(lineageIds.map((id) => normalizeSuiAddress(id)))]; + const types = new Set(); + for (const pkg of lineage) { + const modules = await client.getNormalizedMoveModulesByPackage({ package: pkg }); + for (const [moduleName, mod] of Object.entries(modules)) { + for (const [structName, struct] of Object.entries(mod.structs ?? {})) { + if (struct.abilities.abilities.includes("Store")) { + types.add( + `${registryPkg}::attestation_registry::Attestation<${pkg}::${moduleName}::${structName}>`, + ); } } } - return [...types]; - })(); - - trustedTypesCache.set(key, promise); - return promise; + } + return [...types]; } /** The attestations about `subject` from the configured trusted attesters. */ @@ -222,3 +230,76 @@ export function useInheritedVulns( }, }); } + +/** An attestation issued *by* a package, and the subject it is about. */ +export interface IssuedAttestation { + info: AttestationInfo; + /** The subject (package) the attestation is about. */ + subject: string; + effective: boolean; +} + +const ISSUED_QUERY = `query($type: String!) { + objects(filter: { type: $type }) { + nodes { address asMoveObject { contents { json } } } + } +}`; + +/** + * The attestations *issued by* `pkg` — the reverse of the per-subject read. + * Uses GraphQL `objects(type:)` to find every `Attestation` of the + * package's types across all Boxes (the object's `subject` field says who it's + * about), then re-reads each over JSON-RPC for Display + effectiveness. Only + * configured trusted attesters issue attestations in the demo, so a package not + * in the trust config returns nothing. + */ +export function useIssuedAttestations( + pkg: string | undefined, + network: "mainnet" | "testnet", +) { + const clients = useSuiClientsContext(); + const client = clients[network]; + const gql = clients.graphql[network]; + const cfg = attestationConfig(); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "issued", network, pkg], + enabled: !!pkg && !!cfg, + queryFn: async (): Promise => { + const attestor = cfg!.trustedAttestors.find((a) => + a.lineage.some((id) => normalizeSuiAddress(id) === normalizeSuiAddress(pkg!)), + ); + if (!attestor) return []; + const types = await enumerateAttestationTypes(client, attestor.lineage, cfg!.registryPkg); + + // Reverse query: every object of each issued type, across all Boxes. + const subjectById = new Map(); + for (const type of types) { + const res = await gql.query<{ + objects: { + nodes: { + address: string; + asMoveObject: { contents: { json: { subject?: string } } | null } | null; + }[]; + }; + }>({ query: ISSUED_QUERY, variables: { type } }); + for (const node of res.data?.objects?.nodes ?? []) { + const subject = node.asMoveObject?.contents?.json?.subject; + if (node.address && subject) subjectById.set(node.address, subject); + } + } + if (subjectById.size === 0) return []; + + // Re-read each for Display + effectiveness; drop undisplayed types. + const fetchById = fetchByIdFor(client); + const ctx: ConventionsContext = { fetchById }; + const out: IssuedAttestation[] = []; + for (const [id, subject] of subjectById) { + const info = await fetchById(id).catch(() => null); + if (!info || Object.keys(info.display).length === 0) continue; + out.push({ info, subject: normalizeSuiAddress(subject), effective: await isEffective(info, ctx) }); + } + return out; + }, + }); +} diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 0d277a6a..393726ee 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -119,6 +119,15 @@ export function severityBand(score: number): SeverityBand { return { label: "None", color: "var(--content-tertiary)" }; } +/** Whether `pkg` is a configured trusted attester (any lineage version). + * A pure in-memory check — used to gate the "Issued" tab without any RPC. */ +export function isConfiguredAttestor(cfg: AttestationConfig, pkg: string): boolean { + const id = normalizeSuiAddress(pkg); + return cfg.trustedAttestors.some((a) => + a.lineage.some((v) => normalizeSuiAddress(v) === id), + ); +} + /** The trusted attester whose lineage defines `innerType`, if any. */ export function attestorFor( cfg: AttestationConfig, diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 717b67d1..ec14214a 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -14,6 +14,7 @@ APP_ENV="$SCRIPT_DIR/../app/.env" DEMO_IDS="${1:-$HOME/Mysten/sui-attestation-registry/demo-ids.json}" RPC="${RPC_URL:-http://127.0.0.1:9000}" MVR="${MVR_ENDPOINT:-http://127.0.0.1:8000}" +GRAPHQL="${GRAPHQL_URL:-http://127.0.0.1:9125/graphql}" if [[ ! -f "$DEMO_IDS" ]]; then echo "demo-ids.json not found at $DEMO_IDS (run the attestation demo first)" >&2 @@ -56,6 +57,7 @@ PY { echo "NEXT_PUBLIC_LOCAL_RPC_URL=\"$RPC\"" echo "NEXT_PUBLIC_LOCAL_MVR_ENDPOINT=\"$MVR\"" + echo "NEXT_PUBLIC_LOCAL_GRAPHQL=\"$GRAPHQL\"" echo "NEXT_PUBLIC_ATTESTATION_CONFIG='$CONFIG'" } > "$APP_ENV" From 386a505e630ec8f2df1489fb3a61ab6dfe4a93d2 Mon Sep 17 00:00:00 2001 From: Tonka User Date: Wed, 3 Jun 2026 16:48:58 +0000 Subject: [PATCH 19/54] docs: drop machine-specific demo-ids path; note WITH_GRAPHQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local run recipe and the demo_server example hardcoded a personal path for demo-ids.json. It's a cross-repo artifact (written by the attestation-registry repo's run-demo.sh) whose location this repo can't know, so use a /demo-ids.json placeholder instead. Also note WITH_GRAPHQL=1 in the recipe — the frontend reads need localnet GraphQL on :9125 (gRPC is the part not needed). Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 11 +++++++---- crates/mvr-api/examples/demo_server.rs | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index ab3272fc..9f6144d0 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -190,12 +190,15 @@ hygiene-only if an attester declares no domains). Three terminals; the first holds the localnet + published packages + attestations. ```bash -# 1) attestation-registry repo: localnet + publish + upgrade + attest, kept up -KEEP_ALIVE=1 bash scripts/run-demo.sh # writes demo-ids.json, holds :9000 +# 1) attestation-registry repo: localnet + publish + upgrade + attest, kept up. +# WITH_GRAPHQL=1 starts localnet GraphQL on :9125 (the frontend reads need it). +KEEP_ALIVE=1 WITH_GRAPHQL=1 bash scripts/run-demo.sh # writes demo-ids.json, holds :9000/:9125 -# 2) mvr repo: real mvr-api over an ephemeral Postgres, seeded from demo-ids.json +# 2) mvr repo: real mvr-api over an ephemeral Postgres, seeded from demo-ids.json. +# Pass the path to the attestation-registry checkout's demo-ids.json (written +# by its run-demo.sh in step 1). cargo run -p mvr-api --example demo_server -- \ - --demo-ids ~/Mysten/sui-attestation-registry/demo-ids.json --port 8000 + --demo-ids /demo-ids.json --port 8000 # 3) mvr repo: the frontend, all networks repointed at the local stack via # app/.env (NEXT_PUBLIC_LOCAL_RPC_URL=http://127.0.0.1:9000, diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 6a2c5931..58bae295 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -13,7 +13,7 @@ //! //! Run (from the mvr repo), with a localnet up and the demo already run: //! cargo run -p mvr-api --example demo_server -- \ -//! --demo-ids ~/Mysten/sui-attestation-registry/demo-ids.json --port 8000 +//! --demo-ids /demo-ids.json --port 8000 //! //! Then point the MVR frontend's mainnet `mvrEndpoint` at http://localhost:8000. From 85866322f09c2b6d30738b355826f2ab2e66f361 Mon Sep 17 00:00:00 2001 From: Tonka User Date: Wed, 3 Jun 2026 16:49:07 +0000 Subject: [PATCH 20/54] MVR frontend: trusted-attestors page, trust badge, attestor icons - New /attestors page listing the configured trusted attestors, each linking to its MVR package page; reachable via a "View all trusted attestors" link on the Security tab. - "MVR-trusted attestor" badge in the package header, shown when the package is a configured attestor (same isConfiguredAttestor gate as the tab). - Rename the attester-only "Issued" tab to "Attestations". - Give the demo attestors real brand icons (public SVGs + iconUrl in the generated config) so AttesterAvatar stops falling back to initials; avatar is now rounded-md to read as a logo. AttesterAvatar exported + gains an lg size so the page and Security tab share one implementation. - write-demo-env.sh now requires the demo-ids.json path explicitly (first arg or ATTESTATION_DEMO_IDS) rather than defaulting to a machine-specific path. All attestation UI stays demo-only: attestationConfig() is null in production, so the page is empty, the badge never shows, and the Security-tab link hides. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/public/demo-attestors/auditor.svg | 6 ++ app/public/demo-attestors/scanner.svg | 7 ++ app/src/app/attestors/page.tsx | 79 +++++++++++++++++++ .../single-package/SinglePackage.tsx | 2 +- .../single-package/SinglePackageHeader.tsx | 48 ++++++++++- .../SinglePackageTrustSignals.tsx | 20 ++++- scripts/write-demo-env.sh | 20 ++++- 7 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 app/public/demo-attestors/auditor.svg create mode 100644 app/public/demo-attestors/scanner.svg create mode 100644 app/src/app/attestors/page.tsx diff --git a/app/public/demo-attestors/auditor.svg b/app/public/demo-attestors/auditor.svg new file mode 100644 index 00000000..79d0bd70 --- /dev/null +++ b/app/public/demo-attestors/auditor.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/public/demo-attestors/scanner.svg b/app/public/demo-attestors/scanner.svg new file mode 100644 index 00000000..d100645b --- /dev/null +++ b/app/public/demo-attestors/scanner.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/app/attestors/page.tsx b/app/src/app/attestors/page.tsx new file mode 100644 index 00000000..b973b605 --- /dev/null +++ b/app/src/app/attestors/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { PlainPageLayout } from "@/components/layouts/PlainPageLayout"; +import { Text } from "@/components/ui/Text"; +import { AttesterAvatar } from "@/components/single-package/SinglePackageTrustSignals"; +import { attestationConfig, type TrustedAttestor } from "@/lib/attestations"; +import { beautifySuiAddress } from "@/lib/utils"; + +export default function TrustedAttestorsPage() { + const cfg = attestationConfig(); + const attestors = cfg?.trustedAttestors ?? []; + + return ( + +
+
+ + Trusted attestors + + + Attestations are surfaced only from this curated set of attesters. + Trust is a consumer-side choice — these are the packages MVR + recognizes as authoritative sources of audits and vulnerability + disclosures. + +
+ + {attestors.length === 0 ? ( + + No trusted attestors are configured. + + ) : ( +
+ {attestors.map((a) => ( + + ))} +
+ )} +
+
+ ); +} + +function AttestorCard({ attestor }: { attestor: TrustedAttestor }) { + return ( +
+ +
+ + {attestor.name} + + {attestor.mvrName ? ( + + + {attestor.mvrName} + + + ) : ( + + {beautifySuiAddress(attestor.originalId)} + + )} +
+
+ ); +} diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 88c12984..6bdbbaec 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -106,7 +106,7 @@ export const Tabs: SinglePackageTab[] = [ }, { key: "issued", - title: "Issued", + title: "Attestations", selectedIcon: , unselectedIcon: , label: (address: string, network: "mainnet" | "testnet") => ( diff --git a/app/src/components/single-package/SinglePackageHeader.tsx b/app/src/components/single-package/SinglePackageHeader.tsx index a98a4ecc..dbd749c3 100644 --- a/app/src/components/single-package/SinglePackageHeader.tsx +++ b/app/src/components/single-package/SinglePackageHeader.tsx @@ -3,6 +3,41 @@ import { Text } from "../ui/Text"; import ImageWithFallback from "../ui/image-with-fallback"; import { beautifySuiAddress } from "@/lib/utils"; import { CopyBtn } from "../ui/CopyBtn"; +import { attestationConfig, isConfiguredAttestor } from "@/lib/attestations"; + +/** Shield-check glyph; color comes from the surrounding text color. */ +function ShieldCheckIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +/** Pill marking a package as a trusted attestor in this consumer's trust + * config. Links to the full trusted-attestors list. */ +function TrustedAttestorBadge() { + return ( + + + + MVR-trusted attestor + + + ); +} export function SinglePackageHeader({ name, @@ -11,6 +46,10 @@ export function SinglePackageHeader({ name: ResolvedName; network: "mainnet" | "testnet"; }) { + const cfg = attestationConfig(); + const isTrustedAttestor = + !!cfg && isConfiguredAttestor(cfg, name.package_address); + return (
@@ -20,9 +59,12 @@ export function SinglePackageHeader({ className="h-14 w-14 rounded-sm" />
- - {name.name} - +
+ + {name.name} + + {isTrustedAttestor && } +
0 && } + + {attestationConfig() && ( + + + View all trusted attestors → + + + )}
); } @@ -324,15 +336,15 @@ function InactiveList({ items }: { items: DisplayedAttestation[] }) { ); } -function AttesterAvatar({ +export function AttesterAvatar({ attestor, size = "md", }: { attestor: TrustedAttestor; - size?: "sm" | "md"; + size?: "sm" | "md" | "lg"; }) { - const dim = size === "sm" ? "h-5 w-5" : "h-9 w-9"; - const base = `flex ${dim} shrink-0 items-center justify-center rounded-full overflow-hidden`; + const dim = size === "sm" ? "h-5 w-5" : size === "lg" ? "h-12 w-12" : "h-9 w-9"; + const base = `flex ${dim} shrink-0 items-center justify-center rounded-md overflow-hidden`; if (attestor.iconUrl) { return ( diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index ec14214a..54a9a8dd 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -4,14 +4,21 @@ # attestation config (registry id/pkg + trusted attesters with their lineage). # # Usage: -# bash scripts/write-demo-env.sh [path/to/demo-ids.json] -# Env overrides: RPC_URL, MVR_ENDPOINT. +# bash scripts/write-demo-env.sh +# The path is required (or set ATTESTATION_DEMO_IDS); demo-ids.json is written +# by the attestation-registry repo's run-demo.sh and lives in that checkout, +# whose location this repo can't know. Env overrides: RPC_URL, MVR_ENDPOINT. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_ENV="$SCRIPT_DIR/../app/.env" -DEMO_IDS="${1:-$HOME/Mysten/sui-attestation-registry/demo-ids.json}" +DEMO_IDS="${1:-${ATTESTATION_DEMO_IDS:-}}" +if [[ -z "$DEMO_IDS" ]]; then + echo "usage: write-demo-env.sh (or set ATTESTATION_DEMO_IDS)" >&2 + echo " demo-ids.json is produced by the attestation-registry repo's run-demo.sh" >&2 + exit 2 +fi RPC="${RPC_URL:-http://127.0.0.1:9000}" MVR="${MVR_ENDPOINT:-http://127.0.0.1:8000}" GRAPHQL="${GRAPHQL_URL:-http://127.0.0.1:9125/graphql}" @@ -37,11 +44,18 @@ MVR_NAMES = { "audit_example": "@example-auditor/audits", "vuln_example": "@example-scanner/disclosures", } +# Brand icons (served from app/public). Presentation lives in the consumer's +# trust config, never on-chain; absent → the UI falls back to an initials avatar. +ICONS = { + "audit_example": "/demo-attestors/auditor.svg", + "vuln_example": "/demo-attestors/scanner.svg", +} attestors = [] for a in d["trustedAttestors"]: lineage = list(dict.fromkeys([a["originalId"], a.get("latestId", a["originalId"])])) attestors.append({ "name": NAMES.get(a["name"], a["name"]), + "iconUrl": ICONS.get(a["name"]), "mvrName": MVR_NAMES.get(a["name"]), "originalId": a["originalId"], "lineage": lineage, From 62c79bf7379e2ae8642ea1a76c164cb7f300c6cb Mon Sep 17 00:00:00 2001 From: Tonka User Date: Wed, 3 Jun 2026 17:54:32 +0000 Subject: [PATCH 21/54] Fix stale ts/lib provenance refs (registry repo moved them to ts/src) The attestation-registry repo reorganized ts/lib into ts/src (SDK) / examples / demo. Update the ported-from references (boxes/conventions/queries) in the attestations read layer and ATTESTATION-INTEGRATION.md accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 10 +++++----- app/src/lib/attestations.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index 9f6144d0..b1915fb7 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -46,7 +46,7 @@ frontend feature pointed at whatever chain the `SuiClient` uses. | # | Decision | Rationale | |---|----------|-----------| | D1 | **Approach A**: seed Postgres + localnet | Faithful to how the app fetches; isolates us from MVR's on-chain name-registration contracts. The `crates/mvr-api/tests/mvr_test_cluster.rs` `setup_dummy_data` pattern inserts directly into `name_records`/`packages`/`package_infos` — no indexer, no live chain needed for resolution. | -| D2 | **JSON-RPC**, not gRPC | MVR is pinned to `@mysten/sui@1.39.0`, which has no `/grpc` export. Adding `SuiGrpcClient` forces a 1.x→2.x SDK upgrade dragging dapp-kit `0.19→1.0`, kiosk, suins — a repo-wide modernization that dwarfs (and destabilizes) this feature. gRPC stays a separate, future MVR initiative; the attestation-registry repo's `ts/lib/queries.ts` (gRPC) ports back trivially when it lands. | +| D2 | **JSON-RPC**, not gRPC | MVR is pinned to `@mysten/sui@1.39.0`, which has no `/grpc` export. Adding `SuiGrpcClient` forces a 1.x→2.x SDK upgrade dragging dapp-kit `0.19→1.0`, kiosk, suins — a repo-wide modernization that dwarfs (and destabilizes) this feature. gRPC stays a separate, future MVR initiative; the attestation-registry repo's `ts/src/queries.ts` (gRPC) ports back trivially when it lands. | | D3 | **Subject = `package_address`** (resolved version), not original ID | Directly available on `ResolvedName`; the demo seeds the on-chain attestation against the same value. | | D4 | **Trusted set = original package IDs** | Mirrors on-chain `attester_of() = type_name::original_id()`. Any type from any version of a trusted package counts. | | D5 | **Option 2**: server-side exact-type `MatchAny`, trusted set = `Attestation` types each trusted attester **registered a Display for** | The JSON-RPC `StructType` filter matches type params all-or-nothing (`sui-json-rpc-types` `SuiObjectDataFilter::matches`) — no inner-package prefix. Spam-resistance therefore requires the exact trusted-type list. "Registered a Display" is the deliberate, finite, evolution-friendly definition, and Display registration carries the same `internal::Permit` bytecode identity as `attest`, so it can't be forged. | @@ -71,7 +71,7 @@ Browser (MVR app, @mysten/sui 1.39 JSON-RPC) ``` `boxAddr = deriveObjectID(registryId, '0x2::object::ID', subjectBytes)` — the -client-agnostic derivation in the attestation-registry repo's `ts/lib/boxes.ts`, +client-agnostic derivation in the attestation-registry repo's `ts/src/boxes.ts`, ports to MVR as-is. ## Implementation sections @@ -108,7 +108,7 @@ New code in MVR (`app/src`): - **`lib/constants.ts`**: `attestationRegistryPkg`, `attestationRegistryId` (per network; demo fills `mainnet` with the localnet registry id), and `trustedAttestors: { originalId, name, iconUrl, domains? }[]`. -- **`boxAddress` helper**: port the attestation-registry repo's `ts/lib/boxes.ts` +- **`boxAddress` helper**: port the attestation-registry repo's `ts/src/boxes.ts` (pure `deriveObjectID`). - **Trusted-type resolver** (cached per attester, GraphQL): 1. For each trusted `originalId`, get its lineage via `packageVersions`. @@ -123,7 +123,7 @@ New code in MVR (`app/src`): paginated → map each `SuiObjectResponse` into the `AttestationInfo` shape (`{ id, version, digest, type, display, content }`; JSON-RPC nests Display under `data.display.data`). -- **Effectiveness**: port the attestation-registry repo's `ts/lib/conventions.ts` +- **Effectiveness**: port the attestation-registry repo's `ts/src/conventions.ts` (`isEffective`, `active`/`expires_at`/`requires`) — operates purely on `display` + `id`, so it drops in unchanged. @@ -144,7 +144,7 @@ New code in MVR (`app/src`): - **(Phase 2) Sidebar trust badge** in `SinglePackageSidebar` — compact "✓ Attested by N trusted attestors"; same hook (react-query dedupes). -### Section 4 — Convention additions (attestation-registry repo: `CONVENTIONS.md` / `ts/lib/conventions.ts`) +### Section 4 — Convention additions (attestation-registry repo: `CONVENTIONS.md` / `ts/src/conventions.ts`) Add two **optional** conventions using the standard Sui Display keys (so attestations render in any Display-aware tool, not just MVR): diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 393726ee..fa60fb7b 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -140,7 +140,7 @@ export function attestorFor( } // === Conventions (effectiveness) — ported from the attestation-registry repo's -// ts/lib/conventions.ts. Operates purely on Display fields + ids. === +// ts/src/conventions.ts. Operates purely on Display fields + ids. === export interface ConventionsContext { fetchById: (id: string) => Promise; From 2cf92ee651a5764d36a2a6a6b1b3b7a7f3f09a5e Mon Sep 17 00:00:00 2001 From: Tonka User Date: Tue, 16 Jun 2026 22:40:55 +0000 Subject: [PATCH 22/54] Frontend: salt-revocation read-model + Issued-tab revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the on-chain box-membership model: - attestations.ts: `boxAddress(registryPkg, registryId, subject)` keys on `BoxKey{subject, revoked:false}`; add `revokedBoxAddress` (revoked:true). `isEffective` shrinks to expiry-only; `readActive`/`readRequires`/ `ConventionsContext` removed. - useGetAttestations: Trust Signals reads the active box, so revoked attestations are absent by construction. The Issued tab reads by type across all boxes, so it recovers revocation from ownership — an issued attestation is revoked iff its owner is the subject's revoked sink. Drops the now-dead `fetchByIdFor`. - SinglePackageIssued: greys out revoked/expired rows, labelling "(revoked)" vs "(expired)". Verified on localnet: the auditor's Issued tab flags the revoked dependency audit; the subject's audits stay live. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 11 +- app/src/hooks/useGetAttestations.ts | 65 +++++----- app/src/lib/attestations.ts | 114 ++++++++---------- 3 files changed, 95 insertions(+), 95 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 33b1507a..94492f97 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -101,21 +101,24 @@ function IssuedRow({ item }: { item: IssuedAttestation }) { const title = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; const severity = negative ? readSeverity(item.info) : null; const band = severity !== null ? severityBand(severity) : null; + const live = !item.revoked && item.effective; return (
{title} - {!item.effective && ( - (inactive) - )} + {item.revoked ? ( + (revoked) + ) : !item.effective ? ( + (expired) + ) : null} {band && ( diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 364e792f..6d888f06 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -8,12 +8,12 @@ import { attestationConfig, attestorFor, boxAddress, + revokedBoxAddress, isEffective, isNegative, toAttestationInfo, type AttestationConfig, type AttestationInfo, - type ConventionsContext, type TrustedAttestor, } from "@/lib/attestations"; @@ -21,7 +21,7 @@ export interface DisplayedAttestation { info: AttestationInfo; /** The trusted attester this attestation's type belongs to. */ attestor: TrustedAttestor; - /** Effectiveness per the conventions (active + unexpired + requires met). */ + /** Effectiveness per the conventions (unexpired; revocation is box membership). */ effective: boolean; } @@ -34,19 +34,6 @@ export interface InheritedVuln { viaName?: string; } -/** Resolve an attestation by id from chain (for `requires` traversal). */ -function fetchByIdFor(client: SuiClient): ConventionsContext["fetchById"] { - return async (id: string) => { - const resp = await client.getObject({ - id, - options: { showType: true, showDisplay: true }, - }); - const info = toAttestationInfo(resp); - if (!info) throw new Error(`required attestation ${id} is not an Attestation`); - return info; - }; -} - /** * Core read: the attestations about `subject` from the configured trusted * attesters, with effectiveness. Reads the per-subject Box directly over @@ -64,7 +51,7 @@ export async function fetchTrustedAttestations( cfg: AttestationConfig, subject: string, ): Promise { - const owner = boxAddress(cfg.registryId, subject); + const owner = boxAddress(cfg.registryPkg, cfg.registryId, subject); const trustedTypes = await resolveTrustedTypes(client, cfg); if (trustedTypes.length === 0) return []; @@ -94,15 +81,12 @@ export async function fetchTrustedAttestations( !!x.attestor && Object.keys(x.info.display).length > 0, ); - // 3. Effectiveness honours the transitive `requires` convention. - const ctx: ConventionsContext = { fetchById: fetchByIdFor(client) }; - return Promise.all( - trusted.map(async ({ info, attestor }) => ({ - info, - attestor, - effective: await isEffective(info, ctx), - })), - ); + // 3. Effectiveness: unexpired (revocation is handled by box membership). + return trusted.map(({ info, attestor }) => ({ + info, + attestor, + effective: isEffective(info), + })); } // Cache the trusted type set per config — the lineage is static, so this only @@ -236,6 +220,9 @@ export interface IssuedAttestation { info: AttestationInfo; /** The subject (package) the attestation is about. */ subject: string; + /** Moved to the subject's revoked sink (vs. its active box). */ + revoked: boolean; + /** Unexpired per the `expires_at` convention (independent of `revoked`). */ effective: boolean; } @@ -290,14 +277,32 @@ export function useIssuedAttestations( } if (subjectById.size === 0) return []; - // Re-read each for Display + effectiveness; drop undisplayed types. - const fetchById = fetchByIdFor(client); - const ctx: ConventionsContext = { fetchById }; + // Re-read each for Display + owner; drop undisplayed types. An issued + // attestation is revoked iff it now lives in its subject's revoked sink + // rather than the active box — the read-by-type Issued view is the one + // place that recovers revocation from ownership, since the object itself + // carries no status field. const out: IssuedAttestation[] = []; for (const [id, subject] of subjectById) { - const info = await fetchById(id).catch(() => null); + const resp = await client + .getObject({ id, options: { showType: true, showDisplay: true, showOwner: true } }) + .catch(() => null); + if (!resp) continue; + const info = toAttestationInfo(resp); if (!info || Object.keys(info.display).length === 0) continue; - out.push({ info, subject: normalizeSuiAddress(subject), effective: await isEffective(info, ctx) }); + const ownerField = resp.data?.owner; + const owner = + ownerField && typeof ownerField === "object" && "AddressOwner" in ownerField + ? ownerField.AddressOwner + : undefined; + const sink = revokedBoxAddress(cfg!.registryPkg, cfg!.registryId, subject); + const revoked = !!owner && normalizeSuiAddress(owner) === normalizeSuiAddress(sink); + out.push({ + info, + subject: normalizeSuiAddress(subject), + revoked, + effective: isEffective(info), + }); } return out; }, diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index fa60fb7b..33a91849 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -3,7 +3,8 @@ // `Attestation` objects it owns, keeping only those from trusted attesters. // See ATTESTATION-INTEGRATION.md. -import { deriveObjectID, fromHex, normalizeSuiAddress } from "@mysten/sui/utils"; +import { bcs } from "@mysten/sui/bcs"; +import { deriveObjectID, normalizeSuiAddress } from "@mysten/sui/utils"; import type { SuiObjectResponse } from "@mysten/sui/client"; // === Config (NEXT_PUBLIC_ATTESTATION_CONFIG, JSON) === @@ -46,10 +47,47 @@ export function attestationConfig(): AttestationConfig | null { // === Box address === -/** Address of the per-subject `Box`, mirroring `derived_object::derive_address`. */ -export function boxAddress(registryId: string, subject: string): string { - const subjectBytes = fromHex(normalizeSuiAddress(subject).slice(2)); - return deriveObjectID(registryId, "0x2::object::ID", subjectBytes); +const BoxKey = bcs.struct("BoxKey", { subject: bcs.Address, revoked: bcs.bool() }); + +/** Derive a subject's box address, mirroring on-chain + * `derived_object::derive_address(registry, BoxKey { subject, revoked })`. */ +function derivedBox( + registryPkg: string, + registryId: string, + subject: string, + revoked: boolean, +): string { + const keyBytes = BoxKey.serialize({ + subject: normalizeSuiAddress(subject), + revoked, + }).toBytes(); + return deriveObjectID( + registryId, + `${registryPkg}::attestation_registry::BoxKey`, + keyBytes, + ); +} + +/** Address of the per-subject active `Box` (`revoked: false`). `revoke` moves + * attestations out to the sibling revoked sink, so a read of this address + * yields exactly the un-revoked set. */ +export function boxAddress( + registryPkg: string, + registryId: string, + subject: string, +): string { + return derivedBox(registryPkg, registryId, subject, false); +} + +/** Address of the per-subject revoked sink (`revoked: true`) — where `revoke` + * moves attestations. Lets a read-by-type view (the Issued tab) tell a revoked + * attestation from a live one by its owner, since the object carries no status. */ +export function revokedBoxAddress( + registryPkg: string, + registryId: string, + subject: string, +): string { + return derivedBox(registryPkg, registryId, subject, true); } // === Attestation info + mapping === @@ -140,20 +178,10 @@ export function attestorFor( } // === Conventions (effectiveness) — ported from the attestation-registry repo's -// ts/src/conventions.ts. Operates purely on Display fields + ids. === - -export interface ConventionsContext { - fetchById: (id: string) => Promise; - now?: () => number; -} - -/** `active` renders as "true"/"false"; absent defaults to active. */ -function readActive(att: AttestationInfo): boolean { - const raw = att.display["active"]; - if (raw === true || raw === "true") return true; - if (raw === false || raw === "false") return false; - return true; -} +// ts/src/conventions.ts. Operates purely on Display fields. Revocation is no +// longer a convention: a revoked attestation is moved out of the active Box +// (the address `boxAddress` reads), so anything fetched from there is, by +// construction, un-revoked. === /** `expires_at` as Unix-ms, or null if absent/unparseable. */ function readExpiresAt(att: AttestationInfo): number | null { @@ -169,50 +197,14 @@ function readExpiresAt(att: AttestationInfo): number | null { return null; } -/** `requires` as a list of attestation ids (a JSON-array string over JSON-RPC). */ -function readRequires(att: AttestationInfo): string[] { - const raw = att.display["requires"]; - if (raw == null) return []; - if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string"); - if (typeof raw === "string") { - try { - const parsed: unknown = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed.filter((x): x is string => typeof x === "string"); - } - } catch { - // not JSON; ignore - } - } - return []; -} - /** - * An attestation is effective iff it is active, unexpired, and every - * attestation it `requires` is (transitively) effective. Cycles — which can't - * occur on-chain but could in malformed Display data — are treated as - * ineffective. + * An attestation is effective iff its `expires_at` Display field (if present) + * is still in the future. Revocation is handled upstream by box membership. */ -export async function isEffective( +export function isEffective( att: AttestationInfo, - ctx: ConventionsContext, - visited: Set = new Set(), -): Promise { - if (!readActive(att)) return false; - + now: () => number = Date.now, +): boolean { const expiresAt = readExpiresAt(att); - const now = (ctx.now ?? Date.now)(); - if (expiresAt !== null && now >= expiresAt) return false; - - const required = readRequires(att); - if (required.length > 0) { - if (visited.has(att.id)) return false; // cycle - const next = new Set(visited); - next.add(att.id); - for (const reqId of required) { - const req = await ctx.fetchById(reqId); - if (!(await isEffective(req, ctx, next))) return false; - } - } - return true; + return expiresAt === null || now() < expiresAt; } From ca99354e235cdddac6ff1e8c3f90f9589eebd072 Mon Sep 17 00:00:00 2001 From: Tonka User Date: Wed, 17 Jun 2026 14:44:20 +0000 Subject: [PATCH 23/54] Frontend: surface revoked attestations distinctly Revoked attestations live in the per-subject revoked sink, so the active-box reads never see them. Surface them explicitly instead of a subtle inline grey: - Issued tab: revoked entries move to their own "Revoked" section at the bottom (factored out groupBySubject + SubjectGroup). - Security page: read the revoked sink (fetchRevokedAttestations / useGetRevokedAttestations), sharing a new fetchBoxAttestations core with the active-box read, and render a concise "Revoked: ..." list. - InactiveList gains a `label` ("Inactive" for expired, "Revoked" for the sink); DisplayedAttestation now extends a base AttributedAttestation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 74 +++++++++++++------ .../SinglePackageTrustSignals.tsx | 40 +++++++--- app/src/hooks/useGetAttestations.ts | 71 ++++++++++++++---- 3 files changed, 138 insertions(+), 47 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 94492f97..a8a51783 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -36,17 +36,12 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { const subjects = [...new Set(issued.map((i) => i.subject))]; const { items: names } = useReverseResolution(subjects, network); + const nameOf = (subject: string) => (names[subject] as { name?: string })?.name; - // Group issued attestations by the subject they're about. - const groups: { subject: string; items: IssuedAttestation[] }[] = []; - for (const item of issued) { - let g = groups.find((x) => x.subject === item.subject); - if (!g) { - g = { subject: item.subject, items: [] }; - groups.push(g); - } - g.items.push(item); - } + // Live attestations group at the top; revoked ones move to their own + // section at the bottom so they don't read as endorsements. + const liveGroups = groupBySubject(issued.filter((i) => !i.revoked)); + const revokedGroups = groupBySubject(issued.filter((i) => i.revoked)); return (
@@ -62,8 +57,7 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { > On-chain claims this package has signed about other packages — audits, vulnerability disclosures, and the like. Each one also appears on the - subject package's Security tab. Inactive entries have been revoked - or have expired. + subject package's Security tab.
@@ -77,19 +71,55 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { )} - {groups.map((g) => ( -
- - about{" "} - + {liveGroups.map((g) => ( + + ))} + + {revokedGroups.length > 0 && ( +
+ +

Revoked

- {g.items.map((item) => ( - + {revokedGroups.map((g) => ( + ))}
+ )} +
+ ); +} + +/** Group issued attestations by the subject they're about. */ +function groupBySubject( + items: IssuedAttestation[], +): { subject: string; items: IssuedAttestation[] }[] { + const groups: { subject: string; items: IssuedAttestation[] }[] = []; + for (const item of items) { + let g = groups.find((x) => x.subject === item.subject); + if (!g) { + g = { subject: item.subject, items: [] }; + groups.push(g); + } + g.items.push(item); + } + return groups; +} + +/** A card of the attestations one package issued about a single subject. */ +function SubjectGroup({ + group, + name, +}: { + group: { subject: string; items: IssuedAttestation[] }; + name?: string; +}) { + return ( +
+ + about + + {group.items.map((item) => ( + ))}
); diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index f12bbfc8..bf1cb45c 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -2,7 +2,9 @@ import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; import { useGetAttestations, + useGetRevokedAttestations, useInheritedVulns, + type AttributedAttestation, type DisplayedAttestation, type InheritedVuln, } from "@/hooks/useGetAttestations"; @@ -119,6 +121,8 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { const { data, isLoading } = useGetAttestations(name.package_address, network); const { data: inheritedData } = useInheritedVulns(name.package_address, network); const inherited = inheritedData ?? []; + const { data: revokedData } = useGetRevokedAttestations(name.package_address, network); + const revoked = revokedData ?? []; const all = data ?? []; const negatives = all.filter((a) => isNegative(a.info)); @@ -135,11 +139,14 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { )} - {!isLoading && all.length === 0 && inherited.length === 0 && ( - - No attestations from trusted attestors. - - )} + {!isLoading && + all.length === 0 && + inherited.length === 0 && + revoked.length === 0 && ( + + No attestations from trusted attestors. + + )} {hasVulnSection && ( @@ -147,6 +154,12 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { {positives.length > 0 && } + {revoked.length > 0 && ( +
+ +
+ )} + {attestationConfig() && ( ( ))} - {ineffective.length > 0 && } + {ineffective.length > 0 && } ); } @@ -288,7 +301,7 @@ function AuditsSection({ items }: { items: DisplayedAttestation[] }) { {groups.map((g) => ( ))} - {ineffective.length > 0 && } + {ineffective.length > 0 && } ); } @@ -321,11 +334,18 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) { ); } -/** A compact, de-emphasized list of ineffective (revoked/superseded) ones. */ -function InactiveList({ items }: { items: DisplayedAttestation[] }) { +/** A compact, de-emphasized list — `label` is "Inactive" (expired) or + * "Revoked". Items are attributed attestations; effectiveness isn't read. */ +function InactiveList({ + label, + items, +}: { + label: string; + items: AttributedAttestation[]; +}) { return ( - Inactive:{" "} + {label}:{" "} {items.map((it, i) => ( {i > 0 ? ", " : ""} diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 6d888f06..3581602a 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -17,10 +17,15 @@ import { type TrustedAttestor, } from "@/lib/attestations"; -export interface DisplayedAttestation { +/** A trusted attestation attributed to the attester whose lineage defines its + * type — the shared base for the active-box and revoked-sink reads. */ +export interface AttributedAttestation { info: AttestationInfo; /** The trusted attester this attestation's type belongs to. */ attestor: TrustedAttestor; +} + +export interface DisplayedAttestation extends AttributedAttestation { /** Effectiveness per the conventions (unexpired; revocation is box membership). */ effective: boolean; } @@ -51,16 +56,46 @@ export async function fetchTrustedAttestations( cfg: AttestationConfig, subject: string, ): Promise { - const owner = boxAddress(cfg.registryPkg, cfg.registryId, subject); + const box = boxAddress(cfg.registryPkg, cfg.registryId, subject); + const trusted = await fetchBoxAttestations(client, cfg, box); + // Effectiveness: unexpired. Revocation is handled by box membership — a + // revoked attestation isn't in this box at all. + return trusted.map((a) => ({ ...a, effective: isEffective(a.info) })); +} + +/** + * The revoked attestations about `subject`: the trusted, displayed ones that + * `revoke` moved out of the active box into the subject's revoked sink. Same + * trusted/Display filter, just against the sink address. + */ +export async function fetchRevokedAttestations( + client: SuiClient, + cfg: AttestationConfig, + subject: string, +): Promise { + const sink = revokedBoxAddress(cfg.registryPkg, cfg.registryId, subject); + return fetchBoxAttestations(client, cfg, sink); +} + +/** + * Trusted, displayed attestations owned by `boxAddr`, attributed to their + * attester. Shared by the active-box and revoked-sink reads. Uses the gRPC + * `MatchAny` `StructType` filter so untrusted attesters are never fetched; a + * read-time Display-gate drops trusted-but-undisplayed types. + */ +async function fetchBoxAttestations( + client: SuiClient, + cfg: AttestationConfig, + boxAddr: string, +): Promise { const trustedTypes = await resolveTrustedTypes(client, cfg); if (trustedTypes.length === 0) return []; - // 1. Fetch only attestations of trusted types (server-side MatchAny). const infos: AttestationInfo[] = []; let cursor: string | null | undefined = null; do { const page = await client.getOwnedObjects({ - owner, + owner: boxAddr, filter: { MatchAny: trustedTypes.map((StructType) => ({ StructType })) }, options: { showType: true, showDisplay: true }, cursor, @@ -72,21 +107,12 @@ export async function fetchTrustedAttestations( cursor = page.hasNextPage ? page.nextCursor : null; } while (cursor); - // 2. Display-gate (drop trusted-but-undisplayed types) and attribute each to - // its attester for grouping. - const trusted = infos + return infos .map((info) => ({ info, attestor: attestorFor(cfg, info.innerType) })) .filter( - (x): x is { info: AttestationInfo; attestor: TrustedAttestor } => + (x): x is AttributedAttestation => !!x.attestor && Object.keys(x.info.display).length > 0, ); - - // 3. Effectiveness: unexpired (revocation is handled by box membership). - return trusted.map(({ info, attestor }) => ({ - info, - attestor, - effective: isEffective(info), - })); } // Cache the trusted type set per config — the lineage is static, so this only @@ -154,6 +180,21 @@ export function useGetAttestations( }); } +/** The revoked attestations about `subject` (read from the revoked sink). */ +export function useGetRevokedAttestations( + subject: string | undefined, + network: "mainnet" | "testnet", +) { + const client = useSuiClientsContext()[network]; + const cfg = attestationConfig(); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "revoked", network, subject], + enabled: !!subject && !!cfg, + queryFn: () => fetchRevokedAttestations(client, cfg!, subject!), + }); +} + /** * Vulnerabilities inherited from `subject`'s dependencies: a dependency's * effective negative attestations surface on its dependents (the negative dual From 19613d447ac3b4fca63b385020ef593ce8c9c322 Mon Sep 17 00:00:00 2001 From: Tonka User Date: Wed, 17 Jun 2026 14:59:28 +0000 Subject: [PATCH 24/54] Frontend positive MVP: carve out negatives + revocation polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the attestation frontend positive-only for the MVP and finish the revocation UX: - Carve out the negative surface: drop useInheritedVulns/InheritedVuln, the VulnerabilitiesSection/VulnRow/severity-chip UI, and isNegative/ readSeverity/severityBand. Shared MVR features (useMvrDependencies, the Dependencies/Dependents/Versions tabs) untouched. - Security page: warn when a package has no live attestations ("…no active attestations published on MVR, it may not have been audited") — covers unattested and only-revoked alike — and drop the "View all trusted attestors" link (reachable via the attester icon). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 18 +- .../SinglePackageTrustSignals.tsx | 240 ++---------------- app/src/hooks/useGetAttestations.ts | 71 ------ app/src/lib/attestations.ts | 30 --- 4 files changed, 27 insertions(+), 332 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index a8a51783..1aeac957 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -7,7 +7,6 @@ import { import { useReverseResolution } from "@/hooks/useReverseResolution"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; -import { isNegative, readSeverity, severityBand } from "@/lib/attestations"; /** Tab label: count of attestations this package has issued. */ export function IssuedCount({ @@ -127,19 +126,13 @@ function SubjectGroup({ function IssuedRow({ item }: { item: IssuedAttestation }) { const { display, innerType } = item.info; - const negative = isNegative(item.info); const title = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; - const severity = negative ? readSeverity(item.info) : null; - const band = severity !== null ? severityBand(severity) : null; const live = !item.revoked && item.effective; return (
@@ -150,13 +143,6 @@ function IssuedRow({ item }: { item: IssuedAttestation }) { (expired) ) : null} - {band && ( - - - {band.label} ({severity!.toFixed(1)}) - - - )}
{innerType.split("::").slice(1).join("::")} diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index bf1cb45c..c54e4e05 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -3,50 +3,33 @@ import { usePackagesNetwork } from "../providers/packages-provider"; import { useGetAttestations, useGetRevokedAttestations, - useInheritedVulns, type AttributedAttestation, type DisplayedAttestation, - type InheritedVuln, } from "@/hooks/useGetAttestations"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; import ExplorerLink from "../ui/explorer-link"; -import { - attestationConfig, - isNegative, - readSeverity, - severityBand, - type TrustedAttestor, -} from "@/lib/attestations"; +import { type TrustedAttestor } from "@/lib/attestations"; -/** A triangle-exclamation glyph; color comes from the text color (currentColor). */ -function WarningIcon({ - className, - style, -}: { - className?: string; - style?: React.CSSProperties; -}) { +/** A check glyph; color comes from the text color (currentColor). */ +function CheckIcon({ className }: { className?: string }) { return ( - - - + ); } -/** A check glyph; color comes from the text color (currentColor). */ -function CheckIcon({ className }: { className?: string }) { +/** A triangle-exclamation glyph; color comes from the text color (currentColor). */ +function WarningIcon({ className }: { className?: string }) { return ( - + + + ); } -/** Tab label: two pills — effective positive attestations (check) and - * effective negative ones (warning, incl. inherited). Each shown if non-zero. */ +/** Tab label: a pill with the count of effective attestations. */ export function TrustSignalCount({ address, network, @@ -72,33 +56,15 @@ export function TrustSignalCount({ network: "mainnet" | "testnet"; }) { const { data } = useGetAttestations(address, network); - const { data: inherited } = useInheritedVulns(address, network); - const effective = (data ?? []).filter((a) => a.effective); - const positives = effective.filter((a) => !isNegative(a.info)).length; + const positives = (data ?? []).filter((a) => a.effective).length; - // Vulnerability scores (own effective negatives + inherited); the warning - // pill is colored by the most severe one. - const vulnScores = effective - .filter((a) => isNegative(a.info)) - .map((a) => readSeverity(a.info) ?? 0) - .concat((inherited ?? []).map((v) => readSeverity(v.attestation.info) ?? 0)); - const warnColor = severityBand(vulnScores.length ? Math.max(...vulnScores) : 0).color; - - if (!positives && !vulnScores.length) return null; + if (!positives) return null; return (
- {positives > 0 && ( - } - count={positives} - /> - )} - {vulnScores.length > 0 && ( - } - count={vulnScores.length} - /> - )} + } + count={positives} + />
); } @@ -119,15 +85,11 @@ function CountPill({ icon, count }: { icon: React.ReactNode; count: number }) { export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { data, isLoading } = useGetAttestations(name.package_address, network); - const { data: inheritedData } = useInheritedVulns(name.package_address, network); - const inherited = inheritedData ?? []; const { data: revokedData } = useGetRevokedAttestations(name.package_address, network); const revoked = revokedData ?? []; - const all = data ?? []; - const negatives = all.filter((a) => isNegative(a.info)); - const positives = all.filter((a) => !isNegative(a.info)); - const hasVulnSection = negatives.length > 0 || inherited.length > 0; + const positives = data ?? []; + const hasLiveAttestation = positives.some((a) => a.effective); return (
@@ -139,17 +101,14 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { )} - {!isLoading && - all.length === 0 && - inherited.length === 0 && - revoked.length === 0 && ( + {!isLoading && !hasLiveAttestation && ( +
+ - No attestations from trusted attestors. + This package has no active attestations published on MVR — it may + not have been audited. - )} - - {hasVulnSection && ( - +
)} {positives.length > 0 && } @@ -159,129 +118,6 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) {
)} - - {attestationConfig() && ( -
- - View all trusted attestors → - - - )} -
- ); -} - -/** A single vulnerability to render — own or inherited from a dependency. */ -interface VulnEntry { - attestation: DisplayedAttestation; - /** Set when the vulnerability is inherited from a dependency. */ - via?: { id: string; name?: string }; -} - -/** All vulnerabilities (own + inherited) in one severity-sorted list. */ -function VulnerabilitiesSection({ - own, - inherited, -}: { - own: DisplayedAttestation[]; - inherited: InheritedVuln[]; -}) { - const ineffective = own.filter((a) => !a.effective); - const entries: VulnEntry[] = [ - ...own.filter((a) => a.effective).map((a) => ({ attestation: a })), - ...inherited.map((v) => ({ - attestation: v.attestation, - via: { id: v.viaPackageId, name: v.viaName }, - })), - ].sort((a, b) => severityOf(b.attestation.info) - severityOf(a.attestation.info)); - - // Header is colored and summarized by the active vulnerabilities' severities. - const scores = entries.map((e) => readSeverity(e.attestation.info) ?? 0); - const maxColor = severityBand(scores.length ? Math.max(...scores) : 0).color; - - return ( -
-
- - - Vulnerabilities - - - · {scores.length ? severityBreakdown(scores) : "none active"} - -
- {entries.map((e) => ( - - ))} - {ineffective.length > 0 && } -
- ); -} - -/** Summarize scores by band, e.g. "1 high, 1 medium" (most severe first), - * each segment colored by its own band. */ -function severityBreakdown(scores: number[]): React.ReactNode { - const order = ["Critical", "High", "Medium", "Low", "None"]; - const byBand: Record = {}; - for (const s of scores) { - const b = severityBand(s); - byBand[b.label] = { count: (byBand[b.label]?.count ?? 0) + 1, color: b.color }; - } - return order - .filter((label) => byBand[label]) - .map((label, i) => ( - - {i > 0 ? ", " : ""} - {byBand[label]!.count} {label.toLowerCase()} - - )); -} - -function VulnRow({ entry }: { entry: VulnEntry }) { - const network = usePackagesNetwork() as "mainnet" | "testnet"; - const { attestation, via } = entry; - const { display, innerType, id } = attestation.info; - const headline = str(display["description"]) ?? str(display["name"]) ?? "Vulnerability"; - const link = httpsLink(display["link"]); - const severity = readSeverity(attestation.info); - const band = severityBand(severity ?? 0); - - return ( -
-
- - {headline} - {link && <> ({reportLink(link)})} - - {severity !== null && } -
-
- - - {attestation.attestor.mvrName ? ( - <> - {mvrLink(attestation.attestor.mvrName)} - {`::${moduleAndType(innerType)} (`} - {objectLink(id, network)} - {")"} - - ) : ( - {innerType} - )} - {via && ( - <> - {" · in dependency "} - - - )} - -
); } @@ -407,17 +243,6 @@ function AttestationRow({ item }: { item: DisplayedAttestation }) { ); } -function SeverityChip({ score }: { score: number }) { - const band = severityBand(score); - return ( - - - {band.label} ({score.toFixed(1)}) - - - ); -} - /** The attestation object id, truncated and linked to an explorer. */ function objectLink(id: string, network: "mainnet" | "testnet"): React.ReactNode { return ( @@ -443,17 +268,6 @@ function reportLink(url: string): React.ReactNode { /** Link to a dependency's MVR page by name, or show its id when unresolved * (the package route resolves by name, so a raw id isn't linkable). */ -function DepLink({ id, name }: { id: string; name?: string }) { - if (name) { - return ( - - {name} - - ); - } - return {truncateId(id)}; -} - /** Render an MVR name as a link to its package page. */ function mvrLink(name: string): React.ReactNode { return ( @@ -501,10 +315,6 @@ function initials(name: string): string { } /** CVSS score for sorting; unscored attestations sort last. */ -function severityOf(info: DisplayedAttestation["info"]): number { - return readSeverity(info) ?? -1; -} - function truncateId(id: string): string { return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id; } diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 3581602a..21d3e038 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -3,14 +3,12 @@ import { AppQueryKeys } from "@/utils/types"; import { useQuery } from "@tanstack/react-query"; import type { SuiClient } from "@mysten/sui/client"; import { normalizeSuiAddress } from "@mysten/sui/utils"; -import { MvrHeader } from "@/lib/utils"; import { attestationConfig, attestorFor, boxAddress, revokedBoxAddress, isEffective, - isNegative, toAttestationInfo, type AttestationConfig, type AttestationInfo, @@ -30,15 +28,6 @@ export interface DisplayedAttestation extends AttributedAttestation { effective: boolean; } -/** A dependency's effective vulnerability, surfaced on a dependent package. */ -export interface InheritedVuln { - attestation: DisplayedAttestation; - /** The dependency package the vulnerability is attested about. */ - viaPackageId: string; - /** That dependency's MVR name, if it resolves. */ - viaName?: string; -} - /** * Core read: the attestations about `subject` from the configured trusted * attesters, with effectiveness. Reads the per-subject Box directly over @@ -195,66 +184,6 @@ export function useGetRevokedAttestations( }); } -/** - * Vulnerabilities inherited from `subject`'s dependencies: a dependency's - * effective negative attestations surface on its dependents (the negative dual - * of `requires` — see CONVENTIONS.md `polarity`). Dependencies come from MVR; - * the per-dependency reads are the same trusted-attestation reads as above. - */ -export function useInheritedVulns( - subject: string | undefined, - network: "mainnet" | "testnet", -) { - const clients = useSuiClientsContext(); - const client = clients[network]; - const endpoint = clients.mvrEndpoints[network]; - const cfg = attestationConfig(); - - return useQuery({ - queryKey: [AppQueryKeys.ATTESTATIONS, "inherited", network, subject], - enabled: !!subject && !!cfg, - queryFn: async (): Promise => { - // 1. Direct dependencies (from MVR). - const res = await fetch( - `${endpoint}/v1/package-address/${subject}/dependencies`, - MvrHeader(), - ); - const deps: string[] = res.ok ? ((await res.json()).dependencies ?? []) : []; - if (!deps.length) return []; - - // 2. Each dependency's effective negative attestations. - const perDep = await Promise.all( - deps.map(async (dep) => { - const atts = await fetchTrustedAttestations(client, cfg!, dep); - return atts - .filter((a) => a.effective && isNegative(a.info)) - .map((a) => ({ attestation: a, viaPackageId: dep })); - }), - ); - const inherited = perDep.flat(); - if (!inherited.length) return inherited; - - // 3. Best-effort dependency names for the provenance note. - try { - const body = await fetch(`${endpoint}/v1/reverse-resolution/bulk`, { - method: "POST", - ...MvrHeader({ "Content-Type": "application/json" }), - body: JSON.stringify({ package_ids: deps }), - }).then((r) => r.json()); - const nameByAddr: Record = {}; - for (const [addr, r] of Object.entries(body.resolution ?? {})) { - nameByAddr[normalizeSuiAddress(addr)] = (r as { name?: string })?.name; - } - for (const v of inherited) { - v.viaName = nameByAddr[normalizeSuiAddress(v.viaPackageId)]; - } - } catch { - // names are optional; fall back to the package id in the UI - } - return inherited; - }, - }); -} /** An attestation issued *by* a package, and the subject it is about. */ export interface IssuedAttestation { diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 33a91849..5e8d9c62 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -127,36 +127,6 @@ export function innerTypePackage(innerType: string): string { return normalizeSuiAddress(innerType.split("::")[0]!); } -/** A negative attestation (e.g. a vulnerability) per the `polarity` convention. - * Absence of the field defaults to positive. */ -export function isNegative(att: AttestationInfo): boolean { - return att.display["polarity"] === "negative"; -} - -/** The `severity` convention value (a CVSS base score 0–10), or null. */ -export function readSeverity(att: AttestationInfo): number | null { - const raw = att.display["severity"]; - const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN; - return Number.isFinite(n) ? n : null; -} - -export interface SeverityBand { - label: string; - /** A CSS color (var) for the band, for inline styling. The `content` - * palette is text-only (no `border-content-*`), and Tailwind only scans - * `.tsx`, so a class built here wouldn't be generated — hence a raw var. */ - color: string; -} - -/** Map a CVSS base score to its qualitative band (CVSS v3.1). */ -export function severityBand(score: number): SeverityBand { - if (score >= 9) return { label: "Critical", color: "var(--content-negative)" }; - if (score >= 7) return { label: "High", color: "var(--content-negative)" }; - if (score >= 4) return { label: "Medium", color: "var(--content-warning)" }; - if (score > 0) return { label: "Low", color: "var(--content-tertiary)" }; - return { label: "None", color: "var(--content-tertiary)" }; -} - /** Whether `pkg` is a configured trusted attester (any lineage version). * A pure in-memory check — used to gate the "Issued" tab without any RPC. */ export function isConfiguredAttestor(cfg: AttestationConfig, pkg: string): boolean { From ee60fff9ef99bce039ca6a8d2c9f2a5a75364eca Mon Sep 17 00:00:00 2001 From: Michael George Date: Tue, 23 Jun 2026 19:28:52 +0000 Subject: [PATCH 25/54] demo trust config: audit_example -> auditor_a ("Auditor A") Match the attestation-repo restructure that renamed the demo's trusted auditor package to `auditor_a` (a published copy of `examples/auditor`). write-demo-env keys the friendly name/icon/mvr-name maps on the demo-ids trustedAttestor name, which is now `auditor_a`. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/write-demo-env.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 54a9a8dd..4c42c280 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -36,18 +36,18 @@ d = json.load(open(sys.argv[1])) # Friendly display names for the demo attesters (presentation lives in the # consumer's trust config, not on-chain). NAMES = { - "audit_example": "Example Auditor", + "auditor_a": "Auditor A", "vuln_example": "Example Security Scanner", } # MVR names of the attester packages (kept in sync with demo_server.rs). MVR_NAMES = { - "audit_example": "@example-auditor/audits", + "auditor_a": "@auditor-a/audits", "vuln_example": "@example-scanner/disclosures", } # Brand icons (served from app/public). Presentation lives in the consumer's # trust config, never on-chain; absent → the UI falls back to an initials avatar. ICONS = { - "audit_example": "/demo-attestors/auditor.svg", + "auditor_a": "/demo-attestors/auditor.svg", "vuln_example": "/demo-attestors/scanner.svg", } attestors = [] From cff87433018efb905524bc73a1ee62f120443449 Mon Sep 17 00:00:00 2001 From: Michael George Date: Wed, 24 Jun 2026 19:33:57 +0000 Subject: [PATCH 26/54] Frontend PR-A cleanups: remove expiration; lib/hooks/script tidy Easy #243 review comments: - Remove the expiration feature (expires_at is planned-future on the registry): drop isEffective/readExpiresAt + the `effective` field across lib/hooks/components. - attestations.ts: SuiAddress/ObjectId type aliases; drop the redundant `type` field from AttestationInfo (derivable from innerType); reword the confusing lineage comment; strip M1/M2/M3 milestone labels. - write-demo-env.sh: collapse the parallel name/icon/mvr maps into one per-attester ATTESTORS dict; document the demo-ids.json format. Fix stale "revoked sink" -> "revoked box". typecheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 8 +- .../SinglePackageTrustSignals.tsx | 14 ++- app/src/hooks/useGetAttestations.ts | 70 ++++++------ app/src/lib/attestations.ts | 102 +++++++----------- scripts/write-demo-env.sh | 53 +++++---- 5 files changed, 110 insertions(+), 137 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 1aeac957..d248e305 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -127,7 +127,7 @@ function SubjectGroup({ function IssuedRow({ item }: { item: IssuedAttestation }) { const { display, innerType } = item.info; const title = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; - const live = !item.revoked && item.effective; + const live = !item.revoked; return (
{title} - {item.revoked ? ( + {item.revoked && ( (revoked) - ) : !item.effective ? ( - (expired) - ) : null} + )}
diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index c54e4e05..14b33505 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -47,7 +47,7 @@ function WarningIcon({ className }: { className?: string }) { ); } -/** Tab label: a pill with the count of effective attestations. */ +/** Tab label: a pill with the count of live attestations. */ export function TrustSignalCount({ address, network, @@ -56,7 +56,7 @@ export function TrustSignalCount({ network: "mainnet" | "testnet"; }) { const { data } = useGetAttestations(address, network); - const positives = (data ?? []).filter((a) => a.effective).length; + const positives = (data ?? []).length; if (!positives) return null; return ( @@ -89,7 +89,7 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { const revoked = revokedData ?? []; const positives = data ?? []; - const hasLiveAttestation = positives.some((a) => a.effective); + const hasLiveAttestation = positives.length > 0; return (
@@ -124,8 +124,7 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { /** Audits (positive attestations), grouped by attester. */ function AuditsSection({ items }: { items: DisplayedAttestation[] }) { - const ineffective = items.filter((a) => !a.effective); - const groups = groupByAttestor(items.filter((a) => a.effective)); + const groups = groupByAttestor(items); return (
@@ -137,7 +136,6 @@ function AuditsSection({ items }: { items: DisplayedAttestation[] }) { {groups.map((g) => ( ))} - {ineffective.length > 0 && }
); } @@ -170,8 +168,8 @@ function AttestorGroupCard({ group }: { group: AttestorGroup }) { ); } -/** A compact, de-emphasized list — `label` is "Inactive" (expired) or - * "Revoked". Items are attributed attestations; effectiveness isn't read. */ +/** A compact, de-emphasized list — `label` is "Revoked". Items are attributed + * attestations. */ function InactiveList({ label, items, diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 21d3e038..e79d03c2 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -8,7 +8,6 @@ import { attestorFor, boxAddress, revokedBoxAddress, - isEffective, toAttestationInfo, type AttestationConfig, type AttestationInfo, @@ -16,59 +15,56 @@ import { } from "@/lib/attestations"; /** A trusted attestation attributed to the attester whose lineage defines its - * type — the shared base for the active-box and revoked-sink reads. */ + * type — the shared base for the active-box and revoked-box reads. */ export interface AttributedAttestation { info: AttestationInfo; /** The trusted attester this attestation's type belongs to. */ attestor: TrustedAttestor; } -export interface DisplayedAttestation extends AttributedAttestation { - /** Effectiveness per the conventions (unexpired; revocation is box membership). */ - effective: boolean; -} +/** A trusted, display-gated attestation ready to render. (Currently the same + * shape as AttributedAttestation; kept as a distinct name on the render path.) */ +export type DisplayedAttestation = AttributedAttestation; /** * Core read: the attestations about `subject` from the configured trusted - * attesters, with effectiveness. Reads the per-subject Box directly over - * JSON-RPC (no MVR backend). + * attesters. Reads the per-subject Box directly over JSON-RPC (no MVR backend). * - * Spam-resistant (M3): rather than fetch every `Attestation<*>` on the box and - * filter client-side, it asks the node for only the exact trusted types via a - * `MatchAny` `StructType` filter — so attestations from untrusted attesters are - * never returned. The trusted type set is the `Attestation` for every store - * type `T` defined by a trusted attester's lineage (see `resolveTrustedTypes`). - * A read-time Display-gate still drops trusted-but-undisplayed types. + * Spam-resistant: rather than fetch every `Attestation<*>` on the box and filter + * client-side, it asks the node for only the exact trusted types via a `MatchAny` + * `StructType` filter — so attestations from untrusted attesters are never + * returned. The trusted type set is the `Attestation` for every store type `T` + * defined by a trusted attester's lineage (see `resolveTrustedTypes`). A + * read-time Display-gate still drops trusted-but-undisplayed types. */ export async function fetchTrustedAttestations( client: SuiClient, cfg: AttestationConfig, subject: string, ): Promise { + // Revocation is handled by box membership — a revoked attestation isn't in + // the active box at all, so everything read here is live. const box = boxAddress(cfg.registryPkg, cfg.registryId, subject); - const trusted = await fetchBoxAttestations(client, cfg, box); - // Effectiveness: unexpired. Revocation is handled by box membership — a - // revoked attestation isn't in this box at all. - return trusted.map((a) => ({ ...a, effective: isEffective(a.info) })); + return fetchBoxAttestations(client, cfg, box); } /** * The revoked attestations about `subject`: the trusted, displayed ones that - * `revoke` moved out of the active box into the subject's revoked sink. Same - * trusted/Display filter, just against the sink address. + * `revoke` moved out of the active box into the subject's revoked box. Same + * trusted/Display filter, just against the revoked-box address. */ export async function fetchRevokedAttestations( client: SuiClient, cfg: AttestationConfig, subject: string, ): Promise { - const sink = revokedBoxAddress(cfg.registryPkg, cfg.registryId, subject); - return fetchBoxAttestations(client, cfg, sink); + const revokedBox = revokedBoxAddress(cfg.registryPkg, cfg.registryId, subject); + return fetchBoxAttestations(client, cfg, revokedBox); } /** * Trusted, displayed attestations owned by `boxAddr`, attributed to their - * attester. Shared by the active-box and revoked-sink reads. Uses the gRPC + * attester. Shared by the active-box and revoked-box reads. Uses the gRPC * `MatchAny` `StructType` filter so untrusted attesters are never fetched; a * read-time Display-gate drops trusted-but-undisplayed types. */ @@ -129,8 +125,8 @@ export function resolveTrustedTypes( /** * The `Attestation` type strings for every `store` type `T` defined across - * the given package lineage. Querying each version covers types by their - * defining (canonical) id; non-canonical combinations match no objects. + * the given package lineage. Querying each version covers types by their defining + * (canonical) id; non-canonical combinations match no objects. */ export async function enumerateAttestationTypes( client: SuiClient, @@ -169,7 +165,7 @@ export function useGetAttestations( }); } -/** The revoked attestations about `subject` (read from the revoked sink). */ +/** The revoked attestations about `subject` (read from the revoked box). */ export function useGetRevokedAttestations( subject: string | undefined, network: "mainnet" | "testnet", @@ -184,16 +180,13 @@ export function useGetRevokedAttestations( }); } - /** An attestation issued *by* a package, and the subject it is about. */ export interface IssuedAttestation { info: AttestationInfo; /** The subject (package) the attestation is about. */ subject: string; - /** Moved to the subject's revoked sink (vs. its active box). */ + /** Moved to the subject's revoked box (vs. its active box). */ revoked: boolean; - /** Unexpired per the `expires_at` convention (independent of `revoked`). */ - effective: boolean; } const ISSUED_QUERY = `query($type: String!) { @@ -204,11 +197,11 @@ const ISSUED_QUERY = `query($type: String!) { /** * The attestations *issued by* `pkg` — the reverse of the per-subject read. - * Uses GraphQL `objects(type:)` to find every `Attestation` of the - * package's types across all Boxes (the object's `subject` field says who it's - * about), then re-reads each over JSON-RPC for Display + effectiveness. Only - * configured trusted attesters issue attestations in the demo, so a package not - * in the trust config returns nothing. + * Uses GraphQL `objects(type:)` to find every `Attestation` of the package's + * types across all Boxes (the object's `subject` field says who it's about), then + * re-reads each over JSON-RPC for Display + owner. Only configured trusted + * attesters issue attestations in the demo, so a package not in the trust config + * returns nothing. */ export function useIssuedAttestations( pkg: string | undefined, @@ -248,7 +241,7 @@ export function useIssuedAttestations( if (subjectById.size === 0) return []; // Re-read each for Display + owner; drop undisplayed types. An issued - // attestation is revoked iff it now lives in its subject's revoked sink + // attestation is revoked iff it now lives in its subject's revoked box // rather than the active box — the read-by-type Issued view is the one // place that recovers revocation from ownership, since the object itself // carries no status field. @@ -265,13 +258,12 @@ export function useIssuedAttestations( ownerField && typeof ownerField === "object" && "AddressOwner" in ownerField ? ownerField.AddressOwner : undefined; - const sink = revokedBoxAddress(cfg!.registryPkg, cfg!.registryId, subject); - const revoked = !!owner && normalizeSuiAddress(owner) === normalizeSuiAddress(sink); + const revokedBox = revokedBoxAddress(cfg!.registryPkg, cfg!.registryId, subject); + const revoked = !!owner && normalizeSuiAddress(owner) === normalizeSuiAddress(revokedBox); out.push({ info, subject: normalizeSuiAddress(subject), revoked, - effective: isEffective(info), }); } return out; diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 5e8d9c62..d5442099 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -7,6 +7,11 @@ import { bcs } from "@mysten/sui/bcs"; import { deriveObjectID, normalizeSuiAddress } from "@mysten/sui/utils"; import type { SuiObjectResponse } from "@mysten/sui/client"; +/** A 0x-prefixed Sui address. */ +export type SuiAddress = string; +/** A 0x-prefixed object id. */ +export type ObjectId = string; + // === Config (NEXT_PUBLIC_ATTESTATION_CONFIG, JSON) === export interface TrustedAttestor { @@ -18,19 +23,19 @@ export interface TrustedAttestor { /** Optional MVR name of the attester package, for linking to its page. */ mvrName?: string; /** Original publish id of the attester package — the trust anchor. */ - originalId: string; - /** Every package-version id in the attester's lineage. Matching an - * attestation's inner-type package against this set is equivalent to - * resolving that package's original id (the on-chain `attester_of` rule). - * M2 seeds this directly; M3 derives it via GraphQL `packageVersions`. */ - lineage: string[]; + originalId: SuiAddress; + /** Every package-version id in the attester's lineage (original publish + + * upgrades). An attestation is trusted when its inner type's defining + * package is in this set — the off-chain equivalent of the on-chain + * `attester_of` rule. */ + lineage: SuiAddress[]; } export interface AttestationConfig { /** attestation_registry package id (the `Attestation<>` wrapper type). */ - registryPkg: string; + registryPkg: SuiAddress; /** The shared Registry object id — parent for per-subject Box derivation. */ - registryId: string; + registryId: ObjectId; trustedAttestors: TrustedAttestor[]; } @@ -52,11 +57,11 @@ const BoxKey = bcs.struct("BoxKey", { subject: bcs.Address, revoked: bcs.bool() /** Derive a subject's box address, mirroring on-chain * `derived_object::derive_address(registry, BoxKey { subject, revoked })`. */ function derivedBox( - registryPkg: string, - registryId: string, - subject: string, + registryPkg: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, revoked: boolean, -): string { +): ObjectId { const keyBytes = BoxKey.serialize({ subject: normalizeSuiAddress(subject), revoked, @@ -68,35 +73,35 @@ function derivedBox( ); } -/** Address of the per-subject active `Box` (`revoked: false`). `revoke` moves - * attestations out to the sibling revoked sink, so a read of this address - * yields exactly the un-revoked set. */ +/** Address of the subject's active `Box` (`revoked: false`). `revoke` moves an + * attestation to the sibling revoked box, so reading this address yields + * exactly the un-revoked set. */ export function boxAddress( - registryPkg: string, - registryId: string, - subject: string, -): string { + registryPkg: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, +): ObjectId { return derivedBox(registryPkg, registryId, subject, false); } -/** Address of the per-subject revoked sink (`revoked: true`) — where `revoke` - * moves attestations. Lets a read-by-type view (the Issued tab) tell a revoked - * attestation from a live one by its owner, since the object carries no status. */ +/** Address of the subject's revoked `Box` (`revoked: true`) — where `revoke` + * moves attestations. Lets the read-by-type Issued tab tell a revoked + * attestation from a live one by its owner, since the object carries no + * status field. */ export function revokedBoxAddress( - registryPkg: string, - registryId: string, - subject: string, -): string { + registryPkg: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, +): ObjectId { return derivedBox(registryPkg, registryId, subject, true); } // === Attestation info + mapping === export interface AttestationInfo { - id: string; - /** Full object type, `…::attestation_registry::Attestation`. */ - type: string; - /** The inner type `T`, e.g. `0xAUD::audit::Audit`. */ + id: ObjectId; + /** The inner type `T`, e.g. `0xAUD::audit::Audit`. The full object type is + * always `${registryPkg}::attestation_registry::Attestation<${innerType}>`. */ innerType: string; /** Server-rendered Display v2 fields (all values are strings). */ display: Record; @@ -116,20 +121,19 @@ export function toAttestationInfo(resp: SuiObjectResponse): AttestationInfo | nu if (!m) return null; return { id: d.objectId, - type: d.type, innerType: m[1]!, display: (d.display?.data ?? {}) as Record, }; } /** The defining (origin) package id of an inner type string. */ -export function innerTypePackage(innerType: string): string { +export function innerTypePackage(innerType: string): SuiAddress { return normalizeSuiAddress(innerType.split("::")[0]!); } /** Whether `pkg` is a configured trusted attester (any lineage version). * A pure in-memory check — used to gate the "Issued" tab without any RPC. */ -export function isConfiguredAttestor(cfg: AttestationConfig, pkg: string): boolean { +export function isConfiguredAttestor(cfg: AttestationConfig, pkg: SuiAddress): boolean { const id = normalizeSuiAddress(pkg); return cfg.trustedAttestors.some((a) => a.lineage.some((v) => normalizeSuiAddress(v) === id), @@ -146,35 +150,3 @@ export function attestorFor( a.lineage.some((id) => normalizeSuiAddress(id) === pkg), ); } - -// === Conventions (effectiveness) — ported from the attestation-registry repo's -// ts/src/conventions.ts. Operates purely on Display fields. Revocation is no -// longer a convention: a revoked attestation is moved out of the active Box -// (the address `boxAddress` reads), so anything fetched from there is, by -// construction, un-revoked. === - -/** `expires_at` as Unix-ms, or null if absent/unparseable. */ -function readExpiresAt(att: AttestationInfo): number | null { - const raw = att.display["expires_at"]; - if (raw == null) return null; - if (typeof raw === "number") return raw; - if (typeof raw === "string") { - const asNum = Number(raw); - if (!Number.isNaN(asNum) && asNum > 0) return asNum; - const asDate = Date.parse(raw); - if (!Number.isNaN(asDate)) return asDate; - } - return null; -} - -/** - * An attestation is effective iff its `expires_at` Display field (if present) - * is still in the future. Revocation is handled upstream by box membership. - */ -export function isEffective( - att: AttestationInfo, - now: () => number = Date.now, -): boolean { - const expiresAt = readExpiresAt(att); - return expiresAt === null || now() < expiresAt; -} diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 4c42c280..f5278f21 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -3,6 +3,19 @@ # repo's demo-ids.json: repoint all networks at the local stack and inject the # attestation config (registry id/pkg + trusted attesters with their lineage). # +# demo-ids.json (produced by the attestation-registry repo's run-demo.sh): +# { +# "registryId": "0x…", // the shared Registry object id +# "attestationRegistryPkg": "0x…", // the attestation_registry package id +# "subjects": { "subject": "0x…", "dependency": "0x…" }, +# "trustedAttestors": [ +# { "name": "auditor_a", "originalId": "0x…", "latestId": "0x…" } +# ], +# "createdAttestations": { "dependencyAudit": "0x…", "subjectAuditV2": "0x…" } +# } +# This script reads registryId, attestationRegistryPkg, and trustedAttestors +# (name → friendly presentation via ATTESTORS below; originalId+latestId → lineage). +# # Usage: # bash scripts/write-demo-env.sh # The path is required (or set ATTESTATION_DEMO_IDS); demo-ids.json is written @@ -29,34 +42,34 @@ if [[ ! -f "$DEMO_IDS" ]]; then fi # Build the attestation config. Each attester's lineage is its original id plus -# any later version ids (deduped) — the M2 client-side trusted set. +# any later version ids (deduped) — the client-side trusted set. CONFIG=$(python3 - "$DEMO_IDS" <<'PY' import json, sys d = json.load(open(sys.argv[1])) -# Friendly display names for the demo attesters (presentation lives in the -# consumer's trust config, not on-chain). -NAMES = { - "auditor_a": "Auditor A", - "vuln_example": "Example Security Scanner", -} -# MVR names of the attester packages (kept in sync with demo_server.rs). -MVR_NAMES = { - "auditor_a": "@auditor-a/audits", - "vuln_example": "@example-scanner/disclosures", -} -# Brand icons (served from app/public). Presentation lives in the consumer's -# trust config, never on-chain; absent → the UI falls back to an initials avatar. -ICONS = { - "auditor_a": "/demo-attestors/auditor.svg", - "vuln_example": "/demo-attestors/scanner.svg", +# Per-attester presentation metadata, keyed by the demo-ids `name`. Presentation +# lives in the consumer's trust config, never on-chain. `iconUrl` is served from +# app/public; `mvrName` is kept in sync with demo_server.rs. An attester absent +# from this map falls back to its raw name with no icon/mvrName. +ATTESTORS = { + "auditor_a": { + "name": "Auditor A", + "mvrName": "@auditor-a/audits", + "iconUrl": "/demo-attestors/auditor.svg", + }, + "vuln_example": { + "name": "Example Security Scanner", + "mvrName": "@example-scanner/disclosures", + "iconUrl": "/demo-attestors/scanner.svg", + }, } attestors = [] for a in d["trustedAttestors"]: + meta = ATTESTORS.get(a["name"], {}) lineage = list(dict.fromkeys([a["originalId"], a.get("latestId", a["originalId"])])) attestors.append({ - "name": NAMES.get(a["name"], a["name"]), - "iconUrl": ICONS.get(a["name"]), - "mvrName": MVR_NAMES.get(a["name"]), + "name": meta.get("name", a["name"]), + "iconUrl": meta.get("iconUrl"), + "mvrName": meta.get("mvrName"), "originalId": a["originalId"], "lineage": lineage, }) From b5d681976e5ec4ef0c2a0d001b49b9ce70b08762 Mon Sep 17 00:00:00 2001 From: Michael George Date: Wed, 24 Jun 2026 21:05:23 +0000 Subject: [PATCH 27/54] attestation config: source default + env override (not env-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per #243 review (attestations.ts:42): mvr hardcodes prod config as source constants (client-provider.tsx); env vars are only a demo override. The attestation config was the outlier — env-only. Add a checked-in CHECKED_IN_CONFIG source constant (empty trusted-attester list for now → dormant in prod, as today); attestationConfig() falls back to it, with NEXT_PUBLIC_ATTESTATION_CONFIG (write-demo-env) as the demo override — mirroring the `LOCAL_* ?? "https://…"` pattern. The mvr team curates real attesters in CHECKED_IN_CONFIG to enable it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/lib/attestations.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index d5442099..35cf7387 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -39,13 +39,34 @@ export interface AttestationConfig { trustedAttestors: TrustedAttestor[]; } +/** + * Curated trusted attesters for production, sourced from code — like the network + * endpoints in `client-provider.tsx`, not the environment. Empty for now, so the + * feature stays dormant until attesters are onboarded here. The demo overrides + * this via `NEXT_PUBLIC_ATTESTATION_CONFIG` (written by `write-demo-env.sh`), the + * same way `NEXT_PUBLIC_LOCAL_*` overrides the endpoints. + */ +const CHECKED_IN_CONFIG: AttestationConfig = { + registryPkg: "", + registryId: "", + trustedAttestors: [], +}; + let cached: AttestationConfig | null | undefined; -/** Parse the attestation config from env, or null if unset (production). */ +/** + * The attestation config: the `NEXT_PUBLIC_ATTESTATION_CONFIG` env override (the + * demo) if set, else the checked-in production config — or null while that has no + * attesters (the feature is dormant). + */ export function attestationConfig(): AttestationConfig | null { if (cached === undefined) { const raw = process.env.NEXT_PUBLIC_ATTESTATION_CONFIG; - cached = raw ? (JSON.parse(raw) as AttestationConfig) : null; + cached = raw + ? (JSON.parse(raw) as AttestationConfig) + : CHECKED_IN_CONFIG.trustedAttestors.length > 0 + ? CHECKED_IN_CONFIG + : null; } return cached; } From a917d50164fb238739e307049f0bc39919b3b7fc Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 14:18:36 +0000 Subject: [PATCH 28/54] demo: fix auditor mvr name (@demo/auditor-a, not invalid @demo/auditor_a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auditor's mvr name was the package name `auditor_a` verbatim → `@demo/auditor_a`, which is an invalid mvr name (underscores aren't allowed) so it failed resolution; and write-demo-env's mvrName had separately drifted to @auditor-a/audits. Both now derive @demo/auditor-a (sanitize _ -> -, kept in sync), so the UI's auditor click-through resolves. Also fix the README git_path for the demo auditor location (packages/ -> demo/). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 6 +++--- scripts/write-demo-env.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 58bae295..9ba1b900 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -111,7 +111,7 @@ async fn main() -> anyhow::Result<()> { let latest = a["latestId"].as_str().unwrap_or_default().to_string(); let mvr_name = auditor_mvr_name(pkg_name); let pkg_info_id = format!("0x{:064x}", 0xdee0_0010u64 + i as u64); - let git_path = format!("packages/{pkg_name}"); + let git_path = format!("demo/{pkg_name}"); seed( &mut db, &mvr_name, @@ -145,9 +145,9 @@ async fn main() -> anyhow::Result<()> { /// trust config in scripts/write-demo-env.sh). fn auditor_mvr_name(pkg_name: &str) -> String { match pkg_name { - "audit_example" => "@example-auditor/audits".to_string(), "vuln_example" => "@example-scanner/disclosures".to_string(), - other => format!("@demo/{other}"), + // MVR names can't contain `_`; the demo auditor package is `auditor_a`. + other => format!("@demo/{}", other.replace('_', "-")), } } diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index f5278f21..ffe1ba09 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -53,7 +53,7 @@ d = json.load(open(sys.argv[1])) ATTESTORS = { "auditor_a": { "name": "Auditor A", - "mvrName": "@auditor-a/audits", + "mvrName": "@demo/auditor-a", "iconUrl": "/demo-attestors/auditor.svg", }, "vuln_example": { From 539a222fa2ec07731745f2e3ad20ee077cf5cd0a Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 14:52:57 +0000 Subject: [PATCH 29/54] Frontend: client-side trust filtering; icons split; testnet gating; nav fix Per #243 review: - Client-side trust filtering: fetch all Attestation on the box (MoveModule filter) and filter by attestorFor + the Display-gate client-side, dropping the server-side MatchAny + the trusted-type enumeration round-trip (forward read). The Issued/reverse read is unchanged (it still needs the types for GraphQL). - Extract inline icons to app/src/icons/single-package/. - Hide the attestation UI on testnet pages. - Fix the double-click-to-attestors nav bug: TrustedAttestorBadge used a plain (hard nav); switched to next/link for single-click navigation. typecheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageHeader.tsx | 27 ++------- .../single-package/SinglePackageIssued.tsx | 6 +- .../SinglePackageTrustSignals.tsx | 44 +++------------ app/src/hooks/useGetAttestations.ts | 56 ++++++------------- app/src/icons/single-package/CheckIcon.tsx | 16 ++++++ .../icons/single-package/ShieldCheckIcon.tsx | 17 ++++++ app/src/icons/single-package/WarningIcon.tsx | 18 ++++++ 7 files changed, 85 insertions(+), 99 deletions(-) create mode 100644 app/src/icons/single-package/CheckIcon.tsx create mode 100644 app/src/icons/single-package/ShieldCheckIcon.tsx create mode 100644 app/src/icons/single-package/WarningIcon.tsx diff --git a/app/src/components/single-package/SinglePackageHeader.tsx b/app/src/components/single-package/SinglePackageHeader.tsx index dbd749c3..d817e8a3 100644 --- a/app/src/components/single-package/SinglePackageHeader.tsx +++ b/app/src/components/single-package/SinglePackageHeader.tsx @@ -1,33 +1,18 @@ +import Link from "next/link"; import { ResolvedName } from "@/hooks/mvrResolution"; import { Text } from "../ui/Text"; import ImageWithFallback from "../ui/image-with-fallback"; import { beautifySuiAddress } from "@/lib/utils"; import { CopyBtn } from "../ui/CopyBtn"; import { attestationConfig, isConfiguredAttestor } from "@/lib/attestations"; - -/** Shield-check glyph; color comes from the surrounding text color. */ -function ShieldCheckIcon({ className }: { className?: string }) { - return ( - - - - - ); -} +import { ShieldCheckIcon } from "@/icons/single-package/ShieldCheckIcon"; /** Pill marking a package as a trusted attestor in this consumer's trust - * config. Links to the full trusted-attestors list. */ + * config. Links to the full trusted-attestors list. Uses next/link so the + * first click navigates (a plain hard-navigates and was flaky here). */ function TrustedAttestorBadge() { return ( - @@ -35,7 +20,7 @@ function TrustedAttestorBadge() { MVR-trusted attestor - + ); } diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index d248e305..30c6e82c 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -18,7 +18,8 @@ export function IssuedCount({ }) { const { data } = useIssuedAttestations(address, network); const count = (data ?? []).length; - if (!count) return null; + // Attestations are a mainnet-only feature in the demo. + if (network === "testnet" || !count) return null; return (
@@ -37,6 +38,9 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { const { items: names } = useReverseResolution(subjects, network); const nameOf = (subject: string) => (names[subject] as { name?: string })?.name; + // Attestations are a mainnet-only feature in the demo. + if (network === "testnet") return null; + // Live attestations group at the top; revoked ones move to their own // section at the bottom so they don't read as endorsements. const liveGroups = groupBySubject(issued.filter((i) => !i.revoked)); diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 14b33505..50b2096a 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -10,42 +10,8 @@ import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; import ExplorerLink from "../ui/explorer-link"; import { type TrustedAttestor } from "@/lib/attestations"; - -/** A check glyph; color comes from the text color (currentColor). */ -function CheckIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -/** A triangle-exclamation glyph; color comes from the text color (currentColor). */ -function WarningIcon({ className }: { className?: string }) { - return ( - - - - - - ); -} +import { CheckIcon } from "@/icons/single-package/CheckIcon"; +import { WarningIcon } from "@/icons/single-package/WarningIcon"; /** Tab label: a pill with the count of live attestations. */ export function TrustSignalCount({ @@ -58,7 +24,8 @@ export function TrustSignalCount({ const { data } = useGetAttestations(address, network); const positives = (data ?? []).length; - if (!positives) return null; + // Attestations are a mainnet-only feature in the demo. + if (network === "testnet" || !positives) return null; return (
0; + // Attestations are a mainnet-only feature in the demo. + if (network === "testnet") return null; + return (
diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index e79d03c2..0316c2a7 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -28,14 +28,9 @@ export type DisplayedAttestation = AttributedAttestation; /** * Core read: the attestations about `subject` from the configured trusted - * attesters. Reads the per-subject Box directly over JSON-RPC (no MVR backend). - * - * Spam-resistant: rather than fetch every `Attestation<*>` on the box and filter - * client-side, it asks the node for only the exact trusted types via a `MatchAny` - * `StructType` filter — so attestations from untrusted attesters are never - * returned. The trusted type set is the `Attestation` for every store type `T` - * defined by a trusted attester's lineage (see `resolveTrustedTypes`). A - * read-time Display-gate still drops trusted-but-undisplayed types. + * attesters. Reads the per-subject Box directly over JSON-RPC (no MVR backend), + * then filters to trusted, displayed attestations client-side (see + * `fetchBoxAttestations`). */ export async function fetchTrustedAttestations( client: SuiClient, @@ -64,24 +59,27 @@ export async function fetchRevokedAttestations( /** * Trusted, displayed attestations owned by `boxAddr`, attributed to their - * attester. Shared by the active-box and revoked-box reads. Uses the gRPC - * `MatchAny` `StructType` filter so untrusted attesters are never fetched; a - * read-time Display-gate drops trusted-but-undisplayed types. + * attester. Shared by the active-box and revoked-box reads. Fetches every + * `Attestation` on the box (a `MoveModule` filter on the registry's + * `attestation_registry` module) and filters client-side: keep only those from a + * configured trusted attester (`attestorFor`) that carry a registered Display. + * Untrusted attesters can transfer junk into a box; we just drop it here. (A + * server-side trusted-type filter would avoid downloading that junk — a possible + * spam-resistance optimization if it ever matters.) */ async function fetchBoxAttestations( client: SuiClient, cfg: AttestationConfig, boxAddr: string, ): Promise { - const trustedTypes = await resolveTrustedTypes(client, cfg); - if (trustedTypes.length === 0) return []; - const infos: AttestationInfo[] = []; let cursor: string | null | undefined = null; do { const page = await client.getOwnedObjects({ owner: boxAddr, - filter: { MatchAny: trustedTypes.map((StructType) => ({ StructType })) }, + filter: { + MoveModule: { package: cfg.registryPkg, module: "attestation_registry" }, + }, options: { showType: true, showDisplay: true }, cursor, }); @@ -100,33 +98,11 @@ async function fetchBoxAttestations( ); } -// Cache the trusted type set per config — the lineage is static, so this only -// changes when an attester upgrades (re-load the app to refresh). -const trustedTypesCache = new Map>(); - -/** - * The exact set of trusted `Attestation` type strings: for every package in - * a trusted attester's lineage, every `store` struct it defines becomes a - * candidate `T`. Querying each lineage version covers types by their defining - * (canonical) id; non-canonical combinations simply match no objects. - */ -export function resolveTrustedTypes( - client: SuiClient, - cfg: AttestationConfig, -): Promise { - const lineage = cfg.trustedAttestors.flatMap((a) => a.lineage); - const key = `${cfg.registryPkg}|${[...new Set(lineage.map((id) => normalizeSuiAddress(id)))].join(",")}`; - const cached = trustedTypesCache.get(key); - if (cached) return cached; - const promise = enumerateAttestationTypes(client, lineage, cfg.registryPkg); - trustedTypesCache.set(key, promise); - return promise; -} - /** * The `Attestation` type strings for every `store` type `T` defined across - * the given package lineage. Querying each version covers types by their defining - * (canonical) id; non-canonical combinations match no objects. + * the given package lineage. Used by the reverse (Issued) read's per-type GraphQL + * query. Querying each version covers types by their defining (canonical) id; + * non-canonical combinations match no objects. */ export async function enumerateAttestationTypes( client: SuiClient, diff --git a/app/src/icons/single-package/CheckIcon.tsx b/app/src/icons/single-package/CheckIcon.tsx new file mode 100644 index 00000000..4e6193ac --- /dev/null +++ b/app/src/icons/single-package/CheckIcon.tsx @@ -0,0 +1,16 @@ +/** A check glyph; color comes from the text color (currentColor). */ +export function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/src/icons/single-package/ShieldCheckIcon.tsx b/app/src/icons/single-package/ShieldCheckIcon.tsx new file mode 100644 index 00000000..09d8c904 --- /dev/null +++ b/app/src/icons/single-package/ShieldCheckIcon.tsx @@ -0,0 +1,17 @@ +/** Shield-check glyph; color comes from the surrounding text color. */ +export function ShieldCheckIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/app/src/icons/single-package/WarningIcon.tsx b/app/src/icons/single-package/WarningIcon.tsx new file mode 100644 index 00000000..15f2a17c --- /dev/null +++ b/app/src/icons/single-package/WarningIcon.tsx @@ -0,0 +1,18 @@ +/** A triangle-exclamation glyph; color comes from the text color (currentColor). */ +export function WarningIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} From 7c5951b62cd59b25f940b77f7b3db4173a6bb3c0 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 17:14:04 +0000 Subject: [PATCH 30/54] Follow attestation_registry -> attestations module rename The base module renamed to `attestations`, so the on-chain type is now `attestations::attestations::Attestation`. Update the frontend's type parsing (ATTESTATION_RE), box-address derivation (BoxKey type string), the client-side MoveModule trust filter (module: "attestations"), the reverse-read type string, and the doc references (ATTESTATION-INTEGRATION, write-demo-env comment). typecheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- ATTESTATION-INTEGRATION.md | 4 ++-- app/src/hooks/useGetAttestations.ts | 6 +++--- app/src/lib/attestations.ts | 8 ++++---- scripts/write-demo-env.sh | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md index b1915fb7..b8286189 100644 --- a/ATTESTATION-INTEGRATION.md +++ b/ATTESTATION-INTEGRATION.md @@ -79,7 +79,7 @@ ports to MVR as-is. ### Section 1 — Demo environment (the simulated network) 1. `sui start --with-faucet --with-graphql` (localnet + faucet + GraphQL/indexer). -2. Publish on localnet: `attestation_registry` (creates the shared `Registry` +2. Publish on localnet: `attestations` (creates the shared `Registry` in `init`), `audit_example`/`vuln_example` attestors, and subject package(s). **Exercise evolution**: upgrade an attestor to add a second schema type (e.g. `AuditV2`) and register its Display, so both surface under one attester. @@ -113,7 +113,7 @@ New code in MVR (`app/src`): - **Trusted-type resolver** (cached per attester, GraphQL): 1. For each trusted `originalId`, get its lineage via `packageVersions`. 2. Enumerate `Attestation` types the lineage registered Displays for — - either `objects(filter:{type:"0x2::display_registry::Display<…attestation_registry::Attestation>"})` + either `objects(filter:{type:"0x2::display_registry::Display<…attestations::Attestation>"})` filtered to trusted lineages, or per-attester datatypes → derived `Display>` existence check. **Spike: confirm GraphQL generic type-filter matching; pick the mechanism.** diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 0316c2a7..9acc89e5 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -61,7 +61,7 @@ export async function fetchRevokedAttestations( * Trusted, displayed attestations owned by `boxAddr`, attributed to their * attester. Shared by the active-box and revoked-box reads. Fetches every * `Attestation` on the box (a `MoveModule` filter on the registry's - * `attestation_registry` module) and filters client-side: keep only those from a + * `attestations` module) and filters client-side: keep only those from a * configured trusted attester (`attestorFor`) that carry a registered Display. * Untrusted attesters can transfer junk into a box; we just drop it here. (A * server-side trusted-type filter would avoid downloading that junk — a possible @@ -78,7 +78,7 @@ async function fetchBoxAttestations( const page = await client.getOwnedObjects({ owner: boxAddr, filter: { - MoveModule: { package: cfg.registryPkg, module: "attestation_registry" }, + MoveModule: { package: cfg.registryPkg, module: "attestations" }, }, options: { showType: true, showDisplay: true }, cursor, @@ -117,7 +117,7 @@ export async function enumerateAttestationTypes( for (const [structName, struct] of Object.entries(mod.structs ?? {})) { if (struct.abilities.abilities.includes("Store")) { types.add( - `${registryPkg}::attestation_registry::Attestation<${pkg}::${moduleName}::${structName}>`, + `${registryPkg}::attestations::Attestation<${pkg}::${moduleName}::${structName}>`, ); } } diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index 35cf7387..c931f31f 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -32,7 +32,7 @@ export interface TrustedAttestor { } export interface AttestationConfig { - /** attestation_registry package id (the `Attestation<>` wrapper type). */ + /** attestations package id (the `Attestation<>` wrapper type). */ registryPkg: SuiAddress; /** The shared Registry object id — parent for per-subject Box derivation. */ registryId: ObjectId; @@ -89,7 +89,7 @@ function derivedBox( }).toBytes(); return deriveObjectID( registryId, - `${registryPkg}::attestation_registry::BoxKey`, + `${registryPkg}::attestations::BoxKey`, keyBytes, ); } @@ -122,13 +122,13 @@ export function revokedBoxAddress( export interface AttestationInfo { id: ObjectId; /** The inner type `T`, e.g. `0xAUD::audit::Audit`. The full object type is - * always `${registryPkg}::attestation_registry::Attestation<${innerType}>`. */ + * always `${registryPkg}::attestations::Attestation<${innerType}>`. */ innerType: string; /** Server-rendered Display v2 fields (all values are strings). */ display: Record; } -const ATTESTATION_RE = /::attestation_registry::Attestation<(.+)>$/; +const ATTESTATION_RE = /::attestations::Attestation<(.+)>$/; /** * Map a `getOwnedObjects`/`getObject` response into `AttestationInfo`, or null diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index ffe1ba09..9ca3b07f 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -6,7 +6,7 @@ # demo-ids.json (produced by the attestation-registry repo's run-demo.sh): # { # "registryId": "0x…", // the shared Registry object id -# "attestationRegistryPkg": "0x…", // the attestation_registry package id +# "attestationRegistryPkg": "0x…", // the attestations package id # "subjects": { "subject": "0x…", "dependency": "0x…" }, # "trustedAttestors": [ # { "name": "auditor_a", "originalId": "0x…", "latestId": "0x…" } From 4d7c85253e946da5020376172b4b22c533683a76 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 19:35:37 +0000 Subject: [PATCH 31/54] Remove demo attester icons from the app; source them from the registry repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo's attester brand icons were the last demo-only asset co-mingled in the mvr app (app/public/demo-attestors/*.svg) — dead weight in production (the checked-in trust config is empty) and conceptually misplaced. Each icon now lives with its attester package in the attestation-registry repo (beside the README the mvr demo already renders), and write-demo-env points iconUrl at the GitHub raw URL. So no demo asset ships in the app, and iconUrl is a real external URL — which models the production shape (a consumer's trust config points at an attester's hosted icon). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/public/demo-attestors/auditor.svg | 6 ------ app/public/demo-attestors/scanner.svg | 7 ------- scripts/write-demo-env.sh | 15 ++++++++++----- 3 files changed, 10 insertions(+), 18 deletions(-) delete mode 100644 app/public/demo-attestors/auditor.svg delete mode 100644 app/public/demo-attestors/scanner.svg diff --git a/app/public/demo-attestors/auditor.svg b/app/public/demo-attestors/auditor.svg deleted file mode 100644 index 79d0bd70..00000000 --- a/app/public/demo-attestors/auditor.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/public/demo-attestors/scanner.svg b/app/public/demo-attestors/scanner.svg deleted file mode 100644 index d100645b..00000000 --- a/app/public/demo-attestors/scanner.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 9ca3b07f..a5540cc5 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -47,19 +47,24 @@ CONFIG=$(python3 - "$DEMO_IDS" <<'PY' import json, sys d = json.load(open(sys.argv[1])) # Per-attester presentation metadata, keyed by the demo-ids `name`. Presentation -# lives in the consumer's trust config, never on-chain. `iconUrl` is served from -# app/public; `mvrName` is kept in sync with demo_server.rs. An attester absent -# from this map falls back to its raw name with no icon/mvrName. +# lives in the consumer's trust config, never on-chain. `iconUrl` points at the +# attester's brand icon hosted with its package in the attestation-registry repo +# (the same place its README — shown on the mvr page — lives), so no demo asset +# ships in the mvr app. `mvrName` is kept in sync with demo_server.rs. An +# attester absent from this map falls back to its raw name with no icon/mvrName. +RAW = "https://raw.githubusercontent.com/mdgeorge4153/sui-attestation-registry/mdgeorge/attest-positive" ATTESTORS = { "auditor_a": { "name": "Auditor A", "mvrName": "@demo/auditor-a", - "iconUrl": "/demo-attestors/auditor.svg", + "iconUrl": f"{RAW}/demo/auditor_a/icon.svg", }, + # PR-B attester; its icon lands at demo/vuln_reporter_a/icon.svg when that + # package is added. Inert here (not in PR A's trusted set). "vuln_example": { "name": "Example Security Scanner", "mvrName": "@example-scanner/disclosures", - "iconUrl": "/demo-attestors/scanner.svg", + "iconUrl": f"{RAW}/demo/vuln_reporter_a/icon.svg", }, } attestors = [] From a2deb3cf069ca8f72d9def4a1834643cedd63ec0 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 19:39:01 +0000 Subject: [PATCH 32/54] Add an example trust config so reviewers can see its shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHECKED_IN_CONFIG ships empty (feature dormant), so there's no populated example of an AttestationConfig in the tree. Add attestation-config.example.ts: a typed (so it can't drift from the interface), fully-commented config with two example attesters — showing the production CHECKED_IN_CONFIG shape, which is also exactly what NEXT_PUBLIC_ATTESTATION_CONFIG holds as JSON. Point CHECKED_IN_CONFIG's doc comment at it. Not imported anywhere (reference only). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/lib/attestation-config.example.ts | 52 +++++++++++++++++++++++ app/src/lib/attestations.ts | 2 + 2 files changed, 54 insertions(+) create mode 100644 app/src/lib/attestation-config.example.ts diff --git a/app/src/lib/attestation-config.example.ts b/app/src/lib/attestation-config.example.ts new file mode 100644 index 00000000..a2d7378c --- /dev/null +++ b/app/src/lib/attestation-config.example.ts @@ -0,0 +1,52 @@ +// Example trust config — what a populated production `CHECKED_IN_CONFIG` +// (`app/src/lib/attestations.ts`) looks like once attesters are onboarded. +// +// This is a reviewer-facing reference, not wired into the app: the production +// list lives in `CHECKED_IN_CONFIG` and the demo injects the same shape as JSON +// via `NEXT_PUBLIC_ATTESTATION_CONFIG` (generated by `scripts/write-demo-env.sh`). +// Serialize this object to JSON and you have exactly the env-var form. +// +// All ids below are illustrative placeholders — replace with real on-chain ids. +// Trust is a *consumer* decision keyed by package address: listing an attester +// here means "I trust attestations whose inner type is defined by this package." + +import type { AttestationConfig } from "./attestations"; + +export const EXAMPLE_ATTESTATION_CONFIG: AttestationConfig = { + // The `attestations` base-registry package id — the `Attestation<>` wrapper's + // defining package (used to match the on-chain object type). + registryPkg: "0x1111111111111111111111111111111111111111111111111111111111111111", + // The shared Registry object id — the parent for per-subject Box derivation. + registryId: "0x2222222222222222222222222222222222222222222222222222222222222222", + + trustedAttestors: [ + { + // Shown as the attester heading in the UI. + name: "Example Auditor", + // Brand icon, hosted by the attester (never read from on-chain data). + // Absent → the UI renders an initials avatar instead. + iconUrl: "https://example-auditor.com/icon.svg", + // Optional: links the attester heading to its MVR package page. + mvrName: "@example-auditor/audits", + // The attester package's *original* publish id — the trust anchor. + originalId: "0xaaaa000000000000000000000000000000000000000000000000000000000000", + // Every version id in the attester's lineage (original publish + each + // upgrade). An attestation counts as from this attester when its inner + // type's defining package is in this set — so attestation types added in + // later upgrades stay trusted automatically. + lineage: [ + "0xaaaa000000000000000000000000000000000000000000000000000000000000", + "0xaaaa111111111111111111111111111111111111111111111111111111111111", + ], + }, + { + // A second attester with no icon (renders an initials avatar) and no MVR + // page, and a single-version lineage (never upgraded). + name: "Example Security Scanner", + originalId: "0xbbbb000000000000000000000000000000000000000000000000000000000000", + lineage: [ + "0xbbbb000000000000000000000000000000000000000000000000000000000000", + ], + }, + ], +}; diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts index c931f31f..6f3c30df 100644 --- a/app/src/lib/attestations.ts +++ b/app/src/lib/attestations.ts @@ -45,6 +45,8 @@ export interface AttestationConfig { * feature stays dormant until attesters are onboarded here. The demo overrides * this via `NEXT_PUBLIC_ATTESTATION_CONFIG` (written by `write-demo-env.sh`), the * same way `NEXT_PUBLIC_LOCAL_*` overrides the endpoints. + * + * See `attestation-config.example.ts` for a populated example of this shape. */ const CHECKED_IN_CONFIG: AttestationConfig = { registryPkg: "", From 07430fdf3867ee73a0f5220554f0b03e651d8545 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 19:44:20 +0000 Subject: [PATCH 33/54] demo_server: fetch demo READMEs from the branch, not the stale mvr-demo tag The demo seeded git_info.tag = "mvr-demo", a tag cut before the demo reorg (auditor was at packages/audit_example/ then). After the move to demo/auditor_a the README path 404s at that tag, so @demo/auditor-a showed no README. Point at the PR branch instead (matching the attester icon URLs in write-demo-env), so the READMEs track the deployed sources and don't go stale on restructure. Rename the constant DEMO_GIT_TAG -> DEMO_GIT_REF to reflect it's a branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 9ba1b900..6acd5c51 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -30,9 +30,11 @@ use mvr_schema::{ MIGRATIONS, }; -// Where the auditor package READMEs live, for MVR's git-backed README fetch. +// Where the demo package READMEs live, for MVR's git-backed README fetch. We +// point at the PR branch (not a tag) so the READMEs always track the deployed +// demo sources; a pinned tag goes stale whenever the demo is restructured. const DEMO_REPO_URL: &str = "https://github.com/mdgeorge4153/sui-attestation-registry"; -const DEMO_GIT_TAG: &str = "mvr-demo"; +const DEMO_GIT_REF: &str = "mdgeorge/attest-positive"; use serde_json::json; use sui_pg_db::{temp::TempDb, Db, DbArgs}; use tokio_util::sync::CancellationToken; @@ -215,7 +217,7 @@ async fn seed( chain_id: "localnet".to_string(), repository: Some(DEMO_REPO_URL.to_string()), path: Some(path.to_string()), - tag: Some(DEMO_GIT_TAG.to_string()), + tag: Some(DEMO_GIT_REF.to_string()), }]) .execute(&mut *conn) .await?; From 4a8c2e2d0a572f5a80b8bdde8dec95c8361606dc Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 20:02:17 +0000 Subject: [PATCH 34/54] Version selector for attestations; seed the demo dependency as multi-version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo_server: seed @demo/dependency as a multi-version package (one packages row per version sharing original_id, driven by demo-ids dependencyVersions), so @demo/dependency/1 and /2 resolve — mirroring how resolution_loader joins on original_id. seed() now delegates to the new seed_versions() helper. SinglePackageTrustSignals: a version dropdown (Radix Select) in the Security header, shown only when the package has >1 version. Attestations are per package version, so it re-queries useGetAttestations for the selected version's address (via useGetMvrVersionAddresses), defaulting to the resolved version and re-syncing on navigation. The unaudited dependency v2 shows the existing "may not have been audited" warning. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 62 +++++++++++- crates/mvr-api/examples/demo_server.rs | 99 +++++++++++++++---- 2 files changed, 137 insertions(+), 24 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 50b2096a..95d4edd7 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; import { @@ -6,12 +7,20 @@ import { type AttributedAttestation, type DisplayedAttestation, } from "@/hooks/useGetAttestations"; +import { useGetMvrVersionAddresses } from "@/hooks/useGetMvrVersionAddresses"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; import ExplorerLink from "../ui/explorer-link"; import { type TrustedAttestor } from "@/lib/attestations"; import { CheckIcon } from "@/icons/single-package/CheckIcon"; import { WarningIcon } from "@/icons/single-package/WarningIcon"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; /** Tab label: a pill with the count of live attestations. */ export function TrustSignalCount({ @@ -51,8 +60,24 @@ function CountPill({ icon, count }: { icon: React.ReactNode; count: number }) { export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; - const { data, isLoading } = useGetAttestations(name.package_address, network); - const { data: revokedData } = useGetRevokedAttestations(name.package_address, network); + // Attestations are per package version (the subject is a version's package + // id), so let the viewer pick which version's attestations to inspect. + // Defaults to the resolved version; the selector only appears when the + // package has more than one version. + const { data: versions } = useGetMvrVersionAddresses( + name.name, + name.version, + network, + ); + const [selectedAddress, setSelectedAddress] = useState(name.package_address); + // Re-sync to the resolved version when the page navigates to a different + // package/version; picking a version in the dropdown changes selectedAddress + // but not name.package_address, so it doesn't fight the user's selection. + useEffect(() => { + setSelectedAddress(name.package_address); + }, [name.package_address]); + const { data, isLoading } = useGetAttestations(selectedAddress, network); + const { data: revokedData } = useGetRevokedAttestations(selectedAddress, network); const revoked = revokedData ?? []; const positives = data ?? []; @@ -61,11 +86,38 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { // Attestations are a mainnet-only feature in the demo. if (network === "testnet") return null; + const versionList = versions ?? []; + const selectedVersion = + versionList.find((v) => v.address === selectedAddress)?.version ?? + name.version; + return (
- -

Security

-
+
+ +

Security

+
+ {versionList.length > 1 && ( + + )} +
{isLoading && ( diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 6acd5c51..67e88b95 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -63,6 +63,9 @@ async fn main() -> anyhow::Result<()> { let ids: serde_json::Value = serde_json::from_slice(&std::fs::read(&args.demo_ids)?)?; let subject = field(&ids, "subject"); let dependency = field(&ids, "dependency"); + // The dependency is published in two versions (demo of the per-version + // attestation selector); seed all of them under one MVR name. + let dependency_versions = dependency_versions(&ids); // Ephemeral Postgres, alive for the lifetime of this process. let temp_db = TempDb::new()?; @@ -80,13 +83,13 @@ async fn main() -> anyhow::Result<()> { None, ) .await?; - seed( + seed_versions( &mut db, "@demo/dependency", - &dependency, + &dependency_versions, PKG_INFO_DEPENDENCY, "A dependency of @demo/subject.", - None, + Some("demo/dependency_example"), ) .await?; @@ -128,7 +131,15 @@ async fn main() -> anyhow::Result<()> { } println!("seeded @demo/subject -> {subject}"); - println!("seeded @demo/dependency -> {dependency}"); + println!( + "seeded @demo/dependency -> {} version(s): {}", + dependency_versions.len(), + dependency_versions + .iter() + .map(|(addr, v)| format!("v{v}={addr}")) + .collect::>() + .join(", ") + ); println!("seeded dependency edge {subject} -> {dependency}"); println!("point the frontend's mainnet mvrEndpoint at http://127.0.0.1:{}", args.port); @@ -161,8 +172,28 @@ fn field(ids: &serde_json::Value, key: &str) -> String { .to_string() } -/// Insert the `packages` / `package_infos` / `name_records` rows that make -/// `name` resolve to `package_id` (version 1, not upgraded) on mainnet. +/// Read the top-level `dependencyVersions` array as `(package_id, version)` +/// pairs, sorted ascending by version so `[0]` is v1 (the original id). +fn dependency_versions(ids: &serde_json::Value) -> Vec<(String, i64)> { + let mut versions: Vec<(String, i64)> = ids["dependencyVersions"] + .as_array() + .unwrap_or_else(|| panic!("demo-ids.json missing dependencyVersions")) + .iter() + .map(|v| { + let addr = v["address"] + .as_str() + .expect("dependencyVersions[].address") + .to_string(); + let version = v["version"].as_i64().expect("dependencyVersions[].version"); + (addr, version) + }) + .collect(); + versions.sort_by_key(|(_, v)| *v); + versions +} + +/// Insert the rows that make `name` resolve to `package_id` (single version, +/// not upgraded) on mainnet. async fn seed( db: &mut Db, name: &str, @@ -171,21 +202,51 @@ async fn seed( description: &str, git_path: Option<&str>, ) -> anyhow::Result<()> { - let package = Package { - package_id: package_id.to_string(), - original_id: package_id.to_string(), - package_version: 1, - move_package: vec![], - chain_id: "localnet".to_string(), - tx_hash: String::new(), - sender: String::new(), - timestamp: NaiveDateTime::MAX, - deps: vec![], - }; + seed_versions( + db, + name, + &[(package_id.to_string(), 1)], + pkg_info_id, + description, + git_path, + ) + .await +} + +/// Insert the `packages` / `package_infos` / `name_records` rows for a package +/// published in one or more versions. `versions` is `(package_id, version)` +/// sorted ascending; `[0]` is the original (v1). mvr resolution models an +/// upgraded package as multiple `packages` rows sharing one `original_id`, so +/// we seed one row per version (all under that original) plus a single +/// `package_info`/`name_record`/`git_info` — then `@name/N` resolves to the row +/// whose `package_version == N`, and bare `@name` to the latest. +async fn seed_versions( + db: &mut Db, + name: &str, + versions: &[(String, i64)], + pkg_info_id: &str, + description: &str, + git_path: Option<&str>, +) -> anyhow::Result<()> { + let original_id = &versions[0].0; + let packages: Vec = versions + .iter() + .map(|(package_id, version)| Package { + package_id: package_id.to_string(), + original_id: original_id.to_string(), + package_version: *version, + move_package: vec![], + chain_id: "localnet".to_string(), + tx_hash: String::new(), + sender: String::new(), + timestamp: NaiveDateTime::MAX, + deps: vec![], + }) + .collect(); let package_info = PackageInfo { id: pkg_info_id.to_string(), object_version: 0, - package_id: package_id.to_string(), + package_id: original_id.to_string(), git_table_id: if git_path.is_some() { pkg_info_id.to_string() } else { @@ -204,7 +265,7 @@ async fn seed( }; let mut conn = db.connect().await?; - insert_into(packages::table).values(vec![package]).execute(&mut *conn).await?; + insert_into(packages::table).values(packages).execute(&mut *conn).await?; insert_into(package_infos::table).values(vec![package_info]).execute(&mut *conn).await?; insert_into(name_records::table).values(vec![name_record]).execute(&mut *conn).await?; if let Some(path) = git_path { From 73b8c8427d4c44b31cc82a8981e19013e061dee4 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 20:06:56 +0000 Subject: [PATCH 35/54] Warning copy: 'This package version has no published audits' Attestations are per package version (and the version selector now makes that explicit), so the no-attestations warning should say 'version', not 'package'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/single-package/SinglePackageTrustSignals.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 95d4edd7..00b55f1f 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -127,8 +127,7 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) {
- This package has no active attestations published on MVR — it may - not have been audited. + This package version has no published audits.
)} From 5bd18533ba24d6d7079d204f2207c95a3d9064ec Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 20:27:28 +0000 Subject: [PATCH 36/54] UI review: focus the Security view; make the Issued list scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (one package, few audits): flatten the per-attester grouping into one focused card per audit — image_url badge (falls back to the attester avatar on load failure, so the placeholder never shows broken), verdict-first headline + score pill, with the inner type / object id demoted to a faint trailing line. Revoked collapsed into an unobtrusive "N revoked"
. Issued (one attester, potentially many): replace card-per-subject with a dense divide-y list sorted by publish_date (newest first), a "N attestations about M packages · A active · R revoked" summary, and pagination (20/page). Reverse- resolve only the visible rows' subjects so the lookup fan-out tracks what's shown. Revoked stay inline but de-emphasized (the attester's revocation pattern is a signal here). Internal links use next/link. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 183 ++++++++++------ .../SinglePackageTrustSignals.tsx | 206 +++++++++--------- 2 files changed, 208 insertions(+), 181 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 30c6e82c..6215780b 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import Link from "next/link"; import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; import { @@ -8,6 +10,8 @@ import { useReverseResolution } from "@/hooks/useReverseResolution"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; +const PAGE_SIZE = 20; + /** Tab label: count of attestations this package has issued. */ export function IssuedCount({ address, @@ -32,19 +36,25 @@ export function IssuedCount({ export function SinglePackageIssued({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { data, isLoading } = useIssuedAttestations(name.package_address, network); - const issued = data ?? []; + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); - const subjects = [...new Set(issued.map((i) => i.subject))]; - const { items: names } = useReverseResolution(subjects, network); + const issued = data ?? []; + // Newest first; attestations without a publish_date sort last. + const sorted = [...issued].sort((a, b) => publishMs(b) - publishMs(a)); + const visible = sorted.slice(0, visibleCount); + + // Resolve names only for the subjects currently on screen, so the lookup + // fan-out grows with what's shown rather than the whole (possibly large) list. + const visibleSubjects = [...new Set(visible.map((i) => i.subject))]; + const { items: names } = useReverseResolution(visibleSubjects, network); const nameOf = (subject: string) => (names[subject] as { name?: string })?.name; // Attestations are a mainnet-only feature in the demo. if (network === "testnet") return null; - // Live attestations group at the top; revoked ones move to their own - // section at the bottom so they don't read as endorsements. - const liveGroups = groupBySubject(issued.filter((i) => !i.revoked)); - const revokedGroups = groupBySubject(issued.filter((i) => i.revoked)); + const subjectCount = new Set(issued.map((i) => i.subject)).size; + const revokedCount = issued.filter((i) => i.revoked).length; + const activeCount = issued.length - revokedCount; return (
@@ -58,10 +68,22 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { size="paragraph-small" className="text-content-secondary" > - On-chain claims this package has signed about other packages — audits, - vulnerability disclosures, and the like. Each one also appears on the - subject package's Security tab. + On-chain claims this package has signed about other packages. Each one + also appears on the subject package's Security tab. + {issued.length > 0 && ( + + {issued.length} attestation{issued.length === 1 ? "" : "s"} about{" "} + {subjectCount} package{subjectCount === 1 ? "" : "s"} · {activeCount}{" "} + active + {revokedCount > 0 && <> · {revokedCount} revoked} + + )}
{isLoading && ( @@ -74,81 +96,71 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { )} - {liveGroups.map((g) => ( - - ))} - - {revokedGroups.length > 0 && ( -
- -

Revoked

-
- {revokedGroups.map((g) => ( - + {visible.length > 0 && ( +
+ {visible.map((item) => ( + ))}
)} -
- ); -} -/** Group issued attestations by the subject they're about. */ -function groupBySubject( - items: IssuedAttestation[], -): { subject: string; items: IssuedAttestation[] }[] { - const groups: { subject: string; items: IssuedAttestation[] }[] = []; - for (const item of items) { - let g = groups.find((x) => x.subject === item.subject); - if (!g) { - g = { subject: item.subject, items: [] }; - groups.push(g); - } - g.items.push(item); - } - return groups; -} - -/** A card of the attestations one package issued about a single subject. */ -function SubjectGroup({ - group, - name, -}: { - group: { subject: string; items: IssuedAttestation[] }; - name?: string; -}) { - return ( -
- - about - - {group.items.map((item) => ( - - ))} + {visibleCount < sorted.length && ( + + )}
); } -function IssuedRow({ item }: { item: IssuedAttestation }) { +/** One dense row: the subject (linked), the attestation kind, its date, and a + * revoked tag. Revoked rows stay visible — on an attester you want to see how + * many they pull — but are de-emphasized. */ +function IssuedRow({ item, name }: { item: IssuedAttestation; name?: string }) { const { display, innerType } = item.info; - const title = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; - const live = !item.revoked; + const date = formatDate(display["publish_date"]); return (
-
- - {title} - {item.revoked && ( - (revoked) - )} +
+ + + + + {moduleAndType(innerType)} + {item.revoked && ( + + + revoked + + + )}
- - {innerType.split("::").slice(1).join("::")} - + {date && ( + + {date} + + )}
); } @@ -157,9 +169,9 @@ function IssuedRow({ item }: { item: IssuedAttestation }) { function SubjectLink({ id, name }: { id: string; name?: string }) { if (name) { return ( - + {name} - + ); } return ( @@ -169,6 +181,33 @@ function SubjectLink({ id, name }: { id: string; name?: string }) { ); } +/** Epoch ms of an attestation's publish_date, or -Infinity when absent (so it + * sorts to the end of a newest-first list). */ +function publishMs(item: IssuedAttestation): number { + const s = str(item.info.display["publish_date"]); + const t = s ? Date.parse(s) : NaN; + return Number.isNaN(t) ? -Infinity : t; +} + +/** A publish_date rendered as a short date, or "" when absent/unparseable. */ +function formatDate(v: unknown): string { + const s = str(v); + if (!s) return ""; + const t = Date.parse(s); + if (Number.isNaN(t)) return ""; + return new Date(t).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +/** The `module::Type` part of an inner type, dropping the package address. */ +function moduleAndType(innerType: string): string { + const parts = innerType.split("::"); + return parts.length > 1 ? parts.slice(1).join("::") : innerType; +} + function str(v: unknown): string | undefined { return typeof v === "string" && v.length > 0 ? v : undefined; } diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 00b55f1f..a41d2a33 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import Link from "next/link"; import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; import { @@ -132,82 +133,114 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) {
)} - {positives.length > 0 && } - - {revoked.length > 0 && ( -
- -
+ {positives.length > 0 && ( +
+ {positives.map((item) => ( + + ))} +
)} + + {revoked.length > 0 && }
); } -/** Audits (positive attestations), grouped by attester. */ -function AuditsSection({ items }: { items: DisplayedAttestation[] }) { - const groups = groupByAttestor(items); - return ( -
-
- - - Audits - -
- {groups.map((g) => ( - - ))} -
- ); -} +/** One focused card per audit: a badge, the verdict headline (and score), the + * attester, and — demoted — the type and object link. Optimized for the common + * case of one audit per attester, so there is no per-attester sub-grouping. */ +function AuditCard({ item }: { item: DisplayedAttestation }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { display, innerType, id } = item.info; + const headline = + str(display["description"]) ?? str(display["name"]) ?? "Attestation"; + const score = str(display["score"]); + const link = httpsLink(display["link"]); -/** Effective attestations from one trusted attester. */ -function AttestorGroupCard({ group }: { group: AttestorGroup }) { return ( -
-
- -
+
+ +
+ {/* Verdict — the line the eye should land on first. */} +
- {group.attestor.name} + {headline} - {group.attestor.mvrName ? ( - - {mvrLink(group.attestor.mvrName)} - + {score && {score}} +
+ + {/* Attester identity + report link. */} + + by {item.attestor.mvrName ? ( + mvrLink(item.attestor.mvrName, item.attestor.name) ) : ( - - {truncateId(group.attestor.originalId)} - + {item.attestor.name} )} -
+ {link && <> · {reportLink(link)}} + + + {/* Developer metadata, de-emphasized at the end. */} + + {moduleAndType(innerType)} ·{" "} + {objectLink(id, network)} +
- {group.items.map((item) => ( - - ))}
); } -/** A compact, de-emphasized list — `label` is "Revoked". Items are attributed - * attestations. */ -function InactiveList({ - label, - items, -}: { - label: string; - items: AttributedAttestation[]; -}) { +/** The audit's `image_url` as a badge, falling back to the attester avatar if + * it is absent or fails to load (so a broken/placeholder URL never shows a + * broken-image icon). */ +function AuditBadge({ item }: { item: DisplayedAttestation }) { + const [broken, setBroken] = useState(false); + const src = httpsLink(item.info.display["image_url"]); + if (!src || broken) { + return ; + } + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + setBroken(true)} + /> + + ); +} + +/** A small score pill (e.g. "95/100"), reusing the count-chip styling. */ +function ScorePill({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} + +
+ ); +} + +/** Revoked attestations, minimized: on a package the viewer rarely cares about + * them, so they collapse to a small "N revoked" line that expands on demand. */ +function RevokedSummary({ items }: { items: AttributedAttestation[] }) { return ( - - {label}:{" "} - {items.map((it, i) => ( - - {i > 0 ? ", " : ""} - {it.attestor.name} ({str(it.info.display["name"]) ?? "Attestation"}) - - ))} - +
+ + + {items.length} revoked + + + + {items.map((it, i) => ( + + {i > 0 ? ", " : ""} + {it.attestor.name} ({str(it.info.display["name"]) ?? "Attestation"}) + + ))} + +
); } @@ -241,27 +274,6 @@ export function AttesterAvatar({ ); } -/** A positive attestation row (audits). */ -function AttestationRow({ item }: { item: DisplayedAttestation }) { - const network = usePackagesNetwork() as "mainnet" | "testnet"; - const { display, innerType, id } = item.info; - const headline = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; - const link = httpsLink(display["link"]); - - return ( -
- - {headline} - {link && <> ({reportLink(link)})} - - - {moduleAndType(innerType)} ( - {objectLink(id, network)}) - -
- ); -} - /** The attestation object id, truncated and linked to an explorer. */ function objectLink(id: string, network: "mainnet" | "testnet"): React.ReactNode { return ( @@ -285,38 +297,15 @@ function reportLink(url: string): React.ReactNode { ); } -/** Link to a dependency's MVR page by name, or show its id when unresolved - * (the package route resolves by name, so a raw id isn't linkable). */ -/** Render an MVR name as a link to its package page. */ -function mvrLink(name: string): React.ReactNode { +/** Render an MVR name as an internal link to its package page. */ +function mvrLink(name: string, label?: string): React.ReactNode { return ( - - {name} - + + {label ?? name} + ); } -interface AttestorGroup { - attestor: TrustedAttestor; - items: DisplayedAttestation[]; -} - -/** Group attestations by their trusted attester, preserving discovery order. */ -function groupByAttestor(items: DisplayedAttestation[]): AttestorGroup[] { - const groups: AttestorGroup[] = []; - for (const item of items) { - let group = groups.find( - (g) => g.attestor.originalId === item.attestor.originalId, - ); - if (!group) { - group = { attestor: item.attestor, items: [] }; - groups.push(group); - } - group.items.push(item); - } - return groups; -} - const AVATAR_COLORS = ["bg-pastel-blue", "bg-pastel-green", "bg-pastel-purple", "bg-pastel-orange"]; /** Deterministic avatar background from the attester name. */ @@ -333,7 +322,6 @@ function initials(name: string): string { return letters.toUpperCase(); } -/** CVSS score for sorting; unscored attestations sort last. */ function truncateId(id: string): string { return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id; } From 9faacf8d682a2d30030beadd5b64878d72b8e79c Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 25 Jun 2026 23:22:07 +0000 Subject: [PATCH 37/54] Surface reverse-read GraphQL errors instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Issued reverse read did `res.data?.objects?.nodes ?? []`, so a failed GraphQL query (e.g. the localnet's "Request is outside consistent range" when its consistent store lags) rendered as an empty "this package hasn't issued any attestations" — indistinguishable from a genuinely-empty result, and a real debugging trap. Throw on `res.errors` so it propagates to React Query, and give the Issued tab an explicit error state showing the message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 14 ++++++++++++-- app/src/hooks/useGetAttestations.ts | 7 +++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 6215780b..319f81a3 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -9,6 +9,7 @@ import { import { useReverseResolution } from "@/hooks/useReverseResolution"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; +import { WarningIcon } from "@/icons/single-package/WarningIcon"; const PAGE_SIZE = 20; @@ -35,7 +36,7 @@ export function IssuedCount({ export function SinglePackageIssued({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; - const { data, isLoading } = useIssuedAttestations(name.package_address, network); + const { data, isLoading, error } = useIssuedAttestations(name.package_address, network); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const issued = data ?? []; @@ -90,7 +91,16 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { )} - {!isLoading && issued.length === 0 && ( + {error && ( +
+ + + Couldn't load issued attestations: {error.message} + +
+ )} + + {!isLoading && !error && issued.length === 0 && ( This package hasn't issued any attestations. diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 9acc89e5..818e790a 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -209,6 +209,13 @@ export function useIssuedAttestations( }[]; }; }>({ query: ISSUED_QUERY, variables: { type } }); + // Surface GraphQL errors instead of treating a failed query as "no + // results" — e.g. the localnet GraphQL's "Request is outside consistent + // range" when its consistent store lags. Swallowing it renders a + // failure as an empty Issued tab, which is misleading. + if (res.errors?.length) { + throw new Error(`GraphQL query failed: ${res.errors[0]?.message}`); + } for (const node of res.data?.objects?.nodes ?? []) { const subject = node.asMoveObject?.contents?.json?.subject; if (node.address && subject) subjectById.set(node.address, subject); From b6fc9f0303e9ccce60f0d37728501406f4944f02 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 00:04:25 +0000 Subject: [PATCH 38/54] Surface forward-read errors on the Security tab too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same swallow as the Issued tab: SinglePackageTrustSignals only checked isLoading, so a failed forward read would render as "This package version has no published audits" — a failure disguised as a clean negative. Surface the query error explicitly, matching the Issued tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageTrustSignals.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index a41d2a33..e54ec6ed 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -77,7 +77,7 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { useEffect(() => { setSelectedAddress(name.package_address); }, [name.package_address]); - const { data, isLoading } = useGetAttestations(selectedAddress, network); + const { data, isLoading, error } = useGetAttestations(selectedAddress, network); const { data: revokedData } = useGetRevokedAttestations(selectedAddress, network); const revoked = revokedData ?? []; @@ -124,7 +124,16 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { )} - {!isLoading && !hasLiveAttestation && ( + {error && ( +
+ + + Couldn't load attestations: {error.message} + +
+ )} + + {!isLoading && !error && !hasLiveAttestation && (
From 46d2b2b845044edc3eaf8b5b7ab746fa61a5ddf8 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 00:15:57 +0000 Subject: [PATCH 39/54] Issued tab: surface per-object failures, split revoked, show descriptions - Reverse read returns { items, failures }: the per-object getObject re-read now counts rejections instead of silently dropping them, and the Issued tab shows "N attestations couldn't be loaded" while still rendering the rest. - Revoked moved out of the inline list into their own de-emphasized section (each section independently paginated), so endorsements read distinctly from withdrawn ones. - Each row now leads with the attestation's description (then module::Type + date as secondary metadata). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageIssued.tsx | 170 +++++++++++++----- app/src/hooks/useGetAttestations.ts | 27 ++- 2 files changed, 139 insertions(+), 58 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 319f81a3..5b6e1571 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -22,7 +22,7 @@ export function IssuedCount({ network: "mainnet" | "testnet"; }) { const { data } = useIssuedAttestations(address, network); - const count = (data ?? []).length; + const count = data?.items.length ?? 0; // Attestations are a mainnet-only feature in the demo. if (network === "testnet" || !count) return null; return ( @@ -37,16 +37,26 @@ export function IssuedCount({ export function SinglePackageIssued({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { data, isLoading, error } = useIssuedAttestations(name.package_address, network); - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [liveCount, setLiveCount] = useState(PAGE_SIZE); + const [revokedShown, setRevokedShown] = useState(PAGE_SIZE); - const issued = data ?? []; - // Newest first; attestations without a publish_date sort last. - const sorted = [...issued].sort((a, b) => publishMs(b) - publishMs(a)); - const visible = sorted.slice(0, visibleCount); + const issued = data?.items ?? []; + const failures = data?.failures ?? 0; - // Resolve names only for the subjects currently on screen, so the lookup - // fan-out grows with what's shown rather than the whole (possibly large) list. - const visibleSubjects = [...new Set(visible.map((i) => i.subject))]; + // Newest first; attestations without a publish_date sort last. Live and + // revoked are split into separate sections (revoked moved out of the main + // list) so endorsements read distinctly from withdrawn ones. + const byDate = (a: IssuedAttestation, b: IssuedAttestation) => publishMs(b) - publishMs(a); + const liveItems = issued.filter((i) => !i.revoked).sort(byDate); + const revokedItems = issued.filter((i) => i.revoked).sort(byDate); + const liveVisible = liveItems.slice(0, liveCount); + const revokedVisible = revokedItems.slice(0, revokedShown); + + // Resolve names only for the subjects currently on screen (both sections), so + // the lookup fan-out grows with what's shown rather than the whole list. + const visibleSubjects = [ + ...new Set([...liveVisible, ...revokedVisible].map((i) => i.subject)), + ]; const { items: names } = useReverseResolution(visibleSubjects, network); const nameOf = (subject: string) => (names[subject] as { name?: string })?.name; @@ -54,8 +64,6 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { if (network === "testnet") return null; const subjectCount = new Set(issued.map((i) => i.subject)).size; - const revokedCount = issued.filter((i) => i.revoked).length; - const activeCount = issued.length - revokedCount; return (
@@ -80,9 +88,9 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { className="text-content-tertiary" > {issued.length} attestation{issued.length === 1 ? "" : "s"} about{" "} - {subjectCount} package{subjectCount === 1 ? "" : "s"} · {activeCount}{" "} + {subjectCount} package{subjectCount === 1 ? "" : "s"} · {liveItems.length}{" "} active - {revokedCount > 0 && <> · {revokedCount} revoked} + {revokedItems.length > 0 && <> · {revokedItems.length} revoked} )}
@@ -100,28 +108,78 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) {
)} + {failures > 0 && ( +
+ + + {failures} attestation{failures === 1 ? "" : "s"} couldn't be loaded. + +
+ )} + {!isLoading && !error && issued.length === 0 && ( This package hasn't issued any attestations. )} - {visible.length > 0 && ( -
- {visible.map((item) => ( - - ))} + {liveVisible.length > 0 && ( + setLiveCount((c) => c + PAGE_SIZE)} + /> + )} + + {revokedItems.length > 0 && ( +
+ +

Revoked

+
+ setRevokedShown((c) => c + PAGE_SIZE)} + />
)} +
+ ); +} - {visibleCount < sorted.length && ( - )} @@ -129,36 +187,50 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { ); } -/** One dense row: the subject (linked), the attestation kind, its date, and a - * revoked tag. Revoked rows stay visible — on an attester you want to see how - * many they pull — but are de-emphasized. */ -function IssuedRow({ item, name }: { item: IssuedAttestation; name?: string }) { +/** One dense row: the subject (linked) + the attestation's description, with the + * `module::Type` and date as secondary metadata. Revoked rows live in their own + * section and are de-emphasized. */ +function IssuedRow({ + item, + name, + deemphasized, +}: { + item: IssuedAttestation; + name?: string; + deemphasized?: boolean; +}) { const { display, innerType } = item.info; const date = formatDate(display["publish_date"]); + const description = str(display["description"]) ?? str(display["name"]); return (
-
- - - - - {moduleAndType(innerType)} - - {item.revoked && ( - - - revoked - - +
+
+ + + + + {moduleAndType(innerType)} + +
+ {description && ( + + {description} + )}
{date && ( diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 818e790a..5954dfc3 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -191,11 +191,11 @@ export function useIssuedAttestations( return useQuery({ queryKey: [AppQueryKeys.ATTESTATIONS, "issued", network, pkg], enabled: !!pkg && !!cfg, - queryFn: async (): Promise => { + queryFn: async (): Promise<{ items: IssuedAttestation[]; failures: number }> => { const attestor = cfg!.trustedAttestors.find((a) => a.lineage.some((id) => normalizeSuiAddress(id) === normalizeSuiAddress(pkg!)), ); - if (!attestor) return []; + if (!attestor) return { items: [], failures: 0 }; const types = await enumerateAttestationTypes(client, attestor.lineage, cfg!.registryPkg); // Reverse query: every object of each issued type, across all Boxes. @@ -221,19 +221,28 @@ export function useIssuedAttestations( if (node.address && subject) subjectById.set(node.address, subject); } } - if (subjectById.size === 0) return []; + if (subjectById.size === 0) return { items: [], failures: 0 }; // Re-read each for Display + owner; drop undisplayed types. An issued // attestation is revoked iff it now lives in its subject's revoked box // rather than the active box — the read-by-type Issued view is the one // place that recovers revocation from ownership, since the object itself - // carries no status field. + // carries no status field. A re-read that fails (vs. a legitimately + // undisplayed/non-attestation object) is counted so the UI can flag that + // some attestations couldn't be loaded rather than silently dropping them. const out: IssuedAttestation[] = []; + let failures = 0; for (const [id, subject] of subjectById) { - const resp = await client - .getObject({ id, options: { showType: true, showDisplay: true, showOwner: true } }) - .catch(() => null); - if (!resp) continue; + let resp; + try { + resp = await client.getObject({ + id, + options: { showType: true, showDisplay: true, showOwner: true }, + }); + } catch { + failures++; + continue; + } const info = toAttestationInfo(resp); if (!info || Object.keys(info.display).length === 0) continue; const ownerField = resp.data?.owner; @@ -249,7 +258,7 @@ export function useIssuedAttestations( revoked, }); } - return out; + return { items: out, failures }; }, }); } From d99e48d25a38759ac7626245b531d0a4160aadf6 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 00:16:05 +0000 Subject: [PATCH 40/54] Version selector navigates to the version's page; surface revoked-read error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Security tab's version selector now navigates to /package// (preserving the tab) instead of swapping a local list, so the whole page — and the tab's attestation-count pill, which keys off the resolved version — reflects the chosen version. Drops the selectedAddress state + re-sync effect. Also fold the revoked sub-query's error into the surfaced error, so a revoked-read failure no longer hides behind "no published audits". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index e54ec6ed..efaa8107 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; import { @@ -70,16 +71,19 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { name.version, network, ); - const [selectedAddress, setSelectedAddress] = useState(name.package_address); - // Re-sync to the resolved version when the page navigates to a different - // package/version; picking a version in the dropdown changes selectedAddress - // but not name.package_address, so it doesn't fight the user's selection. - useEffect(() => { - setSelectedAddress(name.package_address); - }, [name.package_address]); - const { data, isLoading, error } = useGetAttestations(selectedAddress, network); - const { data: revokedData } = useGetRevokedAttestations(selectedAddress, network); + const router = useRouter(); + const searchParams = useSearchParams(); + // The attestations shown are for the version the page resolved to. The version + // selector navigates to /package// rather than swapping a local + // list, so the whole page — including the tab's attestation-count pill — + // reflects the chosen version. + const { data, isLoading, error } = useGetAttestations(name.package_address, network); + const { data: revokedData, error: revokedError } = useGetRevokedAttestations( + name.package_address, + network, + ); const revoked = revokedData ?? []; + const displayError = error ?? revokedError; const positives = data ?? []; const hasLiveAttestation = positives.length > 0; @@ -88,9 +92,7 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { if (network === "testnet") return null; const versionList = versions ?? []; - const selectedVersion = - versionList.find((v) => v.address === selectedAddress)?.version ?? - name.version; + const selectedVersion = name.version; return (
@@ -102,8 +104,8 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { { - const qs = searchParams.toString(); - router.push(`/package/${name.name}/${val}${qs ? `?${qs}` : ""}`); - }} + +

Security

+
+ + {versionList.length <= 1 ? ( + // Single-version package (the common case): no per-version headers. + + ) : ( + versionList.map((v, i) => ( +
0 ? "border-t border-stroke-secondary pt-md" : "" + }`} > - - - - - {versionList.map((v) => ( - - Version {v.version} - - ))} - - - )} -
+
+ + Version {v.version} + + {v.version === latestVersion && ( + + Latest + + )} +
+ +
+ )) + )} +
+ ); +} +/** The attestation status of a single package version: live audits, the + * "no audits" warning, surfaced load errors, and any revoked attestations. */ +function VersionAudits({ + address, + network, +}: { + address: string; + network: "mainnet" | "testnet"; +}) { + const { data, isLoading, error } = useGetAttestations(address, network); + const { data: revokedData, error: revokedError } = useGetRevokedAttestations( + address, + network, + ); + const revoked = revokedData ?? []; + const displayError = error ?? revokedError; + const positives = data ?? []; + const hasLiveAttestation = positives.length > 0; + + return ( +
{isLoading && ( )} From 4ad5e16a1c772118e359f3c5935473b3c6ad9933 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 12:04:15 +0000 Subject: [PATCH 43/54] Trust-signal pill: show the count even at 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "✓ 0" on the latest version is a real signal — this package has no published audits — so show the pill at zero too, instead of hiding it. (No config gate; the feature only ships once attesters are configured for prod anyway.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackageTrustSignals.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index f6a9aad8..7fd92f57 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -16,7 +16,9 @@ import { type TrustedAttestor } from "@/lib/attestations"; import { CheckIcon } from "@/icons/single-package/CheckIcon"; import { WarningIcon } from "@/icons/single-package/WarningIcon"; -/** Tab label: a pill with the count of live attestations. */ +/** Tab label: a pill with the count of live attestations on the latest version. + * Shown whenever attestations are configured — "✓ 0" is itself a signal (this + * package has no published audits), so it shows even at zero. */ export function TrustSignalCount({ address, network, @@ -27,8 +29,9 @@ export function TrustSignalCount({ const { data } = useGetAttestations(address, network); const positives = (data ?? []).length; - // Attestations are a mainnet-only feature in the demo. - if (network === "testnet" || !positives) return null; + // Mainnet-only. Shown even at 0 — "✓ 0" is itself a signal that the latest + // version has no published audits. + if (network === "testnet") return null; return (
Date: Fri, 26 Jun 2026 13:01:28 +0000 Subject: [PATCH 44/54] Security page heading: "Audits" (tab stays "Security") The in-page heading reads "Audits" for now, since audits are the only trust signal. Once others are integrated, the "Security" tab becomes an h1 with an "Audits" h2 beneath it. The tab title is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/single-package/SinglePackageTrustSignals.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 7fd92f57..9f0d2105 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -79,8 +79,10 @@ export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { return (
+ {/* Page heading is "Audits" for now; the "Security" tab will become an + h1 over an "Audits" h2 once other trust signals are integrated. */} -

Security

+

Audits

{versionList.length <= 1 ? ( From 30ba1c781e1a2d629ebc3829e304b4c692ed5061 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 13:06:44 +0000 Subject: [PATCH 45/54] Audit card: surface only standard Display conventions, not custom fields The card special-cased `display.score` (an AuditV2-specific field) as a pill, coupling the platform UI to one schema. Drop it: the card now surfaces only the standard presentation conventions (name/description/image_url/link). A schema-specific field like `score` should be promoted to a documented convention if it's broadly useful, not inferred by the general UI. Removes the now-unused ScorePill. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SinglePackageTrustSignals.tsx | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx index 9f0d2105..3304d0c0 100644 --- a/app/src/components/single-package/SinglePackageTrustSignals.tsx +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -175,15 +175,17 @@ function VersionAudits({ ); } -/** One focused card per audit: a badge, the verdict headline (and score), the - * attester, and — demoted — the type and object link. Optimized for the common - * case of one audit per attester, so there is no per-attester sub-grouping. */ +/** One focused card per audit: a badge, the verdict headline, the attester, and + * — demoted — the type and object link. Optimized for the common case of one + * audit per attester, so there is no per-attester sub-grouping. Only the + * standard presentation conventions (name/description/image_url/link) are + * surfaced; schema-specific Display fields are not — the platform stays + * agnostic to any one schema's custom fields. */ function AuditCard({ item }: { item: DisplayedAttestation }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; const { display, innerType, id } = item.info; const headline = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; - const score = str(display["score"]); const link = httpsLink(display["link"]); return ( @@ -191,12 +193,9 @@ function AuditCard({ item }: { item: DisplayedAttestation }) {
{/* Verdict — the line the eye should land on first. */} -
- - {headline} - - {score && {score}} -
+ + {headline} + {/* Attester identity + report link. */} @@ -240,17 +239,6 @@ function AuditBadge({ item }: { item: DisplayedAttestation }) { ); } -/** A small score pill (e.g. "95/100"), reusing the count-chip styling. */ -function ScorePill({ children }: { children: React.ReactNode }) { - return ( -
- - {children} - -
- ); -} - /** Revoked attestations, minimized: on a package the viewer rarely cares about * them, so they collapse to a small "N revoked" line that expands on demand. */ function RevokedSummary({ items }: { items: AttributedAttestation[] }) { From ad7298618bd93ec8ca6ac149a1dbd51b193499d7 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 15:48:04 +0000 Subject: [PATCH 46/54] demo_server: seed names for untrusted attestors too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed an mvr name (+ README) for each untrustedAttestors entry in demo-ids.json, so @demo/auditor-b resolves and its page is browsable. It's not added to the trust config, so the UI hides its Issued tab — the case we want to demo. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 67e88b95..ad80c0dc 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -130,6 +130,28 @@ async fn main() -> anyhow::Result<()> { } } + // Untrusted attesters get a name too, so their pages are browsable — the UI + // should hide the Issued tab for them (they're not in the trust config). + if let Some(attestors) = ids["untrustedAttestors"].as_array() { + for (i, a) in attestors.iter().enumerate() { + let pkg_name = a["name"].as_str().unwrap_or_default(); + let id = a["id"].as_str().unwrap_or_default().to_string(); + let mvr_name = auditor_mvr_name(pkg_name); + let pkg_info_id = format!("0x{:064x}", 0xdee0_0020u64 + i as u64); + let git_path = format!("demo/{pkg_name}"); + seed( + &mut db, + &mvr_name, + &id, + &pkg_info_id, + "An untrusted attester in the demo.", + Some(&git_path), + ) + .await?; + println!("seeded {mvr_name} -> {id} (untrusted)"); + } + } + println!("seeded @demo/subject -> {subject}"); println!( "seeded @demo/dependency -> {} version(s): {}", From 33da41a3fcab8b9dcea03f3684d2b044975b68b9 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 19:35:05 +0000 Subject: [PATCH 47/54] Issued tab: viewable by URL for any package, with a not-trusted warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Issued tab now works for any package, not just configured trusted attesters: - useIssuedAttestations enumerates the viewed package's own types from its mvr version lineage (useGetMvrVersionAddresses) instead of the trust config, so it loads for any package. The trust config governs only presentation now. - The tab stays in the nav for configured attesters only, but is reachable by URL (?tab=issued) for any package — SinglePackage splits nav visibility (navTabs) from URL-selectability (the full Tabs list); the issued label gets `name` so its count can resolve the lineage too. - SinglePackageIssued shows a prominent "not on your trusted list" warning when the package isn't a configured attester. Safe to show: Attestation is mintable only by T's defining package, so a package's issued list is genuinely its own claims; the warning is about trust, not authenticity. Verified: tsc clean; demo end-to-end — auditor-b (untrusted) issued read returns its 1 attestation and /package/@demo/auditor-b?tab=issued serves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../single-package/SinglePackage.tsx | 24 +++++++------ .../single-package/SinglePackageIssued.tsx | 27 +++++++++++--- app/src/hooks/useGetAttestations.ts | 35 +++++++++++-------- 3 files changed, 58 insertions(+), 28 deletions(-) diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 6bdbbaec..1e4c4b55 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -109,9 +109,11 @@ export const Tabs: SinglePackageTab[] = [ title: "Attestations", selectedIcon: , unselectedIcon: , - label: (address: string, network: "mainnet" | "testnet") => ( - - ), + label: ( + _address: string, + network: "mainnet" | "testnet", + name?: ResolvedName, + ) => (name ? : null), component: (name: ResolvedName) => , }, { @@ -135,19 +137,21 @@ export function SinglePackage({ const router = useRouter(); const searchParams = useSearchParams(); - // The "Issued" tab only applies to attesters — gate it on whitelist - // membership (a free in-memory check) rather than probing every package. + // The "Issued" tab is in the nav only for configured attesters (a free + // in-memory check), but it stays reachable by URL (?tab=issued) for any + // package — the panel shows a not-trusted warning. So nav visibility + // (navTabs) and URL-selectability (the full Tabs list) differ. const cfg = attestationConfig(); - const visibleTabs = + const navTabs = cfg && isConfiguredAttestor(cfg, name.package_address) ? Tabs : Tabs.filter((t) => t.key !== "issued"); - const [activeTab, setActiveTab] = useState(visibleTabs[0]!.key); + const [activeTab, setActiveTab] = useState(navTabs[0]!.key); useEffect(() => { const tab = searchParams.get("tab"); - if (tab && visibleTabs.some((t) => t.key === tab) && tab !== activeTab) { + if (tab && Tabs.some((t) => t.key === tab) && tab !== activeTab) { setActiveTab(tab as string); } }, [searchParams]); @@ -166,14 +170,14 @@ export function SinglePackage({
- {visibleTabs.find((t) => t.key === activeTab)?.component(name)} + {Tabs.find((t) => t.key === activeTab)?.component(name)}
diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 5b6e1571..5c788980 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -7,6 +7,7 @@ import { type IssuedAttestation, } from "@/hooks/useGetAttestations"; import { useReverseResolution } from "@/hooks/useReverseResolution"; +import { attestationConfig, isConfiguredAttestor } from "@/lib/attestations"; import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; import { WarningIcon } from "@/icons/single-package/WarningIcon"; @@ -15,13 +16,13 @@ const PAGE_SIZE = 20; /** Tab label: count of attestations this package has issued. */ export function IssuedCount({ - address, + name, network, }: { - address: string; + name: ResolvedName; network: "mainnet" | "testnet"; }) { - const { data } = useIssuedAttestations(address, network); + const { data } = useIssuedAttestations(name, network); const count = data?.items.length ?? 0; // Attestations are a mainnet-only feature in the demo. if (network === "testnet" || !count) return null; @@ -36,7 +37,11 @@ export function IssuedCount({ export function SinglePackageIssued({ name }: { name: ResolvedName }) { const network = usePackagesNetwork() as "mainnet" | "testnet"; - const { data, isLoading, error } = useIssuedAttestations(name.package_address, network); + const { data, isLoading, error } = useIssuedAttestations(name, network); + // The nav lists this tab only for configured attesters, but it's reachable by + // URL for any package — warn when this package isn't a trusted attester. + const cfg = attestationConfig(); + const trusted = !!cfg && isConfiguredAttestor(cfg, name.package_address); const [liveCount, setLiveCount] = useState(PAGE_SIZE); const [revokedShown, setRevokedShown] = useState(PAGE_SIZE); @@ -95,6 +100,20 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { )}
+ {!trusted && ( +
+ + + + This attester isn't on your trusted list. + {" "} + These are on-chain claims this package has signed — not endorsements. + Anyone can publish a package and issue attestations; verify the + attester's identity before relying on them. + +
+ )} + {isLoading && ( )} diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 5954dfc3..67c8c55b 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -13,6 +13,8 @@ import { type AttestationInfo, type TrustedAttestor, } from "@/lib/attestations"; +import { useGetMvrVersionAddresses } from "./useGetMvrVersionAddresses"; +import { ResolvedName } from "./mvrResolution"; /** A trusted attestation attributed to the attester whose lineage defines its * type — the shared base for the active-box and revoked-box reads. */ @@ -172,31 +174,36 @@ const ISSUED_QUERY = `query($type: String!) { }`; /** - * The attestations *issued by* `pkg` — the reverse of the per-subject read. - * Uses GraphQL `objects(type:)` to find every `Attestation` of the package's - * types across all Boxes (the object's `subject` field says who it's about), then - * re-reads each over JSON-RPC for Display + owner. Only configured trusted - * attesters issue attestations in the demo, so a package not in the trust config - * returns nothing. + * The attestations *issued by* a package — the reverse of the per-subject read. + * Enumerates the package's own `store` types across its mvr version lineage, uses + * GraphQL `objects(type:)` to find every `Attestation` of those types across + * all Boxes (the object's `subject` field says who it's about), then re-reads + * each over JSON-RPC for Display + owner. Works for ANY package, not just + * configured trusted attesters — the trust config governs only how the UI frames + * the result (see the Issued tab's not-trusted warning), not whether it loads. */ export function useIssuedAttestations( - pkg: string | undefined, + name: ResolvedName | undefined, network: "mainnet" | "testnet", ) { const clients = useSuiClientsContext(); const client = clients[network]; const gql = clients.graphql[network]; const cfg = attestationConfig(); + // The package's own types may be defined in any version, so enumerate across + // its whole mvr lineage rather than just the resolved version. + const { data: versions } = useGetMvrVersionAddresses( + name?.name ?? "", + name?.version ?? 0, + network, + ); + const lineage = (versions ?? []).map((v) => v.address); return useQuery({ - queryKey: [AppQueryKeys.ATTESTATIONS, "issued", network, pkg], - enabled: !!pkg && !!cfg, + queryKey: [AppQueryKeys.ATTESTATIONS, "issued", network, name?.package_address, lineage], + enabled: !!name && !!cfg && lineage.length > 0, queryFn: async (): Promise<{ items: IssuedAttestation[]; failures: number }> => { - const attestor = cfg!.trustedAttestors.find((a) => - a.lineage.some((id) => normalizeSuiAddress(id) === normalizeSuiAddress(pkg!)), - ); - if (!attestor) return { items: [], failures: 0 }; - const types = await enumerateAttestationTypes(client, attestor.lineage, cfg!.registryPkg); + const types = await enumerateAttestationTypes(client, lineage, cfg!.registryPkg); // Reverse query: every object of each issued type, across all Boxes. const subjectById = new Map(); From 4ef12bed0e4a2164e9965deeeb0ba6b2b165a80b Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 19:42:05 +0000 Subject: [PATCH 48/54] demo_server: seed @demo/attestations for the registry package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the attestation registry package itself an mvr name (@demo/attestations), so resolves by name in the demo — e.g. as a move-call target like the auditor packages, matching what the onboarding doc describes. No README (the package has none; like @demo/subject). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index ad80c0dc..1d8eb9c1 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -46,6 +46,8 @@ const PKG_INFO_SUBJECT: &str = "0x00000000000000000000000000000000000000000000000000000000dee00001"; const PKG_INFO_DEPENDENCY: &str = "0x00000000000000000000000000000000000000000000000000000000dee00002"; +const PKG_INFO_REGISTRY: &str = + "0x00000000000000000000000000000000000000000000000000000000dee00003"; #[derive(Parser)] struct Args { @@ -93,6 +95,22 @@ async fn main() -> anyhow::Result<()> { ) .await?; + // The attestation registry package itself, so `` resolves by + // name (e.g. as a move-call target like the auditor packages). + let registry_pkg = ids["attestationRegistryPkg"] + .as_str() + .expect("demo-ids.json missing attestationRegistryPkg"); + seed( + &mut db, + "@demo/attestations", + registry_pkg, + PKG_INFO_REGISTRY, + "The attestation registry package: Attestation, per-subject boxes, Display.", + None, + ) + .await?; + println!("seeded @demo/attestations -> {registry_pkg}"); + // The real `subject -> dependency` edge, so the dependencies endpoint // returns it (powering both the Dependencies tab and vuln propagation). { From 2b557920827f022464da7b416f49df4a787b4d85 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 19:47:02 +0000 Subject: [PATCH 49/54] demo: per-org mvr names for verisimilitude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the demo packages more realistic mvr names: the registry is @mysten/attestations, and each auditor is its own org (@auditor-a/audit, @auditor-b/audit) rather than @demo/auditor-x — matching the existing @example-scanner/disclosures shape. Subject/dependency stay @demo/*. Kept in sync: auditor_mvr_name (demo_server.rs) and the trusted attester's mvrName (write-demo-env.sh). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 8 ++++---- scripts/write-demo-env.sh | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 1d8eb9c1..319a2e71 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -102,14 +102,14 @@ async fn main() -> anyhow::Result<()> { .expect("demo-ids.json missing attestationRegistryPkg"); seed( &mut db, - "@demo/attestations", + "@mysten/attestations", registry_pkg, PKG_INFO_REGISTRY, "The attestation registry package: Attestation, per-subject boxes, Display.", None, ) .await?; - println!("seeded @demo/attestations -> {registry_pkg}"); + println!("seeded @mysten/attestations -> {registry_pkg}"); // The real `subject -> dependency` edge, so the dependencies endpoint // returns it (powering both the Dependencies tab and vuln propagation). @@ -199,8 +199,8 @@ async fn main() -> anyhow::Result<()> { fn auditor_mvr_name(pkg_name: &str) -> String { match pkg_name { "vuln_example" => "@example-scanner/disclosures".to_string(), - // MVR names can't contain `_`; the demo auditor package is `auditor_a`. - other => format!("@demo/{}", other.replace('_', "-")), + // Each auditor is its own org; MVR names can't contain `_` (auditor_a). + other => format!("@{}/audit", other.replace('_', "-")), } } diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index a5540cc5..2240823f 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -56,7 +56,7 @@ RAW = "https://raw.githubusercontent.com/mdgeorge4153/sui-attestation-registry/m ATTESTORS = { "auditor_a": { "name": "Auditor A", - "mvrName": "@demo/auditor-a", + "mvrName": "@auditor-a/audit", "iconUrl": f"{RAW}/demo/auditor_a/icon.svg", }, # PR-B attester; its icon lands at demo/vuln_reporter_a/icon.svg when that From 2ecbb12208d60b83153984b41d81b158b741a080 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 19:48:57 +0000 Subject: [PATCH 50/54] Issued tab: warning says "mvr's trusted list", not "your" Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/components/single-package/SinglePackageIssued.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 5c788980..9be9ca49 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -105,7 +105,7 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { - This attester isn't on your trusted list. + This attester isn't on mvr's trusted list. {" "} These are on-chain claims this package has signed — not endorsements. Anyone can publish a package and issue attestations; verify the From d5946ecf999236a98a0ac339a8deef20149c990b Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 26 Jun 2026 20:07:19 +0000 Subject: [PATCH 51/54] demo_server: render the @mysten/attestations README Wire the registry name's git_path to packages/attestations so its mvr page shows the package README (mirrored into the public prototype repo that the demo fetches READMEs from). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 319a2e71..9137feda 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -106,7 +106,7 @@ async fn main() -> anyhow::Result<()> { registry_pkg, PKG_INFO_REGISTRY, "The attestation registry package: Attestation, per-subject boxes, Display.", - None, + Some("packages/attestations"), ) .await?; println!("seeded @mysten/attestations -> {registry_pkg}"); From 421934cebd450b385695fdaefb0ce002a425cc07 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 2 Jul 2026 13:41:42 +0000 Subject: [PATCH 52/54] demo: add Auditor C to the trust-config presentation map A second trusted auditor in the demo (name/mvrName @auditor-c/audit/icon), kept in sync with the attestations repo's demo scripts. demo_server auto-seeds its name via the trusted-attestor loop. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/write-demo-env.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh index 2240823f..e59f4395 100644 --- a/scripts/write-demo-env.sh +++ b/scripts/write-demo-env.sh @@ -59,6 +59,11 @@ ATTESTORS = { "mvrName": "@auditor-a/audit", "iconUrl": f"{RAW}/demo/auditor_a/icon.svg", }, + "auditor_c": { + "name": "Auditor C", + "mvrName": "@auditor-c/audit", + "iconUrl": f"{RAW}/demo/auditor_c/icon.svg", + }, # PR-B attester; its icon lands at demo/vuln_reporter_a/icon.svg when that # package is added. Inert here (not in PR A's trusted set). "vuln_example": { From 5ec28267c2cbb0a1dc235fa200d6c615e2a3275e Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 2 Jul 2026 16:30:01 +0000 Subject: [PATCH 53/54] Issued tab: page through all results, render them all (drop see-more) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Issued read fetched only the first GraphQL page per type, so a prolific attester's later attestations were silently dropped. Loop the objects query with pageInfo/after until exhausted. Since we now load everything (and the list is globally sorted newest-first, which needs all items anyway), drop the "Show more" display cap and render the full list — simpler, and no data is hidden. A typed issuedPage() helper keeps the cursor out of the query's return-type inference. --- .../single-package/SinglePackageIssued.tsx | 70 +++++-------------- app/src/hooks/useGetAttestations.ts | 48 ++++++++----- 2 files changed, 47 insertions(+), 71 deletions(-) diff --git a/app/src/components/single-package/SinglePackageIssued.tsx b/app/src/components/single-package/SinglePackageIssued.tsx index 9be9ca49..55f9c8aa 100644 --- a/app/src/components/single-package/SinglePackageIssued.tsx +++ b/app/src/components/single-package/SinglePackageIssued.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import Link from "next/link"; import { ResolvedName } from "@/hooks/mvrResolution"; import { usePackagesNetwork } from "../providers/packages-provider"; @@ -12,8 +11,6 @@ import { Text } from "../ui/Text"; import LoadingState from "../LoadingState"; import { WarningIcon } from "@/icons/single-package/WarningIcon"; -const PAGE_SIZE = 20; - /** Tab label: count of attestations this package has issued. */ export function IssuedCount({ name, @@ -42,27 +39,20 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { // URL for any package — warn when this package isn't a trusted attester. const cfg = attestationConfig(); const trusted = !!cfg && isConfiguredAttestor(cfg, name.package_address); - const [liveCount, setLiveCount] = useState(PAGE_SIZE); - const [revokedShown, setRevokedShown] = useState(PAGE_SIZE); - const issued = data?.items ?? []; const failures = data?.failures ?? 0; // Newest first; attestations without a publish_date sort last. Live and // revoked are split into separate sections (revoked moved out of the main - // list) so endorsements read distinctly from withdrawn ones. + // list) so endorsements read distinctly from withdrawn ones. The fetch pages + // through every result, so we render them all — no cap. const byDate = (a: IssuedAttestation, b: IssuedAttestation) => publishMs(b) - publishMs(a); const liveItems = issued.filter((i) => !i.revoked).sort(byDate); const revokedItems = issued.filter((i) => i.revoked).sort(byDate); - const liveVisible = liveItems.slice(0, liveCount); - const revokedVisible = revokedItems.slice(0, revokedShown); - // Resolve names only for the subjects currently on screen (both sections), so - // the lookup fan-out grows with what's shown rather than the whole list. - const visibleSubjects = [ - ...new Set([...liveVisible, ...revokedVisible].map((i) => i.subject)), - ]; - const { items: names } = useReverseResolution(visibleSubjects, network); + // Resolve subject names for every attestation shown. + const subjects = [...new Set(issued.map((i) => i.subject))]; + const { items: names } = useReverseResolution(subjects, network); const nameOf = (subject: string) => (names[subject] as { name?: string })?.name; // Attestations are a mainnet-only feature in the demo. @@ -142,66 +132,40 @@ export function SinglePackageIssued({ name }: { name: ResolvedName }) { )} - {liveVisible.length > 0 && ( - setLiveCount((c) => c + PAGE_SIZE)} - /> - )} + {liveItems.length > 0 && } {revokedItems.length > 0 && (

Revoked

- setRevokedShown((c) => c + PAGE_SIZE)} - /> +
)}
); } -/** A dense, paginated list of issued-attestation rows. */ +/** A dense list of issued-attestation rows. */ function RowList({ items, - total, nameOf, deemphasized, - onShowMore, }: { items: IssuedAttestation[]; - total: number; nameOf: (subject: string) => string | undefined; deemphasized?: boolean; - onShowMore: () => void; }) { return ( -
-
- {items.map((item) => ( - - ))} -
- {items.length < total && ( - - )} +
+ {items.map((item) => ( + + ))}
); } diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts index 67c8c55b..677c17f4 100644 --- a/app/src/hooks/useGetAttestations.ts +++ b/app/src/hooks/useGetAttestations.ts @@ -167,8 +167,9 @@ export interface IssuedAttestation { revoked: boolean; } -const ISSUED_QUERY = `query($type: String!) { - objects(filter: { type: $type }) { +const ISSUED_QUERY = `query($type: String!, $after: String) { + objects(filter: { type: $type }, after: $after) { + pageInfo { hasNextPage endCursor } nodes { address asMoveObject { contents { json } } } } }`; @@ -205,28 +206,39 @@ export function useIssuedAttestations( queryFn: async (): Promise<{ items: IssuedAttestation[]; failures: number }> => { const types = await enumerateAttestationTypes(client, lineage, cfg!.registryPkg); - // Reverse query: every object of each issued type, across all Boxes. - const subjectById = new Map(); - for (const type of types) { - const res = await gql.query<{ + // Reverse query: page through every object of each issued type, across all + // Boxes. A typed helper (explicit `after` param) keeps the cursor out of + // the query's own return-type inference. + const issuedPage = (type: string, after: string | null) => + gql.query<{ objects: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: { address: string; asMoveObject: { contents: { json: { subject?: string } } | null } | null; }[]; }; - }>({ query: ISSUED_QUERY, variables: { type } }); - // Surface GraphQL errors instead of treating a failed query as "no - // results" — e.g. the localnet GraphQL's "Request is outside consistent - // range" when its consistent store lags. Swallowing it renders a - // failure as an empty Issued tab, which is misleading. - if (res.errors?.length) { - throw new Error(`GraphQL query failed: ${res.errors[0]?.message}`); - } - for (const node of res.data?.objects?.nodes ?? []) { - const subject = node.asMoveObject?.contents?.json?.subject; - if (node.address && subject) subjectById.set(node.address, subject); - } + }>({ query: ISSUED_QUERY, variables: { type, after } }); + + const subjectById = new Map(); + for (const type of types) { + let after: string | null = null; + do { + const res = await issuedPage(type, after); + // Surface GraphQL errors instead of treating a failed query as "no + // results" — e.g. the localnet GraphQL's "Request is outside consistent + // range" when its consistent store lags. Swallowing it renders a + // failure as an empty Issued tab, which is misleading. + if (res.errors?.length) { + throw new Error(`GraphQL query failed: ${res.errors[0]?.message}`); + } + for (const node of res.data?.objects?.nodes ?? []) { + const subject = node.asMoveObject?.contents?.json?.subject; + if (node.address && subject) subjectById.set(node.address, subject); + } + const pageInfo = res.data?.objects?.pageInfo; + after = pageInfo?.hasNextPage ? pageInfo.endCursor : null; + } while (after); } if (subjectById.size === 0) return { items: [], failures: 0 }; From 48e04a284baa5de15732b90a2ff04a1c4d587425 Mon Sep 17 00:00:00 2001 From: Michael George Date: Thu, 2 Jul 2026 16:54:57 +0000 Subject: [PATCH 54/54] demo_server: seed all versions of upgraded trusted attesters The Issued read derives a package's type lineage from its MVR versions, but the trusted-attester seed registered only a single version pointing at the latest package id. For auditor_a (upgraded: Audit in v1, AuditV2 in v2) that made the lineage just [v2], so the canonical Audit type (defined in v1) was never enumerated and its Audits vanished from the Issued tab. Seed originalId as v1 and latestId as v2 (via seed_versions, like @demo/dependency) when they differ, so the lineage is complete. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mvr-api/examples/demo_server.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs index 9137feda..e9ea3d1b 100644 --- a/crates/mvr-api/examples/demo_server.rs +++ b/crates/mvr-api/examples/demo_server.rs @@ -131,20 +131,29 @@ async fn main() -> anyhow::Result<()> { if let Some(attestors) = ids["trustedAttestors"].as_array() { for (i, a) in attestors.iter().enumerate() { let pkg_name = a["name"].as_str().unwrap_or_default(); + let original = a["originalId"].as_str().unwrap_or_default().to_string(); let latest = a["latestId"].as_str().unwrap_or_default().to_string(); let mvr_name = auditor_mvr_name(pkg_name); let pkg_info_id = format!("0x{:064x}", 0xdee0_0010u64 + i as u64); let git_path = format!("demo/{pkg_name}"); - seed( + // Seed every published version, not just the latest: the reverse + // (Issued) read derives its type lineage from the MVR versions, and an + // upgraded auditor (auditor_a) defines Audit in v1 and AuditV2 in v2. + let versions: Vec<(String, i64)> = if !original.is_empty() && original != latest { + vec![(original, 1), (latest, 2)] + } else { + vec![(latest, 1)] + }; + seed_versions( &mut db, &mvr_name, - &latest, + &versions, &pkg_info_id, "A trusted attester in the demo.", Some(&git_path), ) .await?; - println!("seeded {mvr_name} -> {latest}"); + println!("seeded {mvr_name} -> {} version(s)", versions.len()); } }