diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md new file mode 100644 index 00000000..b8286189 --- /dev/null +++ b/ATTESTATION-INTEGRATION.md @@ -0,0 +1,292 @@ +# 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/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. | +| 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/src/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: `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. + 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/src/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<…attestations::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/src/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/src/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 (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. Verified: the tab shows the AuditV2 + (matched via the upgraded lineage) as ineffective post-revoke, and the + Vulnerability as effective. +- **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. + +## 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. +# 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. +# 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 /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, +# 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 +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 + +- [x] Extend the attestation-registry repo's `scripts/run-demo.sh` / `ts/demo.ts`: + publish + upgrade attester (`AuditV2`), create + revoke attestations, emit + published IDs. +- [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. +- [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. +- [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. +- [ ] `image_url`/`link` conventions in `CONVENTIONS.md` + `conventions.ts`; + 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** (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 "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 + +- **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. **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. +- `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 + `mainnet`). +- Upstream PR: trusted-attestor list as real config vs. hardcoded constant. diff --git a/app/.env.example b/app/.env.example index adfe8367..78881a68 100644 --- a/app/.env.example +++ b/app/.env.example @@ -12,3 +12,9 @@ # Example: # SERVERVAR="foo" # NEXT_PUBLIC_CLIENTVAR="bar" + +# 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/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/providers/client-provider.tsx b/app/src/components/providers/client-provider.tsx index 015f8442..068bb5e9 100644 --- a/app/src/components/providers/client-provider.tsx +++ b/app/src/components/providers/client-provider.tsx @@ -33,15 +33,23 @@ export type Clients = { }; }; +// 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 LOCAL_GRAPHQL = process.env.NEXT_PUBLIC_LOCAL_GRAPHQL; + const mainnet = new SuiClient({ - url: "https://suins-rpc.mainnet.sui.io:443", + 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" }), @@ -57,19 +65,19 @@ 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: { - mainnet: "https://mainnet.mvr.mystenlabs.com", - 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", }, }; diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 15334ddd..1e4c4b55 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -12,6 +12,14 @@ import { ReadMeRenderer } from "./ReadMeRenderer"; import { SinglePackageDependencies } from "./SinglePackageDependencies"; import { SinglePackageDependents } from "./SinglePackageDependents"; import { SinglePackageVersions } from "./SinglePackageVersions"; +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"; @@ -23,6 +31,24 @@ 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 = () => ( + + + + +); + +// Upload/outbox glyph for the Issued tab (attestations this package emits). +const IssuedIcon = () => ( + + + + + +); export const Tabs: SinglePackageTab[] = [ { @@ -68,6 +94,28 @@ export const Tabs: SinglePackageTab[] = [ ) => , component: (name: ResolvedName) => , }, + { + key: "trust-signals", + title: "Security", + selectedIcon: , + unselectedIcon: , + label: (address: string, network: "mainnet" | "testnet") => ( + + ), + component: (name: ResolvedName) => , + }, + { + key: "issued", + title: "Attestations", + selectedIcon: , + unselectedIcon: , + label: ( + _address: string, + network: "mainnet" | "testnet", + name?: ResolvedName, + ) => (name ? : null), + component: (name: ResolvedName) => , + }, { key: "analytics", title: "Analytics", @@ -89,7 +137,17 @@ export function SinglePackage({ const router = useRouter(); const searchParams = useSearchParams(); - const [activeTab, setActiveTab] = useState(Tabs[0]!.key); + // 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 navTabs = + cfg && isConfiguredAttestor(cfg, name.package_address) + ? Tabs + : Tabs.filter((t) => t.key !== "issued"); + + const [activeTab, setActiveTab] = useState(navTabs[0]!.key); useEffect(() => { const tab = searchParams.get("tab"); @@ -112,7 +170,7 @@ export function SinglePackage({
hard-navigates and was flaky here). */ +function TrustedAttestorBadge() { + return ( + + + + MVR-trusted attestor + + + ); +} export function SinglePackageHeader({ name, @@ -11,6 +31,10 @@ export function SinglePackageHeader({ name: ResolvedName; network: "mainnet" | "testnet"; }) { + const cfg = attestationConfig(); + const isTrustedAttestor = + !!cfg && isConfiguredAttestor(cfg, name.package_address); + return (
@@ -20,9 +44,12 @@ export function SinglePackageHeader({ className="h-14 w-14 rounded-sm" />
- - {name.name} - +
+ + {name.name} + + {isTrustedAttestor && } +
+ + {count} + +
+ ); +} + +export function SinglePackageIssued({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + 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 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. 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); + + // 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. + if (network === "testnet") return null; + + const subjectCount = new Set(issued.map((i) => i.subject)).size; + + return ( +
+
+ +

Issued attestations

+
+ + 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"} · {liveItems.length}{" "} + active + {revokedItems.length > 0 && <> · {revokedItems.length} revoked} + + )} +
+ + {!trusted && ( +
+ + + + 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 + attester's identity before relying on them. + +
+ )} + + {isLoading && ( + + )} + + {error && ( +
+ + + Couldn't load issued attestations: {error.message} + +
+ )} + + {failures > 0 && ( +
+ + + {failures} attestation{failures === 1 ? "" : "s"} couldn't be loaded. + +
+ )} + + {!isLoading && !error && issued.length === 0 && ( + + This package hasn't issued any attestations. + + )} + + {liveItems.length > 0 && } + + {revokedItems.length > 0 && ( +
+ +

Revoked

+
+ +
+ )} +
+ ); +} + +/** A dense list of issued-attestation rows. */ +function RowList({ + items, + nameOf, + deemphasized, +}: { + items: IssuedAttestation[]; + nameOf: (subject: string) => string | undefined; + deemphasized?: boolean; +}) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +/** 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)} + +
+ {description && ( + + {description} + + )} +
+ {date && ( + + {date} + + )} +
+ ); +} + +/** 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} + + ); +} + +/** 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 new file mode 100644 index 00000000..3304d0c0 --- /dev/null +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -0,0 +1,368 @@ +import { useState } from "react"; +import Link from "next/link"; +import { ResolvedName } from "@/hooks/mvrResolution"; +import { usePackagesNetwork } from "../providers/packages-provider"; +import { + useGetAttestations, + useGetRevokedAttestations, + 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"; + +/** 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, +}: { + address: string; + network: "mainnet" | "testnet"; +}) { + const { data } = useGetAttestations(address, network); + const positives = (data ?? []).length; + + // 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 ( +
+ } + count={positives} + /> +
+ ); +} + +/** 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} + +
+ ); +} + +export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + // Attestations are per package version (the subject is a version's package + // id), and each version is audited — or not — independently. So we show every + // version's status here at once rather than only the resolved version. + const { data: versions } = useGetMvrVersionAddresses( + name.name, + name.version, + network, + ); + + // Attestations are a mainnet-only feature in the demo. + if (network === "testnet") return null; + + const versionList = (versions ?? []) + .slice() + .sort((a, b) => b.version - a.version); // newest first + const latestVersion = versionList.reduce( + (max, v) => Math.max(max, v.version), + name.version, + ); + + return ( +
+ {/* Page heading is "Audits" for now; the "Security" tab will become an + h1 over an "Audits" h2 once other trust signals are integrated. */} + +

Audits

+
+ + {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" : "" + }`} + > +
+ + 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 && ( + + )} + + {displayError && ( +
+ + + Couldn't load attestations: {displayError.message} + +
+ )} + + {!isLoading && !displayError && !hasLiveAttestation && ( +
+ + + This package version has no published audits. + +
+ )} + + {positives.length > 0 && ( +
+ {positives.map((item) => ( + + ))} +
+ )} + + {revoked.length > 0 && } +
+ ); +} + +/** 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 link = httpsLink(display["link"]); + + return ( +
+ +
+ {/* Verdict — the line the eye should land on first. */} + + {headline} + + + {/* Attester identity + report link. */} + + by {item.attestor.mvrName ? ( + mvrLink(item.attestor.mvrName, item.attestor.name) + ) : ( + {item.attestor.name} + )} + {link && <> · {reportLink(link)}} + + + {/* Developer metadata, de-emphasized at the end. */} + + {moduleAndType(innerType)} ·{" "} + {objectLink(id, network)} + +
+
+ ); +} + +/** 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)} + /> + + ); +} + +/** 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 ( +
+ + + {items.length} revoked + + + + {items.map((it, i) => ( + + {i > 0 ? ", " : ""} + {it.attestor.name} ({str(it.info.display["name"]) ?? "Attestation"}) + + ))} + +
+ ); +} + +export function AttesterAvatar({ + attestor, + size = "md", +}: { + attestor: TrustedAttestor; + size?: "sm" | "md" | "lg"; +}) { + 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 ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ); + } + return ( + + + {initials(attestor.name)} + + + ); +} + +/** The attestation object id, truncated and linked to an explorer. */ +function objectLink(id: string, network: "mainnet" | "testnet"): React.ReactNode { + return ( + + {truncateId(id)} + + ); +} + +/** The report/advisory URL as a link showing its host. */ +function reportLink(url: string): React.ReactNode { + return ( + + {hostOf(url)} ↗ + + ); +} + +/** Render an MVR name as an internal link to its package page. */ +function mvrLink(name: string, label?: string): React.ReactNode { + return ( + + {label ?? name} + + ); +} + +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 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 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; +} + +/** 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/SinglePackageVersions.tsx b/app/src/components/single-package/SinglePackageVersions.tsx index 60a45861..b067fe76 100644 --- a/app/src/components/single-package/SinglePackageVersions.tsx +++ b/app/src/components/single-package/SinglePackageVersions.tsx @@ -17,6 +17,14 @@ export function SinglePackageVersions({ name }: { name: ResolvedName }) { network, ); + // The newest version, independent of which one the page is currently + // resolved to (`name.version`) — you can navigate to an older version, so the + // two diverge. + const latestVersion = (versions ?? []).reduce( + (max, v) => Math.max(max, v.version), + name.version, + ); + if (isLoading) { return ; } @@ -29,7 +37,7 @@ export function SinglePackageVersions({ name }: { name: ResolvedName }) { size="heading-regular" className="flex items-center gap-sm" > - Versions + Versions @@ -57,7 +65,7 @@ export function SinglePackageVersions({ name }: { name: ResolvedName }) { {version.version} - {version.version === name.version && ( + {version.version === latestVersion && ( )} + {version.version === name.version && + version.version !== latestVersion && ( + + Current + + )}
diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts new file mode 100644 index 00000000..677c17f4 --- /dev/null +++ b/app/src/hooks/useGetAttestations.ts @@ -0,0 +1,283 @@ +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 { + attestationConfig, + attestorFor, + boxAddress, + revokedBoxAddress, + toAttestationInfo, + type AttestationConfig, + 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. */ +export interface AttributedAttestation { + info: AttestationInfo; + /** The trusted attester this attestation's type belongs to. */ + attestor: TrustedAttestor; +} + +/** 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. 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, + 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); + 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 box. Same + * trusted/Display filter, just against the revoked-box address. + */ +export async function fetchRevokedAttestations( + client: SuiClient, + cfg: AttestationConfig, + subject: string, +): Promise { + 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-box reads. Fetches every + * `Attestation` on the box (a `MoveModule` filter on the registry's + * `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 + * spam-resistance optimization if it ever matters.) + */ +async function fetchBoxAttestations( + client: SuiClient, + cfg: AttestationConfig, + boxAddr: string, +): Promise { + const infos: AttestationInfo[] = []; + let cursor: string | null | undefined = null; + do { + const page = await client.getOwnedObjects({ + owner: boxAddr, + filter: { + MoveModule: { package: cfg.registryPkg, module: "attestations" }, + }, + 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); + + return infos + .map((info) => ({ info, attestor: attestorFor(cfg, info.innerType) })) + .filter( + (x): x is AttributedAttestation => + !!x.attestor && Object.keys(x.info.display).length > 0, + ); +} + +/** + * The `Attestation` type strings for every `store` type `T` defined across + * 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, + 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}::attestations::Attestation<${pkg}::${moduleName}::${structName}>`, + ); + } + } + } + } + return [...types]; +} + +/** The attestations about `subject` from the configured trusted attesters. */ +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: () => fetchTrustedAttestations(client, cfg!, subject!), + }); +} + +/** The revoked attestations about `subject` (read from the revoked box). */ +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!), + }); +} + +/** 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 box (vs. its active box). */ + revoked: boolean; +} + +const ISSUED_QUERY = `query($type: String!, $after: String) { + objects(filter: { type: $type }, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { address asMoveObject { contents { json } } } + } +}`; + +/** + * 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( + 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, name?.package_address, lineage], + enabled: !!name && !!cfg && lineage.length > 0, + queryFn: async (): Promise<{ items: IssuedAttestation[]; failures: number }> => { + const types = await enumerateAttestationTypes(client, lineage, cfg!.registryPkg); + + // 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, 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 }; + + // 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. 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) { + 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; + const owner = + ownerField && typeof ownerField === "object" && "AddressOwner" in ownerField + ? ownerField.AddressOwner + : undefined; + const revokedBox = revokedBoxAddress(cfg!.registryPkg, cfg!.registryId, subject); + const revoked = !!owner && normalizeSuiAddress(owner) === normalizeSuiAddress(revokedBox); + out.push({ + info, + subject: normalizeSuiAddress(subject), + revoked, + }); + } + return { items: out, failures }; + }, + }); +} 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 ( + + + + + + ); +} 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 new file mode 100644 index 00000000..6f3c30df --- /dev/null +++ b/app/src/lib/attestations.ts @@ -0,0 +1,175 @@ +// 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 { 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 { + /** 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; + /** 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: 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 { + /** attestations package id (the `Attestation<>` wrapper type). */ + registryPkg: SuiAddress; + /** The shared Registry object id — parent for per-subject Box derivation. */ + registryId: ObjectId; + 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. + * + * See `attestation-config.example.ts` for a populated example of this shape. + */ +const CHECKED_IN_CONFIG: AttestationConfig = { + registryPkg: "", + registryId: "", + trustedAttestors: [], +}; + +let cached: AttestationConfig | null | undefined; + +/** + * 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) + : CHECKED_IN_CONFIG.trustedAttestors.length > 0 + ? CHECKED_IN_CONFIG + : null; + } + return cached; +} + +// === Box address === + +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: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, + revoked: boolean, +): ObjectId { + const keyBytes = BoxKey.serialize({ + subject: normalizeSuiAddress(subject), + revoked, + }).toBytes(); + return deriveObjectID( + registryId, + `${registryPkg}::attestations::BoxKey`, + keyBytes, + ); +} + +/** 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: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, +): ObjectId { + return derivedBox(registryPkg, registryId, subject, false); +} + +/** 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: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, +): ObjectId { + return derivedBox(registryPkg, registryId, subject, true); +} + +// === Attestation info + mapping === + +export interface AttestationInfo { + id: ObjectId; + /** The inner type `T`, e.g. `0xAUD::audit::Audit`. The full object type is + * always `${registryPkg}::attestations::Attestation<${innerType}>`. */ + innerType: string; + /** Server-rendered Display v2 fields (all values are strings). */ + display: Record; +} + +const ATTESTATION_RE = /::attestations::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, + innerType: m[1]!, + display: (d.display?.data ?? {}) as Record, + }; +} + +/** The defining (origin) package id of an inner type 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: SuiAddress): 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, + innerType: string, +): TrustedAttestor | undefined { + const pkg = innerTypePackage(innerType); + return cfg.trustedAttestors.find((a) => + a.lineage.some((id) => normalizeSuiAddress(id) === pkg), + ); +} 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/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs new file mode 100644 index 00000000..e9ea3d1b --- /dev/null +++ b/crates/mvr-api/examples/demo_server.rs @@ -0,0 +1,336 @@ +//! 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 /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::{GitInfo, NameRecord, Package, PackageDependency, PackageInfo}, + schema::{git_infos, name_records, package_dependencies, package_infos, packages}, + MIGRATIONS, +}; + +// 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_REF: &str = "mdgeorge/attest-positive"; +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"; +const PKG_INFO_REGISTRY: &str = + "0x00000000000000000000000000000000000000000000000000000000dee00003"; + +#[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"); + // 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()?; + 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.", + None, + ) + .await?; + seed_versions( + &mut db, + "@demo/dependency", + &dependency_versions, + PKG_INFO_DEPENDENCY, + "A dependency of @demo/subject.", + Some("demo/dependency_example"), + ) + .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, + "@mysten/attestations", + registry_pkg, + PKG_INFO_REGISTRY, + "The attestation registry package: Attestation, per-subject boxes, Display.", + Some("packages/attestations"), + ) + .await?; + 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). + { + 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?; + } + + // 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 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 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, + &versions, + &pkg_info_id, + "A trusted attester in the demo.", + Some(&git_path), + ) + .await?; + println!("seeded {mvr_name} -> {} version(s)", versions.len()); + } + } + + // 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): {}", + 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); + + run_server( + url, + DbArgs::default(), + Network::Mainnet, + args.port, + CancellationToken::new(), + SocketAddr::from_str("0.0.0.0:9184")?, + ) + .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 { + "vuln_example" => "@example-scanner/disclosures".to_string(), + // Each auditor is its own org; MVR names can't contain `_` (auditor_a). + other => format!("@{}/audit", other.replace('_', "-")), + } +} + +/// 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() +} + +/// 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, + package_id: &str, + pkg_info_id: &str, + description: &str, + git_path: Option<&str>, +) -> anyhow::Result<()> { + 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: original_id.to_string(), + 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, + }; + 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(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 { + // 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_REF.to_string()), + }]) + .execute(&mut *conn) + .await?; + } + Ok(()) +} diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh new file mode 100644 index 00000000..e59f4395 --- /dev/null +++ b/scripts/write-demo-env.sh @@ -0,0 +1,102 @@ +#!/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). +# +# demo-ids.json (produced by the attestation-registry repo's run-demo.sh): +# { +# "registryId": "0x…", // the shared Registry object id +# "attestationRegistryPkg": "0x…", // the attestations 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 +# 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:-${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}" + +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 client-side trusted set. +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` 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": "@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": { + "name": "Example Security Scanner", + "mvrName": "@example-scanner/disclosures", + "iconUrl": f"{RAW}/demo/vuln_reporter_a/icon.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": meta.get("name", a["name"]), + "iconUrl": meta.get("iconUrl"), + "mvrName": meta.get("mvrName"), + "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_LOCAL_GRAPHQL=\"$GRAPHQL\"" + echo "NEXT_PUBLIC_ATTESTATION_CONFIG='$CONFIG'" +} > "$APP_ENV" + +echo "wrote $APP_ENV" +cat "$APP_ENV"