diff --git a/contract/src/lib.rs b/contract/src/lib.rs index a555e88..753e30f 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod config; pub mod hash_validator; pub mod metrics; +pub mod module; pub mod rate_limit; pub mod stellar; @@ -24,6 +25,7 @@ use tracing::{info, warn}; use cache::CacheBackend; use hash_validator::{HashValidator, ValidationError as HashValidationError}; use metrics::MetricsRegistry; +use module::{network::network_handler, ownership_chain::chain_handler}; use stellar::{derive_account_id, StellarClient, TransactionRecord}; // Application state @@ -188,6 +190,10 @@ pub fn app(state: AppState) -> Router { .route("/submit", post(submit_document)) .route("/revoke", post(revoke_document)) .route("/transfer", post(record_transfer)) + // CT-11: Stellar network switcher + .route("/module/network", get(network_handler)) + // CT-22: Ownership chain validator + .route("/module/chain/:document_hash", get(chain_handler)) .layer(TraceLayer::new_for_http()) .with_state(state) } diff --git a/contract/src/module/mod.rs b/contract/src/module/mod.rs new file mode 100644 index 0000000..d21b1cc --- /dev/null +++ b/contract/src/module/mod.rs @@ -0,0 +1,2 @@ +pub mod network; +pub mod ownership_chain; diff --git a/contract/src/module/network/mod.rs b/contract/src/module/network/mod.rs new file mode 100644 index 0000000..65e275b --- /dev/null +++ b/contract/src/module/network/mod.rs @@ -0,0 +1,179 @@ +//! CT-11 — StellarNetworkSwitcher +//! +//! Exposes the currently configured Stellar network (testnet / mainnet) +//! and validates the STELLAR_NETWORK environment variable at startup. +//! +//! Routes wired in `lib.rs`: +//! GET /module/network → [`network_handler`] + +use std::env; + +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde::Serialize; + +// ──────────────────────────────────────────────────────────────────────────── +// Public types +// ──────────────────────────────────────────────────────────────────────────── + +/// The two supported Stellar networks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StellarNetwork { + Testnet, + Mainnet, +} + +impl StellarNetwork { + /// Human-readable network name returned in API responses. + pub fn as_str(&self) -> &'static str { + match self { + Self::Testnet => "testnet", + Self::Mainnet => "mainnet", + } + } + + /// Horizon base URL for this network. + pub fn horizon_url(&self) -> &'static str { + match self { + Self::Testnet => "https://horizon-testnet.stellar.org", + Self::Mainnet => "https://horizon.stellar.org", + } + } +} + +/// Parse a network string (case-insensitive) into a [`StellarNetwork`] variant. +/// +/// Returns `Ok` for `"testnet"` and `"mainnet"`, and `Err` with a descriptive +/// message for any other value. +/// +/// # Examples +/// ``` +/// use stellar_doc_verifier::module::network::{parse_network, StellarNetwork}; +/// +/// assert_eq!(parse_network("testnet").unwrap(), StellarNetwork::Testnet); +/// assert_eq!(parse_network("MAINNET").unwrap(), StellarNetwork::Mainnet); +/// assert!(parse_network("devnet").is_err()); +/// ``` +pub fn parse_network(value: &str) -> Result { + match value.to_lowercase().as_str() { + "testnet" => Ok(StellarNetwork::Testnet), + "mainnet" => Ok(StellarNetwork::Mainnet), + other => Err(format!( + "unrecognised STELLAR_NETWORK value '{}': expected 'testnet' or 'mainnet'", + other + )), + } +} + +/// Read and validate `STELLAR_NETWORK` from the environment. +/// +/// Returns the parsed variant on success. Returns an `Err` with a clear +/// message if the variable is missing or set to an unrecognised value — the +/// caller (main.rs) should treat this as a fatal startup error. +pub fn network_from_env() -> Result { + let raw = env::var("STELLAR_NETWORK").unwrap_or_else(|_| "testnet".to_string()); + parse_network(&raw).map_err(|e| { + format!( + "Application startup error: {}. \ + Set STELLAR_NETWORK to 'testnet' or 'mainnet' and restart.", + e + ) + }) +} + +// ──────────────────────────────────────────────────────────────────────────── +// HTTP handler +// ──────────────────────────────────────────────────────────────────────────── + +/// Response body for `GET /module/network`. +#[derive(Debug, Serialize)] +pub struct NetworkResponse { + pub network: &'static str, + pub horizon_url: &'static str, +} + +/// `GET /module/network` — returns the currently configured network and its +/// Horizon URL. +/// +/// The STELLAR_NETWORK env var was already validated at startup, so this +/// handler simply re-reads it (defaulting to testnet) and returns 200. +pub async fn network_handler() -> impl IntoResponse { + match network_from_env() { + Ok(net) => ( + StatusCode::OK, + Json(NetworkResponse { + network: net.as_str(), + horizon_url: net.horizon_url(), + }), + ) + .into_response(), + Err(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg })), + ) + .into_response(), + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Unit tests +// ──────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_testnet_lowercase() { + assert_eq!(parse_network("testnet").unwrap(), StellarNetwork::Testnet); + } + + #[test] + fn parse_mainnet_lowercase() { + assert_eq!(parse_network("mainnet").unwrap(), StellarNetwork::Mainnet); + } + + #[test] + fn parse_mixed_case_testnet() { + assert_eq!(parse_network("Testnet").unwrap(), StellarNetwork::Testnet); + assert_eq!(parse_network("TESTNET").unwrap(), StellarNetwork::Testnet); + assert_eq!(parse_network("TestNet").unwrap(), StellarNetwork::Testnet); + } + + #[test] + fn parse_mixed_case_mainnet() { + assert_eq!(parse_network("Mainnet").unwrap(), StellarNetwork::Mainnet); + assert_eq!(parse_network("MAINNET").unwrap(), StellarNetwork::Mainnet); + } + + #[test] + fn parse_invalid_returns_err() { + let err = parse_network("devnet").unwrap_err(); + assert!( + err.contains("unrecognised STELLAR_NETWORK value 'devnet'"), + "unexpected error message: {err}" + ); + } + + #[test] + fn parse_empty_string_returns_err() { + assert!(parse_network("").is_err()); + } + + #[test] + fn network_as_str_values() { + assert_eq!(StellarNetwork::Testnet.as_str(), "testnet"); + assert_eq!(StellarNetwork::Mainnet.as_str(), "mainnet"); + } + + #[test] + fn network_horizon_urls() { + assert_eq!( + StellarNetwork::Testnet.horizon_url(), + "https://horizon-testnet.stellar.org" + ); + assert_eq!( + StellarNetwork::Mainnet.horizon_url(), + "https://horizon.stellar.org" + ); + } +} diff --git a/contract/src/module/ownership_chain/mod.rs b/contract/src/module/ownership_chain/mod.rs new file mode 100644 index 0000000..0f12f6f --- /dev/null +++ b/contract/src/module/ownership_chain/mod.rs @@ -0,0 +1,212 @@ +//! CT-22 — OwnershipChainValidator +//! +//! Verifies the continuity of a document's transfer-ownership history stored +//! in the Redis cache. A valid chain requires that each transfer record's +//! `from_owner` matches the previous record's `to_owner`. +//! +//! Routes wired in `lib.rs`: +//! GET /module/chain/:document_hash → [`chain_handler`] + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Serialize; + +use crate::{cache::CacheBackend, AppState, TransferRecord}; +use std::sync::Arc; + +// ──────────────────────────────────────────────────────────────────────────── +// Public result type +// ──────────────────────────────────────────────────────────────────────────── + +/// The outcome of validating an ownership chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status")] +pub enum ValidationResult { + /// The chain is valid: every link connects properly. + Valid, + + /// The chain has a broken link at `gap_at_index`. + /// `expected_owner` is what the chain required; `found_owner` is what was + /// actually present in that record's `from_owner`. + Invalid { + gap_at_index: usize, + expected_owner: String, + found_owner: String, + }, + + /// No transfer records exist for this document hash. + Empty, +} + +// ──────────────────────────────────────────────────────────────────────────── +// Core validation logic +// ──────────────────────────────────────────────────────────────────────────── + +/// Validate the ownership chain for `document_hash` using the provided cache. +/// +/// The function fetches all `TransferRecord`s stored under +/// `transfer:{document_hash}` and checks that the chain is unbroken: +/// +/// ```text +/// record[0].to_owner == record[1].from_owner +/// record[1].to_owner == record[2].from_owner +/// … +/// ``` +/// +/// Returns: +/// - [`ValidationResult::Empty`] — no records found +/// - [`ValidationResult::Valid`] — chain is unbroken +/// - [`ValidationResult::Invalid`] — first gap detected, with position and owners +pub async fn validate_chain( + document_hash: &str, + cache: &CacheBackend, +) -> Result { + let key = format!("transfer:{}", document_hash); + let records: Vec = match cache.get(&key).await? { + Some(v) => v, + None => return Ok(ValidationResult::Empty), + }; + + if records.is_empty() { + return Ok(ValidationResult::Empty); + } + + // A single record is always a valid (trivially unbroken) chain. + if records.len() == 1 { + return Ok(ValidationResult::Valid); + } + + for i in 1..records.len() { + let expected = &records[i - 1].to_owner; + let found = &records[i].from_owner; + if expected != found { + return Ok(ValidationResult::Invalid { + gap_at_index: i, + expected_owner: expected.clone(), + found_owner: found.clone(), + }); + } + } + + Ok(ValidationResult::Valid) +} + +// ──────────────────────────────────────────────────────────────────────────── +// HTTP handler +// ──────────────────────────────────────────────────────────────────────────── + +/// `GET /module/chain/:document_hash` — returns the validation result as JSON. +pub async fn chain_handler( + State(state): State, + Path(document_hash): Path, +) -> impl IntoResponse { + match validate_chain(&document_hash, &state.cache).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Unit tests +// ──────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{CacheBackend, InMemoryCache}; + + /// Helper: build a minimal TransferRecord for testing. + fn make_record(from: &str, to: &str) -> TransferRecord { + TransferRecord { + document_hash: "test_doc".to_string(), + from_owner: from.to_string(), + to_owner: to.to_string(), + transfer_date: "2025-01-01".to_string(), + transfer_reference: "REF".to_string(), + transfer_hash: "hash".to_string(), + memo: "memo".to_string(), + anchored_at: "2025-01-01T00:00:00Z".to_string(), + } + } + + async fn cache_with_records(records: Vec) -> CacheBackend { + let cache = CacheBackend::InMemory(InMemoryCache::new()); + if !records.is_empty() { + let key = format!("transfer:{}", records[0].document_hash); + cache.set(&key, &records, 3600).await.unwrap(); + } + cache + } + + #[tokio::test] + async fn empty_history_returns_empty() { + let cache = CacheBackend::InMemory(InMemoryCache::new()); + let result = validate_chain("no_such_hash", &cache).await.unwrap(); + assert_eq!(result, ValidationResult::Empty); + } + + #[tokio::test] + async fn single_transfer_is_valid() { + let records = vec![make_record("Alice", "Bob")]; + let cache = cache_with_records(records).await; + let result = validate_chain("test_doc", &cache).await.unwrap(); + assert_eq!(result, ValidationResult::Valid); + } + + #[tokio::test] + async fn valid_two_step_chain() { + // Alice → Bob → Charlie + let records = vec![make_record("Alice", "Bob"), make_record("Bob", "Charlie")]; + let cache = cache_with_records(records).await; + let result = validate_chain("test_doc", &cache).await.unwrap(); + assert_eq!(result, ValidationResult::Valid); + } + + #[tokio::test] + async fn gap_at_index_1() { + // Alice → Bob, then Dave → Charlie (gap: expected Bob, found Dave) + let records = vec![ + make_record("Alice", "Bob"), + make_record("Dave", "Charlie"), + ]; + let cache = cache_with_records(records).await; + let result = validate_chain("test_doc", &cache).await.unwrap(); + assert_eq!( + result, + ValidationResult::Invalid { + gap_at_index: 1, + expected_owner: "Bob".to_string(), + found_owner: "Dave".to_string(), + } + ); + } + + #[tokio::test] + async fn gap_at_last_transfer() { + // Alice → Bob → Charlie (valid), Charlie → Eve (valid), then Mallory → Frank (gap) + let records = vec![ + make_record("Alice", "Bob"), + make_record("Bob", "Charlie"), + make_record("Charlie", "Eve"), + make_record("Mallory", "Frank"), + ]; + let cache = cache_with_records(records).await; + let result = validate_chain("test_doc", &cache).await.unwrap(); + assert_eq!( + result, + ValidationResult::Invalid { + gap_at_index: 3, + expected_owner: "Eve".to_string(), + found_owner: "Mallory".to_string(), + } + ); + } +} diff --git a/frontend/app/(protected)/documents/[id]/page.tsx b/frontend/app/(protected)/documents/[id]/page.tsx new file mode 100644 index 0000000..139400a --- /dev/null +++ b/frontend/app/(protected)/documents/[id]/page.tsx @@ -0,0 +1,472 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration +// ───────────────────────────────────────────────────────────────────────────── + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +const WS_BASE = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001").replace(/^http/, "ws"); + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +type DocumentStatus = "PENDING" | "ANALYZING" | "VERIFIED" | "FLAGGED" | "REJECTED"; + +interface DocumentDetail { + id: string; + title: string; + fileHash: string; + status: DocumentStatus; + uploadedAt: string; + updatedAt: string; +} + +interface RiskFlag { + name: string; + weight: number; + detected: boolean; + contribution: number; +} + +interface RiskData { + score: number; + flags: RiskFlag[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function getAuthHeaders(): HeadersInit { + const token = typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function truncateHash(hash: string, chars = 12): string { + if (hash.length <= chars * 2) return hash; + return `${hash.slice(0, chars)}…${hash.slice(-chars)}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Status colour helpers +// ───────────────────────────────────────────────────────────────────────────── + +const STATUS_BADGE: Record = { + PENDING: "bg-yellow-100 text-yellow-800", + ANALYZING: "bg-blue-100 text-blue-800", + VERIFIED: "bg-green-100 text-green-800", + FLAGGED: "bg-orange-100 text-orange-800", + REJECTED: "bg-red-100 text-red-800", +}; + +const RISK_COLOUR = (score: number) => { + if (score < 30) return { stroke: "#22c55e", text: "text-green-600" }; + if (score < 60) return { stroke: "#f59e0b", text: "text-amber-500" }; + return { stroke: "#ef4444", text: "text-red-600" }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Sub-components +// ───────────────────────────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: DocumentStatus }) { + return ( + + {status} + + ); +} + +/** Circular SVG gauge showing the risk score (0-100). */ +function RiskGauge({ score }: { score: number }) { + const R = 52; + const circumference = 2 * Math.PI * R; + const offset = circumference - (score / 100) * circumference; + const { stroke, text } = RISK_COLOUR(score); + + return ( +
+ + + + + {score} + + + / 100 + + +

+ {score < 30 ? "Low Risk" : score < 60 ? "Medium Risk" : "High Risk"} +

+
+ ); +} + +/** Card for a single risk flag. */ +function FlagCard({ flag }: { flag: RiskFlag }) { + return ( +
+
+ {flag.name} + + {flag.detected ? "✗" : "✓"} + +
+
+ Weight: {(flag.weight * 100).toFixed(0)}% + Contribution: {flag.contribution.toFixed(1)} +
+
+ ); +} + +const STATUS_ORDER: DocumentStatus[] = ["PENDING", "ANALYZING", "VERIFIED"]; + +/** Vertical status timeline. */ +function StatusTimeline({ status }: { status: DocumentStatus }) { + const steps = STATUS_ORDER; + const currentIdx = steps.indexOf(status === "FLAGGED" || status === "REJECTED" ? "VERIFIED" : status); + + return ( +
    + {steps.map((step, i) => { + const done = i < currentIdx || (status === "VERIFIED" && step === "VERIFIED"); + const active = + i === currentIdx || + ((status === "FLAGGED" || status === "REJECTED") && step === "VERIFIED"); + const label = step === "VERIFIED" + ? (status === "FLAGGED" ? "FLAGGED" : status === "REJECTED" ? "REJECTED" : "VERIFIED") + : step; + + return ( +
  1. + + {done ? "✓" : i + 1} + +

    + {label} +

    +
  2. + ); + })} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Skeleton loader +// ───────────────────────────────────────────────────────────────────────────── + +function Skeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Share modal +// ───────────────────────────────────────────────────────────────────────────── + +function ShareModal({ docId, onClose }: { docId: string; onClose: () => void }) { + const shareUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/documents/${docId}`; + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(shareUrl).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( +
+
e.stopPropagation()} + > +

Share Document

+
+ {shareUrl} + +
+ +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Page +// ───────────────────────────────────────────────────────────────────────────── + +export default function DocumentDetailPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const docId = params.id; + + const [doc, setDoc] = useState(null); + const [risk, setRisk] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [verifying, setVerifying] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + + // ── WebSocket for real-time status updates (FE-06) ────────────────────── + const wsRef = useRef(null); + + const setupWebSocket = useCallback(() => { + if (!docId || typeof window === "undefined") return; + const ws = new WebSocket(`${WS_BASE}/documents/${docId}/status`); + wsRef.current = ws; + + ws.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as { status?: DocumentStatus }; + if (payload.status) { + setDoc((prev) => prev ? { ...prev, status: payload.status as DocumentStatus } : prev); + } + } catch { + // malformed message — ignore + } + }; + + ws.onerror = () => ws.close(); + ws.onclose = () => { + // Reconnect after 5 s (simple back-off) + setTimeout(setupWebSocket, 5000); + }; + + return () => ws.close(); + }, [docId]); + + // ── Fetch document + risk data ───────────────────────────────────────── + const fetchData = useCallback(async () => { + if (!docId) return; + setLoading(true); + setError(null); + try { + const headers = getAuthHeaders(); + const [docRes, riskRes] = await Promise.all([ + fetch(`${API_BASE}/api/documents/${docId}`, { headers }), + fetch(`${API_BASE}/api/documents/${docId}/risk`, { headers }), + ]); + + if (!docRes.ok) { + throw new Error(docRes.status === 404 ? "Document not found." : `Failed to load document (${docRes.status})`); + } + + const docData: DocumentDetail = await docRes.json(); + setDoc(docData); + + if (riskRes.ok) { + const riskData: RiskData = await riskRes.json(); + setRisk(riskData); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load document."); + } finally { + setLoading(false); + } + }, [docId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + useEffect(() => { + const cleanup = setupWebSocket(); + return () => { + cleanup?.(); + wsRef.current?.close(); + }; + }, [setupWebSocket]); + + // ── Verify on Stellar ────────────────────────────────────────────────── + async function handleVerify() { + if (!docId) return; + setVerifying(true); + try { + const res = await fetch(`${API_BASE}/api/documents/${docId}/verify`, { + method: "POST", + headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`Verification failed (${res.status})`); + // Navigate to verify page to show progress + result + router.push(`/documents/${docId}/verify`); + } catch (err) { + setError(err instanceof Error ? err.message : "Verification failed."); + } finally { + setVerifying(false); + } + } + + // ── Render ───────────────────────────────────────────────────────────── + if (loading) return ; + + if (error || !doc) { + return ( +
+
+

{error ?? "Document not found."}

+ +
+
+ ); + } + + const canVerify = doc.status === "PENDING" || doc.status === "FLAGGED"; + + return ( +
+ {/* ── Header ── */} +
+
+
+

{doc.title}

+

+ {truncateHash(doc.fileHash)} +

+

Uploaded {formatDate(doc.uploadedAt)}

+
+ +
+
+ + {/* ── Risk panel ── */} + {risk ? ( +
+

Risk Analysis

+
+ +
+ {risk.flags.map((flag) => ( + + ))} +
+
+
+ ) : ( +
+

Risk analysis not yet available for this document.

+
+ )} + + {/* ── Status timeline + Actions ── */} +
+
+

Status Timeline

+ +
+ +
+

Actions

+
+ {canVerify && ( + + )} + + + Download Report + + + + File Dispute + + + +
+
+
+ + {shareOpen && setShareOpen(false)} />} +
+ ); +} diff --git a/frontend/app/(protected)/documents/[id]/verify/page.tsx b/frontend/app/(protected)/documents/[id]/verify/page.tsx new file mode 100644 index 0000000..0f30544 --- /dev/null +++ b/frontend/app/(protected)/documents/[id]/verify/page.tsx @@ -0,0 +1,532 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration +// ───────────────────────────────────────────────────────────────────────────── + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +const WS_BASE = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001").replace(/^http/, "ws"); +const STELLAR_EXPLORER = "https://stellar.expert/explorer/testnet/tx"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +type VerifyState = "pre" | "processing" | "post"; + +interface DocumentSummary { + id: string; + title: string; + fileHash: string; + status: string; +} + +interface VerificationRecord { + transactionHash: string; + anchoredAt: string; + ledger: number; + qrCodeUrl?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function getAuthHeaders(): HeadersInit { + const token = typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString("en-GB", { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function truncateHash(hash: string, chars = 10): string { + if (hash.length <= chars * 2) return hash; + return `${hash.slice(0, chars)}…${hash.slice(-chars)}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sub-components +// ───────────────────────────────────────────────────────────────────────────── + +/** Thin animated progress bar. */ +function ProgressBar({ value }: { value: number }) { + return ( +
+
+
+ ); +} + +/** Copyable text field. */ +function CopyField({ label, value, href }: { label: string; value: string; href?: string }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( +
+

{label}

+
+ {href ? ( + + {value} + + ) : ( + {value} + )} + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pre-verification state +// ───────────────────────────────────────────────────────────────────────────── + +function PreVerification({ + doc, + onAnchor, + anchoring, +}: { + doc: DocumentSummary; + onAnchor: () => void; + anchoring: boolean; +}) { + return ( +
+
+

What is blockchain anchoring?

+

+ Anchoring your document on the Stellar blockchain creates a permanent, tamper-proof record + of its existence. A cryptographic hash of your document is written to a public ledger that + cannot be altered retroactively, enabling independent verification by any third party. +

+
+ +
+

Document

+

{doc.title}

+
+ +
+

SHA-256 Hash (preview)

+

+ {doc.fileHash} +

+
+ +
+

+ Estimated cost: one Stellar transaction fee (~0.00001 XLM ≈ <$0.01) +

+
+ + +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Processing state +// ───────────────────────────────────────────────────────────────────────────── + +function ProcessingView({ progress, statusMsg }: { progress: number; statusMsg: string }) { + return ( +
+ +
+

+ {statusMsg} +

+ +

{progress}%

+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Post-verification state +// ───────────────────────────────────────────────────────────────────────────── + +function PostVerification({ + docId, + verification, +}: { + docId: string; + verification: VerificationRecord; +}) { + function handlePrint() { + window.print(); + } + + return ( +
+ {/* Big green checkmark */} +
+ +

Successfully Anchored on Stellar

+

+ Your document has been permanently recorded on the Stellar blockchain. +

+
+ + {/* Transaction details */} +
+ + +
+
+

Anchored At

+

{formatTimestamp(verification.anchoredAt)}

+
+
+

Ledger

+

#{verification.ledger}

+
+
+ + {/* QR code */} + {verification.qrCodeUrl ? ( +
+

QR Code

+ QR code linking to Stellar transaction +
+ ) : ( +
+

QR Code

+ {/* Inline SVG placeholder QR when backend QR endpoint not yet available */} +
+ + {/* finder pattern top-left */} + + + {/* finder pattern top-right */} + + + {/* finder pattern bottom-left */} + + + {/* data area placeholder */} + {[45, 55, 65, 75, 85].map((x) => + [45, 55, 65, 75, 85].map((y) => ( + 0.5 ? "#111" : "none"} /> + )) + )} + +
+

QR code available after server-side generation (BE-85)

+
+ )} +
+ + {/* Actions */} +
+ + + Back to Document + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Page +// ───────────────────────────────────────────────────────────────────────────── + +export default function VerifyPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const docId = params.id; + + const [doc, setDoc] = useState(null); + const [loadError, setLoadError] = useState(null); + const [pageLoading, setPageLoading] = useState(true); + + const [viewState, setViewState] = useState("pre"); + const [anchoring, setAnchoring] = useState(false); + const [anchorError, setAnchorError] = useState(null); + const [progress, setProgress] = useState(0); + const [statusMsg, setStatusMsg] = useState("Submitting to Stellar network…"); + const [verification, setVerification] = useState(null); + + const wsRef = useRef(null); + + // ── Fetch document metadata ──────────────────────────────────────────── + useEffect(() => { + if (!docId) return; + const headers = getAuthHeaders(); + + fetch(`${API_BASE}/api/documents/${docId}`, { headers }) + .then((res) => { + if (!res.ok) throw new Error(res.status === 404 ? "Document not found." : `Error ${res.status}`); + return res.json() as Promise; + }) + .then((data) => { + // If already verified, redirect back to detail page + if (data.status === "VERIFIED") { + router.replace(`/documents/${docId}`); + return; + } + setDoc(data); + }) + .catch((err) => setLoadError(err instanceof Error ? err.message : "Failed to load document.")) + .finally(() => setPageLoading(false)); + }, [docId, router]); + + // ── WebSocket progress handler ───────────────────────────────────────── + const connectProgressWs = useCallback(() => { + if (!docId || typeof window === "undefined") return; + const ws = new WebSocket(`${WS_BASE}/documents/${docId}/verify/progress`); + wsRef.current = ws; + + ws.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as { + progress?: number; + message?: string; + status?: string; + }; + if (payload.progress !== undefined) setProgress(payload.progress); + if (payload.message) setStatusMsg(payload.message); + if (payload.status === "VERIFIED") { + ws.close(); + // Fetch final verification record + fetchVerification(); + } + } catch { + // ignore malformed messages + } + }; + + ws.onerror = () => ws.close(); + }, [docId]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Fetch verification record after anchoring ────────────────────────── + const fetchVerification = useCallback(async () => { + if (!docId) return; + try { + const res = await fetch(`${API_BASE}/api/documents/${docId}/verification`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch verification (${res.status})`); + const data: VerificationRecord = await res.json(); + setVerification(data); + setViewState("post"); + } catch (err) { + // Fall back to a minimal record derived from what we know + setVerification({ + transactionHash: "pending — check Stellar explorer", + anchoredAt: new Date().toISOString(), + ledger: 0, + }); + setViewState("post"); + } + }, [docId]); + + // ── Anchor handler ───────────────────────────────────────────────────── + async function handleAnchor() { + if (!docId) return; + setAnchoring(true); + setAnchorError(null); + setProgress(0); + setStatusMsg("Submitting to Stellar network…"); + + try { + const res = await fetch(`${API_BASE}/api/documents/${docId}/verify`, { + method: "POST", + headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`Verification request failed (${res.status})`); + + // Switch to processing view and open WS for real-time updates + setViewState("processing"); + connectProgressWs(); + + // Simulate progress advancement when WS is unavailable + const interval = setInterval(() => { + setProgress((prev) => { + if (prev >= 90) { + clearInterval(interval); + return prev; + } + return prev + 10; + }); + }, 800); + + // Poll for completion (fallback when WS is not available) + const poll = async () => { + await new Promise((r) => setTimeout(r, 8000)); + const verifyRes = await fetch(`${API_BASE}/api/documents/${docId}/verification`, { + headers: getAuthHeaders(), + }); + if (verifyRes.ok) { + clearInterval(interval); + const data: VerificationRecord = await verifyRes.json(); + setProgress(100); + setVerification(data); + setViewState("post"); + wsRef.current?.close(); + } + }; + + poll().catch(() => { + // WS/poll failed — move to post with partial info + clearInterval(interval); + setProgress(100); + fetchVerification(); + }); + } catch (err) { + setAnchorError(err instanceof Error ? err.message : "Anchoring failed."); + setAnchoring(false); + } + } + + // ── Cleanup ──────────────────────────────────────────────────────────── + useEffect(() => { + return () => wsRef.current?.close(); + }, []); + + // ── Render ───────────────────────────────────────────────────────────── + if (pageLoading) { + return ( +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } + + if (loadError || !doc) { + return ( +
+
+

{loadError ?? "Document not found."}

+ + Back to Documents + +
+
+ ); + } + + const title = + viewState === "pre" + ? "Anchor on Stellar" + : viewState === "processing" + ? "Anchoring in Progress" + : "Verification Complete"; + + return ( +
+ {/* Page header */} +
+ + + + + Back to document + +

{title}

+

{doc.title}

+
+ + {/* Content card */} +
+ {viewState === "pre" && ( + + )} + {viewState === "processing" && ( + + )} + {viewState === "post" && verification && ( + + )} + + {anchorError && ( +

+ {anchorError} +

+ )} +
+ + {/* Print-only header */} +
+

+ SMALDA Verification Certificate — printed {new Date().toLocaleString()} +

+
+
+ ); +}