From 0479bcf0c8c1a7dd5e92428d01d6a358dd3a7399 Mon Sep 17 00:00:00 2001 From: Monserrat Mendoza <210256392+M0nsxx@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:06:51 -0600 Subject: [PATCH] feat(web): add public Audit Trail CID verification page New /verify page: paste a CID anchored by the Audit Trail Node and independently confirm it instead of trusting a dashboard's rendering. Fetches the record through the existing /api/ipfs proxy (auto-retry for the 60-90s gateway propagation window), recomputes the sha-256 of any embedded record client-side with Web Crypto, and verifies an agent attestation's ed25519 signature server-side (/api/audit/verify-signature). Scoped as a single-CID lookup, not a public feed: anchors made for someone else's evidence are not meant for open browsing. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/(app)/verify/page.tsx | 460 ++++++++++++++++++ .../app/api/audit/verify-signature/route.ts | 79 +++ apps/web/components/layout/AppLayout.tsx | 3 +- apps/web/components/layout/Navbar.tsx | 3 +- apps/web/dictionaries/en.json | 46 +- apps/web/dictionaries/es.json | 46 +- apps/web/lib/auditVerify.ts | 82 ++++ 7 files changed, 715 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/(app)/verify/page.tsx create mode 100644 apps/web/app/api/audit/verify-signature/route.ts create mode 100644 apps/web/lib/auditVerify.ts diff --git a/apps/web/app/(app)/verify/page.tsx b/apps/web/app/(app)/verify/page.tsx new file mode 100644 index 00000000..303c0c9c --- /dev/null +++ b/apps/web/app/(app)/verify/page.tsx @@ -0,0 +1,460 @@ +'use client'; + +import { Suspense, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { + Search, Shield, ShieldCheck, ShieldAlert, ShieldQuestion, Info, + Copy, CheckCircle2, ExternalLink, RefreshCw, Hash, Clock, Tag as TagIcon, + FileCode2, AlertTriangle, Loader2, ArrowRight, +} from 'lucide-react'; +import { useLanguage } from '@/context/LanguageContext'; +import { + extractCid, checkRecordIntegrity, networkLabel, explorerTxUrl, explorerAccountUrl, + type AnchoredDoc, type RecordCheck, +} from '@/lib/auditVerify'; + +const MAX_ATTEMPTS = 10; +const RETRY_DELAY_MS = 9000; // 10 attempts * 9s ≈ the 60-90s gateway propagation window + +type Phase = 'idle' | 'searching' | 'found' | 'error_invalid' | 'error_not_found' | 'error_not_audit_doc' | 'error_generic'; +type AttestationState = 'checking' | 'valid' | 'invalid' | 'none' | 'error'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const GATEWAY = (process.env.NEXT_PUBLIC_GATEWAY_URL || 'https://gateway.pinata.cloud').replace(/\/$/, ''); +const gatewayUrl = (cid: string) => `${GATEWAY.startsWith('http') ? GATEWAY : `https://${GATEWAY}`}/ipfs/${cid}`; + +function VerifyContent() { + const { t } = useLanguage(); + const tv = t.verifyAudit; + const router = useRouter(); + const searchParams = useSearchParams(); + + const [cidInput, setCidInput] = useState(''); + const [phase, setPhase] = useState('idle'); + const [attempt, setAttempt] = useState(0); + const [cid, setCid] = useState(''); + const [doc, setDoc] = useState(null); + const [recordCheck, setRecordCheck] = useState(null); + const [attestation, setAttestation] = useState('none'); + const [copiedField, setCopiedField] = useState(null); + + const cancelledRef = useRef(false); + useEffect(() => { + cancelledRef.current = false; + return () => { cancelledRef.current = true; }; + }, []); + + const runAttestationCheck = async (targetDoc: AnchoredDoc) => { + if (!targetDoc.agent?.key || !targetDoc.agent?.signature) { + setAttestation('none'); + return; + } + setAttestation('checking'); + try { + const res = await fetch('/api/audit/verify-signature', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key: targetDoc.agent.key, + signature: targetDoc.agent.signature, + contentSha256: targetDoc.content_sha256, + }), + }); + const data = await res.json(); + if (cancelledRef.current) return; + setAttestation(data?.valid ? 'valid' : 'invalid'); + } catch { + if (cancelledRef.current) return; + // Network/server error — NOT the same as "signature invalid". Never + // claim a verdict we couldn't actually check. + setAttestation('error'); + } + }; + + const runLookup = async (targetCid: string, attemptNum: number) => { + setPhase('searching'); + setAttempt(attemptNum); + try { + const res = await fetch(`/api/ipfs?cid=${encodeURIComponent(targetCid)}`, { signal: AbortSignal.timeout(12_000) }); + if (cancelledRef.current) return; + if (!res.ok) { + if (attemptNum < MAX_ATTEMPTS) { + await sleep(RETRY_DELAY_MS); + if (cancelledRef.current) return; + return runLookup(targetCid, attemptNum + 1); + } + setPhase('error_not_found'); + return; + } + const data = await res.json(); + if (cancelledRef.current) return; + if (!data || typeof data.content_sha256 !== 'string') { + setPhase('error_not_audit_doc'); + return; + } + + setDoc(data); + setCid(targetCid); + setPhase('found'); + router.replace(`/verify?cid=${encodeURIComponent(targetCid)}`, { scroll: false }); + + const rc = await checkRecordIntegrity(data); + if (cancelledRef.current) return; + setRecordCheck(rc); + + await runAttestationCheck(data); + } catch { + if (cancelledRef.current) return; + if (attemptNum < MAX_ATTEMPTS) { + await sleep(RETRY_DELAY_MS); + if (cancelledRef.current) return; + return runLookup(targetCid, attemptNum + 1); + } + setPhase('error_not_found'); + } + }; + + const startLookup = (raw: string) => { + const extracted = extractCid(raw); + if (!extracted) { + setPhase('error_invalid'); + return; + } + setDoc(null); + setRecordCheck(null); + setAttestation('none'); + void runLookup(extracted, 1); + }; + + // Shareable links: /verify?cid=Qm... auto-runs the lookup once. + const autoRanRef = useRef(false); + useEffect(() => { + if (autoRanRef.current) return; + const fromUrl = searchParams.get('cid'); + if (fromUrl) { + autoRanRef.current = true; + setCidInput(fromUrl); + startLookup(fromUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + startLookup(cidInput); + }; + + const handleReset = () => { + cancelledRef.current = false; + setPhase('idle'); + setDoc(null); + setCid(''); + setCidInput(''); + setRecordCheck(null); + setAttestation('none'); + router.replace('/verify', { scroll: false }); + }; + + const copy = (text: string, field: string) => { + navigator.clipboard.writeText(text); + setCopiedField(field); + toast.success(tv.result.copied); + setTimeout(() => setCopiedField(null), 2000); + }; + + const isSearching = phase === 'searching'; + const network = doc?.network ? networkLabel(doc.network) : 'testnet'; + + return ( +
+
+ +
+ {/* Header */} + +
+ + {tv.badge} +
+

+ {tv.title_pre} {tv.title_span} +

+

+ {tv.subtitle} +

+

+ {tv.scope_note} +

+
+ + {/* Input */} + + +
+
+ + setCidInput(e.target.value)} + placeholder={tv.input_placeholder} + disabled={isSearching} + className="w-full bg-black/40 border border-white/10 rounded-2xl py-4 pl-11 pr-4 text-white font-mono text-sm focus:outline-none focus:border-emerald-500/40 transition-all placeholder:text-gray-700 disabled:opacity-50" + /> +
+ +
+
+ + {/* Status / results */} + + {phase === 'searching' && ( + + +

{tv.searching_title}

+ {attempt > 1 && ( +

+ {tv.searching_hint.replace('{attempt}', String(attempt)).replace('{max}', String(MAX_ATTEMPTS))} +

+ )} +
+ )} + + {(phase === 'error_invalid' || phase === 'error_not_found' || phase === 'error_not_audit_doc' || phase === 'error_generic') && ( + + +

+ {phase === 'error_invalid' && tv.error_invalid_cid} + {phase === 'error_not_found' && tv.error_not_found} + {phase === 'error_not_audit_doc' && tv.error_not_audit_doc} + {phase === 'error_generic' && tv.error_generic} +

+ {phase !== 'error_invalid' && ( + + )} +
+ )} + + {phase === 'found' && doc && ( + + {/* Verdict banner */} +
+ {recordCheck === 'mismatch' + ? + : recordCheck === 'match' + ? + : } +
+

+ {recordCheck === 'mismatch' && tv.result.verdict_mismatch} + {recordCheck === 'match' && tv.result.verdict_match} + {(recordCheck === 'no_record' || recordCheck === null) && tv.result.verdict_no_record} +

+ {recordCheck === 'no_record' && ( +

{tv.result.no_record_note}

+ )} +
+
+ + {/* Fields */} +
+ + copy(cid, 'cid')} copied={copiedField === 'cid'} /> + + + copy(doc.content_sha256, 'hash')} copied={copiedField === 'hash'} /> + + + + {network} + + + {doc.anchoredAt && ( + + {new Date(doc.anchoredAt).toLocaleString()} + + )} + {doc.tag && ( + + {doc.tag} + + )} + {doc.version && ( + + v{doc.version} + + )} + {doc.txHash && ( + + + {doc.txHash.slice(0, 16)}… + + + )} + + +
+ + {/* Embedded record */} + {doc.record && ( +
+

+ {tv.result.record_label} +

+
+                                        {JSON.stringify(doc.record, null, 2)}
+                                    
+
+ )} + + {/* Attestation */} +
+

+ {tv.result.attestation_title} +

+ {doc.agent ? ( +
+
+ {tv.result.attested_by} + + {doc.agent.key} + +
+
+ {attestation === 'checking' && <>{tv.result.attestation_checking}} + {attestation === 'valid' && <>{tv.result.attestation_valid}} + {attestation === 'invalid' && <>{tv.result.attestation_invalid}} + {attestation === 'error' && ( + <> + + {tv.error_generic} + + + )} +
+
+ ) : ( +

{tv.result.attestation_none}

+ )} +
+ + +
+ )} +
+
+
+ ); +} + +function FieldRow({ icon: Icon, label, children }: { icon: any; label: string; children: React.ReactNode }) { + return ( +
+
+ + {label} +
+
{children}
+
+ ); +} + +function MonoCopyValue({ value, onCopy, copied }: { value: string; onCopy: () => void; copied: boolean }) { + return ( +
+ {value} + +
+ ); +} + +export default function VerifyPage() { + return ( + + + + }> + + + ); +} diff --git a/apps/web/app/api/audit/verify-signature/route.ts b/apps/web/app/api/audit/verify-signature/route.ts new file mode 100644 index 00000000..3af9e2fa --- /dev/null +++ b/apps/web/app/api/audit/verify-signature/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import crypto from 'node:crypto'; +import { Keypair, StrKey } from '@stellar/stellar-sdk'; + +// Verifies an agent attestation signature server-side (default Node.js runtime — +// Buffer and node:crypto are native here; they are NOT available in the browser +// bundle for this app, see next.config.mjs's `buffer: false` client fallback). +// +// Same rule as packages/agent/src/services/agentAttestation.ts: the signed +// statement uses a separate domain, "nirium-audit-v1:", never +// the bare hash, and two signature encodings are accepted — raw UTF-8 bytes of +// the statement, and its SEP-53 digest — because both require the private key, +// so accepting either does not weaken the proof. +// +// This route only verifies; it never anchors anything and reads no database. + +const SEP53_PREFIX = Buffer.from('Stellar Signed Message:\n', 'utf8'); +const SHA256_HEX = /^[0-9a-fA-F]{64}$/; + +function sep53Digest(message: string): Buffer { + return crypto + .createHash('sha256') + .update(Buffer.concat([SEP53_PREFIX, Buffer.from(message, 'utf8')])) + .digest(); +} + +function decodeSignature(signature: string): Buffer | null { + try { + const b64 = Buffer.from(signature, 'base64'); + if (b64.length === 64) return b64; + } catch { /* try hex below */ } + if (/^[0-9a-fA-F]{128}$/.test(signature)) return Buffer.from(signature, 'hex'); + return null; +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ valid: false, error: 'invalid JSON body' }, { status: 400 }); + } + + const { key, signature, contentSha256 } = (body ?? {}) as Record; + + if (typeof contentSha256 !== 'string' || !SHA256_HEX.test(contentSha256)) { + return NextResponse.json({ valid: false, error: '`contentSha256` must be a 64-char sha-256 hex' }, { status: 400 }); + } + if (typeof key !== 'string' || !StrKey.isValidEd25519PublicKey(key)) { + return NextResponse.json({ valid: false, error: '`key` must be a Stellar ed25519 public key (G...)' }, { status: 400 }); + } + if (typeof signature !== 'string') { + return NextResponse.json({ valid: false, error: '`signature` must be a base64 or hex ed25519 signature' }, { status: 400 }); + } + + const sig = decodeSignature(signature); + if (!sig) { + return NextResponse.json({ valid: false, error: '`signature` must decode to a 64-byte ed25519 signature (base64 or hex)' }, { status: 400 }); + } + + const statement = `nirium-audit-v1:${contentSha256.toLowerCase()}`; + let kp: Keypair; + try { + kp = Keypair.fromPublicKey(key); + } catch { + return NextResponse.json({ valid: false, error: '`key` is not a valid ed25519 public key' }, { status: 400 }); + } + + const candidates = [Buffer.from(statement, 'utf8'), sep53Digest(statement)]; + for (const data of candidates) { + try { + if (kp.verify(data, sig)) { + return NextResponse.json({ valid: true, statement }); + } + } catch { /* try the next encoding */ } + } + + return NextResponse.json({ valid: false, statement, error: 'signature does not verify against this key' }); +} diff --git a/apps/web/components/layout/AppLayout.tsx b/apps/web/components/layout/AppLayout.tsx index ca42a773..96aee917 100644 --- a/apps/web/components/layout/AppLayout.tsx +++ b/apps/web/components/layout/AppLayout.tsx @@ -9,7 +9,7 @@ import { Shield, ShieldCheck, Code2, BookOpen, Menu, X, ChevronRight, Activity, Zap, Globe, Layers, Cpu, Send, Building2 -, KeyRound, Home +, KeyRound, Home, FileCheck } from "lucide-react"; import { useFreighter } from "@/hooks/useFreighter"; import { useLanguage } from "@/context/LanguageContext"; @@ -50,6 +50,7 @@ export function AppSidebar({ onClose }: { onClose?: () => void }) { { href: "/docs", label: t.nav.docs, icon: BookOpen, accent: "#8B5CF6" }, { href: "/developers", label: t.nav.developers, icon: Code2, accent: "#6366F1" }, { href: "/keys", label: t.nav.apikeys, icon: KeyRound, accent: "#FFD23F" }, + { href: "/verify", label: t.nav.verifyAudit, icon: FileCheck, accent: "#10B981" }, ] } ]; diff --git a/apps/web/components/layout/Navbar.tsx b/apps/web/components/layout/Navbar.tsx index b109b217..398d699e 100644 --- a/apps/web/components/layout/Navbar.tsx +++ b/apps/web/components/layout/Navbar.tsx @@ -6,7 +6,7 @@ import { useFreighter } from "@/hooks/useFreighter"; import { Menu, X, Zap, Shield, Activity, LogOut, CreditCard, Building2, Code2, ArrowUpRight, Newspaper, KeyRound, Mail, - ChevronDown, Send, ShieldCheck, Cpu + ChevronDown, Send, ShieldCheck, Cpu, FileCheck } from "lucide-react"; import { useState, useEffect, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; @@ -76,6 +76,7 @@ export default function Navbar() { { name: t.nav.docs, href: "/docs", icon: Newspaper }, { name: t.nav.developers, href: "/developers", icon: Code2 }, { name: t.nav.api, href: "/keys", icon: KeyRound }, + { name: t.nav.verifyAudit, href: "/verify", icon: FileCheck }, ], }, ]; diff --git a/apps/web/dictionaries/en.json b/apps/web/dictionaries/en.json index adfecb22..edd7974a 100644 --- a/apps/web/dictionaries/en.json +++ b/apps/web/dictionaries/en.json @@ -36,7 +36,51 @@ "group_core": "CORE", "group_treasury": "TREASURY", "group_developer": "DEVELOPER", - "product_menu": "Product" + "product_menu": "Product", + "verifyAudit": "Verify Audit" + }, + "verifyAudit": { + "badge": "Audit Trail", + "title_pre": "Verify an", + "title_span": "Audit Trail", + "subtitle": "Paste a CID anchored by Nirium's Audit Trail Node and independently confirm what it declares, when it was anchored, and — if the record was recomputed and compared, not just displayed — whether it still matches.", + "scope_note": "This is a single-record lookup, not a public feed of every anchor: a CID someone anchored for their own evidence (a third party's audit package, for example) isn't meant for open browsing, so it's only shown when you already have its CID.", + "input_label": "CID or IPFS gateway URL", + "input_placeholder": "Qm... or https://gateway.pinata.cloud/ipfs/Qm...", + "button": "Verify", + "button_loading": "Verifying…", + "searching_title": "Looking up this CID on IPFS…", + "searching_hint": "Gateway propagation can take 60–90 seconds right after anchoring. Retrying automatically (attempt {attempt}/{max}).", + "retry_button": "Try again", + "error_invalid_cid": "That doesn't look like a CID — paste the bare CID (Qm… or bafy…) or a full gateway URL containing /ipfs/.", + "error_not_found": "Could not reach this record on the IPFS gateway after several attempts. It may still be propagating, or the CID may not exist.", + "error_not_audit_doc": "This CID resolved, but its content doesn't look like a Nirium audit record — no `content_sha256` field was found.", + "error_generic": "Something went wrong reading this record. Please try again.", + "new_lookup": "Verify another CID", + "result": { + "verdict_match": "Record matches the declared hash", + "verdict_mismatch": "Record does NOT match the declared hash", + "verdict_no_record": "Anchor found — no embedded record to recompute", + "field_cid": "CID", + "field_hash": "Declared content SHA-256", + "field_network": "Network", + "field_anchored_at": "Anchored at", + "field_tag": "Tag", + "field_schema": "Doc schema", + "field_tx": "Linked transaction", + "no_record_note": "This anchor only declares a hash — the original evidence lives with whoever anchored it, not inside this document. There is nothing embedded here to recompute; compare the hash above against your own copy of that evidence.", + "record_label": "Embedded record", + "view_raw": "View raw JSON on IPFS", + "copy": "Copy", + "copied": "Copied", + "attestation_title": "Who declared it", + "attested_by": "Attested by", + "attestation_checking": "Verifying signature…", + "attestation_valid": "Signature verified — this key did sign this exact hash", + "attestation_invalid": "Signature does NOT verify — do not trust this attribution", + "attestation_none": "No agent attestation on this record. The CID still proves the content wasn't altered after anchoring; it just doesn't prove who anchored it.", + "view_on_explorer": "View on Stellar Expert" + } }, "blog": { "badge": "Blog", diff --git a/apps/web/dictionaries/es.json b/apps/web/dictionaries/es.json index 9e178552..59ad50a9 100644 --- a/apps/web/dictionaries/es.json +++ b/apps/web/dictionaries/es.json @@ -36,7 +36,51 @@ "group_core": "CORE", "group_treasury": "TESORERÍA", "group_developer": "DEVELOPER", - "product_menu": "Producto" + "product_menu": "Producto", + "verifyAudit": "Verificar Audit" + }, + "verifyAudit": { + "badge": "Audit Trail", + "title_pre": "Verificar un", + "title_span": "Audit Trail", + "subtitle": "Pega un CID anclado por el Audit Trail Node de Nirium y confirma de forma independiente qué declara, cuándo se ancló y — si el record se recalculó y comparó, no solo se mostró — si todavía coincide.", + "scope_note": "Esto es una búsqueda de un solo registro, no un feed público de todos los anclajes: un CID que alguien ancló para su propia evidencia (el paquete de auditoría de un tercero, por ejemplo) no está pensado para navegación abierta, así que solo se muestra cuando ya tienes su CID.", + "input_label": "CID o URL del gateway de IPFS", + "input_placeholder": "Qm... o https://gateway.pinata.cloud/ipfs/Qm...", + "button": "Verificar", + "button_loading": "Verificando…", + "searching_title": "Buscando este CID en IPFS…", + "searching_hint": "La propagación del gateway puede tardar 60–90 segundos justo después de anclar. Reintentando automáticamente (intento {attempt}/{max}).", + "retry_button": "Intentar de nuevo", + "error_invalid_cid": "Eso no parece un CID — pega el CID solo (Qm… o bafy…) o una URL completa del gateway que contenga /ipfs/.", + "error_not_found": "No se pudo encontrar este registro en el gateway de IPFS tras varios intentos. Puede seguir propagándose, o el CID puede no existir.", + "error_not_audit_doc": "Este CID resolvió, pero su contenido no parece un registro de auditoría de Nirium — no se encontró el campo `content_sha256`.", + "error_generic": "Algo salió mal leyendo este registro. Intenta de nuevo.", + "new_lookup": "Verificar otro CID", + "result": { + "verdict_match": "El record coincide con el hash declarado", + "verdict_mismatch": "El record NO coincide con el hash declarado", + "verdict_no_record": "Anclaje encontrado — sin record embebido que recalcular", + "field_cid": "CID", + "field_hash": "SHA-256 declarado del contenido", + "field_network": "Red", + "field_anchored_at": "Anclado el", + "field_tag": "Tag", + "field_schema": "Esquema del documento", + "field_tx": "Transacción vinculada", + "no_record_note": "Este anclaje solo declara un hash — la evidencia original vive con quien lo ancló, no dentro de este documento. No hay nada embebido aquí que recalcular; compara el hash de arriba contra tu propia copia de esa evidencia.", + "record_label": "Record embebido", + "view_raw": "Ver JSON crudo en IPFS", + "copy": "Copiar", + "copied": "Copiado", + "attestation_title": "Quién lo declaró", + "attested_by": "Atestiguado por", + "attestation_checking": "Verificando firma…", + "attestation_valid": "Firma verificada — esta llave sí firmó este hash exacto", + "attestation_invalid": "La firma NO verifica — no confíes en esta atribución", + "attestation_none": "Este registro no tiene atestiguamiento de agente. El CID sigue probando que el contenido no se alteró después de anclarse; solo no prueba quién lo ancló.", + "view_on_explorer": "Ver en Stellar Expert" + } }, "blog": { "badge": "Blog", diff --git a/apps/web/lib/auditVerify.ts b/apps/web/lib/auditVerify.ts new file mode 100644 index 00000000..5645413b --- /dev/null +++ b/apps/web/lib/auditVerify.ts @@ -0,0 +1,82 @@ +/** + * Client-side helpers for "Verificar un Audit Trail" (/verify). + * + * Deliberately Buffer-free — next.config.mjs sets `buffer: false` in the + * client webpack fallback, so anything relying on the Node Buffer global in + * browser code is not safe to assume here. Hashing uses only + * TextEncoder + crypto.subtle (standard Web Crypto), available in every + * modern browser without a polyfill. + * + * Mirrors the server's anchoring contract — packages/agent/src/services/ + * auditAnchorDoc.ts (document shape) and agentAttestation.ts (statement + * domain) — so a visitor can independently recompute what the document + * claims instead of trusting this page's own rendering of it. + */ + +export interface AgentAttestation { + id?: string; + key: string; + alg: string; + statement: string; + signature: string; +} + +export interface AnchoredDoc { + version?: string; + node?: string; + content_sha256: string; + agent?: AgentAttestation; + record?: Record; + txHash?: string; + network?: 'stellar:pubnet' | 'stellar:testnet'; + tag?: string; + anchoredAt?: string; +} + +const CID_RE = /^[a-zA-Z0-9]{40,80}$/; + +/** Accepts a bare CID or a full gateway URL and returns the CID, or null if unrecognizable. */ +export function extractCid(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const afterIpfs = trimmed.includes('/ipfs/') ? trimmed.split('/ipfs/').pop()! : trimmed; + const cid = afterIpfs.split(/[?#]/)[0].replace(/\/+$/, ''); + return CID_RE.test(cid) ? cid : null; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + // `BufferSource` in the newer DOM lib types is generic over ArrayBuffer vs + // SharedArrayBuffer; a plain Uint8Array's inferred buffer type doesn't + // always match, so cast explicitly rather than widen the whole signature. + const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource); + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** sha-256 of JSON.stringify(record), exactly as the agent computed it at anchor time. */ +export async function hashRecord(record: Record): Promise { + return sha256Hex(new TextEncoder().encode(JSON.stringify(record))); +} + +export type RecordCheck = 'match' | 'mismatch' | 'no_record'; + +/** Recomputes the hash of the embedded `record` (if any) and compares it to the + * declared `content_sha256`. When there is no `record`, there is nothing in the + * anchored document itself to recompute against — `content_sha256` is simply the + * hash the submitter declared for evidence they hold elsewhere. */ +export async function checkRecordIntegrity(doc: AnchoredDoc): Promise { + if (!doc.record) return 'no_record'; + const recomputed = await hashRecord(doc.record); + return recomputed === doc.content_sha256.toLowerCase() ? 'match' : 'mismatch'; +} + +export function networkLabel(network?: string): 'mainnet' | 'testnet' { + return network === 'stellar:pubnet' ? 'mainnet' : 'testnet'; +} + +export function explorerTxUrl(txHash: string, network?: string): string { + return `https://stellar.expert/explorer/${networkLabel(network) === 'mainnet' ? 'public' : 'testnet'}/tx/${txHash}`; +} + +export function explorerAccountUrl(address: string, network?: string): string { + return `https://stellar.expert/explorer/${networkLabel(network) === 'mainnet' ? 'public' : 'testnet'}/account/${address}`; +}