Build BlackRoad Internet: accuracy-first browser, search & verification platform

Tauri v2 desktop browser with AI-powered fact-checking, source reputation
scoring (100+ domains), and accuracy-ranked search. Deploys verification-api
and search-api to Cloudflare Workers backed by D1.

- Rust backend: Ollama AI client, claim extraction/verification pipeline,
  domain reputation database, weighted scoring (0.4*source + 0.6*claims)
- React frontend: tabs, navigation, verification sidebar with accuracy ring,
  source category/bias display, claim-level reasoning and verdicts
- Cloudflare Workers: verification-api (D1 + Hono), search-api with
  accuracy re-ranking (0.4*Relevance + 0.3*Accuracy + 0.2*Source + 0.1*Fresh)
- 60+ seeded domains: academic, journals, government, wire services,
  news, fact-checkers, tech docs, social media, low-credibility sources

Accurate info. Period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexa Amundson
2026-02-19 20:09:12 -06:00
commit 447e3714b2
58 changed files with 11917 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
target/
dist/
*.dmg
*.app
# Rust
Cargo.lock
!apps/browser/src-tauri/Cargo.lock
# Environment
.env
.env.local
.dev.vars
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Wrangler
.wrangler/
# Tauri
apps/browser/src-tauri/gen/
apps/browser/src-tauri/icons/
# Turbo
.turbo/

BIN
apps/browser/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

16
apps/browser/app-icon.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#F5A623"/>
<stop offset="38.2%" style="stop-color:#FF1D6C"/>
<stop offset="61.8%" style="stop-color:#9C27B0"/>
<stop offset="100%" style="stop-color:#2979FF"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="200" fill="#000000"/>
<circle cx="512" cy="512" r="320" fill="none" stroke="url(#bg)" stroke-width="48"/>
<circle cx="512" cy="512" r="180" fill="none" stroke="url(#bg)" stroke-width="32"/>
<circle cx="512" cy="512" r="60" fill="url(#bg)"/>
<line x1="512" y1="192" x2="512" y2="832" stroke="url(#bg)" stroke-width="24" opacity="0.3"/>
<line x1="192" y1="512" x2="832" y2="512" stroke="url(#bg)" stroke-width="24" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

13
apps/browser/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/blackroad.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlackRoad Internet</title>
</head>
<body class="bg-black text-white">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/browser/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@blackroad/browser",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-http": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

5695
apps/browser/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
[package]
name = "blackroad-internet"
version = "0.1.0"
edition = "2021"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-http = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
url = "2"
urlencoding = "2"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[lib]
name = "blackroad_internet_lib"
crate-type = ["lib", "cdylib", "staticlib"]

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,10 @@
{
"identifier": "default",
"description": "Default capabilities for BlackRoad Internet browser",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"http:default"
]
}

View File

@@ -0,0 +1,3 @@
pub mod navigation;
pub mod settings;
pub mod verification;

View File

@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
use tauri::{command, State};
use crate::store::AppStore;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PageInfo {
pub url: String,
pub title: String,
pub is_secure: bool,
pub is_search: bool,
}
fn normalize_url(input: &str) -> PageInfo {
let trimmed = input.trim();
// Internal pages
if trimmed.starts_with("blackroad://") {
return PageInfo {
url: trimmed.to_string(),
title: String::new(),
is_secure: true,
is_search: false,
};
}
// Already a URL
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return PageInfo {
url: trimmed.to_string(),
title: String::new(),
is_secure: trimmed.starts_with("https"),
is_search: false,
};
}
// Looks like a domain (has a dot, no spaces)
if trimmed.contains('.') && !trimmed.contains(' ') {
let url = format!("https://{}", trimmed);
return PageInfo {
url,
title: String::new(),
is_secure: true,
is_search: false,
};
}
// Treat as search query
let encoded = urlencoding::encode(trimmed);
PageInfo {
url: format!("blackroad://search?q={}", encoded),
title: String::new(),
is_secure: true,
is_search: true,
}
}
#[command]
pub async fn navigate_to(url: String, store: State<'_, AppStore>) -> Result<PageInfo, String> {
let page_info = normalize_url(&url);
// Record in history
if !page_info.url.starts_with("blackroad://") {
let db = store.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO history (url, title) VALUES (?1, ?2)",
rusqlite::params![page_info.url, page_info.title],
)
.map_err(|e| e.to_string())?;
}
Ok(page_info)
}
#[command]
pub async fn go_back() -> Result<Option<String>, String> {
// Navigation stack managed by frontend in Phase 1
Ok(None)
}
#[command]
pub async fn go_forward() -> Result<Option<String>, String> {
Ok(None)
}
#[command]
pub async fn refresh_page() -> Result<(), String> {
Ok(())
}

View File

@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
use tauri::command;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BrowserSettings {
pub verification_enabled: bool,
pub sidebar_position: String,
pub theme: String,
pub default_search: String,
}
impl Default for BrowserSettings {
fn default() -> Self {
Self {
verification_enabled: true,
sidebar_position: "right".to_string(),
theme: "dark".to_string(),
default_search: "blackroad".to_string(),
}
}
}
#[command]
pub async fn get_settings() -> Result<BrowserSettings, String> {
Ok(BrowserSettings::default())
}
#[command]
pub async fn update_settings(settings: BrowserSettings) -> Result<BrowserSettings, String> {
// Phase 1: Just echo back. Phase 2: persist to SQLite.
Ok(settings)
}

View File

@@ -0,0 +1,254 @@
use serde::{Deserialize, Serialize};
use std::time::Instant;
use tauri::command;
use crate::verification::ollama::OllamaClient;
use crate::verification::reputation::ReputationDb;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ClaimType {
Factual,
Statistical,
Quote,
Opinion,
Prediction,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum Verdict {
Verified,
Likely,
Uncertain,
Disputed,
False,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ClaimResult {
pub text: String,
pub claim_type: ClaimType,
pub confidence: f64,
pub verdict: Verdict,
pub reasoning: String,
pub sources: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VerificationResult {
pub url: String,
pub overall_score: f64,
pub source_reputation: f64,
pub source_category: String,
pub source_bias: String,
pub claims: Vec<ClaimResult>,
pub claims_checked: usize,
pub verification_time_ms: u64,
pub method: String,
pub ai_available: bool,
}
fn str_to_claim_type(s: &str) -> ClaimType {
match s {
"Statistical" => ClaimType::Statistical,
"Quote" => ClaimType::Quote,
"Opinion" => ClaimType::Opinion,
"Prediction" => ClaimType::Prediction,
_ => ClaimType::Factual,
}
}
fn str_to_verdict(s: &str) -> Verdict {
match s {
"Verified" => Verdict::Verified,
"Likely" => Verdict::Likely,
"Disputed" => Verdict::Disputed,
"False" => Verdict::False,
_ => Verdict::Uncertain,
}
}
/// Verify a page's content for accuracy using AI + source reputation.
#[command]
pub async fn verify_page(url: String, content: String) -> Result<VerificationResult, String> {
let start = Instant::now();
let ollama = OllamaClient::new();
let reputation_db = ReputationDb::new();
// 1. Get source reputation
let rep = reputation_db.lookup(&url);
let source_score = rep.as_ref().map(|r| r.score).unwrap_or(0.50);
let source_category = rep
.as_ref()
.map(|r| r.category.to_string())
.unwrap_or_else(|| "unknown".to_string());
let source_bias = rep
.as_ref()
.map(|r| r.bias.to_string())
.unwrap_or_else(|| "unknown".to_string());
// 2. Check if Ollama is available for AI verification
let ai_available = ollama.is_available().await;
if !ai_available || content.trim().is_empty() || content.len() < 50 {
// Fallback to reputation-only scoring
return Ok(VerificationResult {
url,
overall_score: source_score,
source_reputation: source_score,
source_category,
source_bias,
claims: vec![],
claims_checked: 0,
verification_time_ms: start.elapsed().as_millis() as u64,
method: if ai_available {
"reputation-only (content too short)".to_string()
} else {
"reputation-only (AI offline)".to_string()
},
ai_available,
});
}
// 3. Extract claims from content using Ollama
let extracted = ollama.extract_claims(&content).await.unwrap_or_default();
// 4. Verify each factual claim (skip opinions and predictions)
let mut verified_claims = Vec::new();
let max_claims = 5; // Limit to avoid long wait times
let mut checked = 0;
for claim in extracted.iter().take(10) {
// Skip opinions and predictions - they can't be fact-checked
if claim.claim_type == "Opinion" || claim.claim_type == "Prediction" {
verified_claims.push(ClaimResult {
text: claim.text.clone(),
claim_type: str_to_claim_type(&claim.claim_type),
confidence: 0.0,
verdict: Verdict::Uncertain,
reasoning: "Opinions and predictions are not fact-checkable".to_string(),
sources: vec![],
});
continue;
}
if checked >= max_claims {
verified_claims.push(ClaimResult {
text: claim.text.clone(),
claim_type: str_to_claim_type(&claim.claim_type),
confidence: 0.0,
verdict: Verdict::Uncertain,
reasoning: "Skipped (verification limit reached)".to_string(),
sources: vec![],
});
continue;
}
match ollama.verify_claim(&claim.text).await {
Ok(verdict) => {
verified_claims.push(ClaimResult {
text: claim.text.clone(),
claim_type: str_to_claim_type(&claim.claim_type),
confidence: verdict.confidence,
verdict: str_to_verdict(&verdict.verdict),
reasoning: verdict.reasoning,
sources: verdict.sources,
});
checked += 1;
}
Err(_) => {
verified_claims.push(ClaimResult {
text: claim.text.clone(),
claim_type: str_to_claim_type(&claim.claim_type),
confidence: 0.0,
verdict: Verdict::Uncertain,
reasoning: "Verification failed".to_string(),
sources: vec![],
});
}
}
}
// 5. Calculate overall score
// Formula: 0.4 * source_reputation + 0.6 * claim_accuracy
let claim_score = if verified_claims.is_empty() {
source_score
} else {
let verifiable: Vec<&ClaimResult> = verified_claims
.iter()
.filter(|c| !matches!(c.claim_type, ClaimType::Opinion | ClaimType::Prediction))
.collect();
if verifiable.is_empty() {
source_score
} else {
let total: f64 = verifiable
.iter()
.map(|c| match c.verdict {
Verdict::Verified => 1.0,
Verdict::Likely => 0.8,
Verdict::Uncertain => 0.5,
Verdict::Disputed => 0.3,
Verdict::False => 0.0,
})
.sum();
total / verifiable.len() as f64
}
};
let overall = 0.4 * source_score + 0.6 * claim_score;
Ok(VerificationResult {
url,
overall_score: overall,
source_reputation: source_score,
source_category,
source_bias,
claims: verified_claims,
claims_checked: checked,
verification_time_ms: start.elapsed().as_millis() as u64,
method: "ai+reputation".to_string(),
ai_available: true,
})
}
#[command]
pub async fn get_verification_status(url: String) -> Result<Option<VerificationResult>, String> {
// TODO Phase 3: Check local SQLite cache
let _ = url;
Ok(None)
}
#[command]
pub async fn verify_claim(claim: String) -> Result<ClaimResult, String> {
let ollama = OllamaClient::new();
if !ollama.is_available().await {
return Ok(ClaimResult {
text: claim,
claim_type: ClaimType::Factual,
confidence: 0.0,
verdict: Verdict::Uncertain,
reasoning: "AI verification unavailable".to_string(),
sources: vec![],
});
}
match ollama.verify_claim(&claim).await {
Ok(verdict) => Ok(ClaimResult {
text: claim,
claim_type: ClaimType::Factual,
confidence: verdict.confidence,
verdict: str_to_verdict(&verdict.verdict),
reasoning: verdict.reasoning,
sources: verdict.sources,
}),
Err(e) => Ok(ClaimResult {
text: claim,
claim_type: ClaimType::Factual,
confidence: 0.0,
verdict: Verdict::Uncertain,
reasoning: format!("Verification error: {}", e),
sources: vec![],
}),
}
}

View File

@@ -0,0 +1,27 @@
mod commands;
mod store;
mod verification;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.invoke_handler(tauri::generate_handler![
commands::navigation::navigate_to,
commands::navigation::go_back,
commands::navigation::go_forward,
commands::navigation::refresh_page,
commands::verification::verify_page,
commands::verification::get_verification_status,
commands::verification::verify_claim,
commands::settings::get_settings,
commands::settings::update_settings,
])
.setup(|app| {
store::init(app.handle())?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running BlackRoad Internet");
}

View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
blackroad_internet_lib::run();
}

View File

@@ -0,0 +1,59 @@
use rusqlite::Connection;
use std::sync::Mutex;
use tauri::AppHandle;
use tauri::Manager;
pub struct AppStore {
pub db: Mutex<Connection>,
}
pub fn init(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let app_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&app_dir)?;
let db_path = app_dir.join("blackroad-internet.db");
let conn = Connection::open(db_path)?;
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT DEFAULT '',
visited_at TEXT DEFAULT (datetime('now')),
verification_score REAL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT DEFAULT '',
folder TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS source_reputation (
domain TEXT PRIMARY KEY,
score REAL DEFAULT 0.5,
total_checks INTEGER DEFAULT 0,
last_checked TEXT,
category TEXT
);
CREATE TABLE IF NOT EXISTS verification_cache (
url TEXT PRIMARY KEY,
result_json TEXT NOT NULL,
cached_at TEXT DEFAULT (datetime('now')),
expires_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
CREATE INDEX IF NOT EXISTS idx_history_visited ON history(visited_at);
",
)?;
app.manage(AppStore {
db: Mutex::new(conn),
});
Ok(())
}

View File

@@ -0,0 +1,2 @@
pub mod ollama;
pub mod reputation;

View File

@@ -0,0 +1,227 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct GenerateRequest {
model: String,
prompt: String,
stream: bool,
options: OllamaOptions,
}
#[derive(Serialize)]
struct OllamaOptions {
temperature: f64,
num_predict: i32,
}
#[derive(Deserialize)]
struct GenerateResponse {
response: String,
}
pub struct OllamaClient {
endpoint: String,
client: Client,
}
impl OllamaClient {
pub fn new() -> Self {
let endpoint = std::env::var("OLLAMA_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:11434".to_string());
Self {
endpoint,
client: Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_default(),
}
}
pub async fn is_available(&self) -> bool {
self.client
.get(format!("{}/api/tags", self.endpoint))
.timeout(std::time::Duration::from_secs(3))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
pub async fn generate(&self, model: &str, prompt: &str) -> Result<String, String> {
let request = GenerateRequest {
model: model.to_string(),
prompt: prompt.to_string(),
stream: false,
options: OllamaOptions {
temperature: 0.1, // Low temperature for factual accuracy
num_predict: 2048,
},
};
let response = self
.client
.post(format!("{}/api/generate", self.endpoint))
.json(&request)
.send()
.await
.map_err(|e| format!("Ollama request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status {}", response.status()));
}
let data: GenerateResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Ollama response: {}", e))?;
Ok(data.response)
}
/// Extract verifiable claims from page content
pub async fn extract_claims(&self, content: &str) -> Result<Vec<ExtractedClaim>, String> {
let truncated = if content.len() > 3000 {
&content[..3000]
} else {
content
};
let prompt = format!(
r#"You are a fact-checking assistant. Analyze the following text and extract all verifiable factual claims.
For each claim, output EXACTLY one line in this format:
[TYPE] claim text here
Where TYPE is one of:
- FACT: A verifiable factual statement
- STAT: A statistical claim with numbers
- QUOTE: An attributed quote
- OPINION: A subjective opinion (not verifiable)
- PREDICTION: A claim about the future
Only extract clear, specific claims. Skip vague statements. Output ONLY the tagged claims, nothing else.
TEXT:
{}
CLAIMS:"#,
truncated
);
let response = self.generate("llama3:latest", &prompt).await?;
let claims = parse_claims(&response);
Ok(claims)
}
/// Verify a single factual claim
pub async fn verify_claim(&self, claim: &str) -> Result<ClaimVerdict, String> {
let prompt = format!(
r#"You are a rigorous fact-checker. Evaluate the following claim for accuracy.
CLAIM: "{}"
Respond in EXACTLY this format (one line each):
VERDICT: [VERIFIED|LIKELY|UNCERTAIN|DISPUTED|FALSE]
CONFIDENCE: [0-100]
REASONING: [one sentence explanation]
SOURCES: [comma-separated list of what would verify this, or "none"]
Be conservative. If you're not sure, say UNCERTAIN. Only say VERIFIED for well-established facts."#,
claim
);
let response = self.generate("llama3:latest", &prompt).await?;
parse_verdict(&response, claim)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedClaim {
pub text: String,
pub claim_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimVerdict {
pub verdict: String,
pub confidence: f64,
pub reasoning: String,
pub sources: Vec<String>,
}
fn parse_claims(response: &str) -> Vec<ExtractedClaim> {
let mut claims = Vec::new();
for line in response.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let (claim_type, text) = if line.starts_with("[FACT]") {
("Factual", line.trim_start_matches("[FACT]").trim())
} else if line.starts_with("[STAT]") {
("Statistical", line.trim_start_matches("[STAT]").trim())
} else if line.starts_with("[QUOTE]") {
("Quote", line.trim_start_matches("[QUOTE]").trim())
} else if line.starts_with("[OPINION]") {
("Opinion", line.trim_start_matches("[OPINION]").trim())
} else if line.starts_with("[PREDICTION]") {
("Prediction", line.trim_start_matches("[PREDICTION]").trim())
} else {
continue;
};
if !text.is_empty() {
claims.push(ExtractedClaim {
text: text.to_string(),
claim_type: claim_type.to_string(),
});
}
}
claims
}
fn parse_verdict(response: &str, original_claim: &str) -> Result<ClaimVerdict, String> {
let mut verdict = "Uncertain".to_string();
let mut confidence = 0.0;
let mut reasoning = String::new();
let mut sources = Vec::new();
for line in response.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("VERDICT:") {
let v = v.trim().trim_matches('[').trim_matches(']');
verdict = match v.to_uppercase().as_str() {
"VERIFIED" => "Verified",
"LIKELY" => "Likely",
"UNCERTAIN" => "Uncertain",
"DISPUTED" => "Disputed",
"FALSE" => "False",
_ => "Uncertain",
}
.to_string();
} else if let Some(c) = line.strip_prefix("CONFIDENCE:") {
let c = c.trim().trim_matches('[').trim_matches(']').trim_end_matches('%');
confidence = c.parse::<f64>().unwrap_or(0.0) / 100.0;
} else if let Some(r) = line.strip_prefix("REASONING:") {
reasoning = r.trim().trim_matches('[').trim_matches(']').to_string();
} else if let Some(s) = line.strip_prefix("SOURCES:") {
let s = s.trim().trim_matches('[').trim_matches(']');
if s.to_lowercase() != "none" {
sources = s.split(',').map(|s| s.trim().to_string()).collect();
}
}
}
if reasoning.is_empty() {
reasoning = format!("Analysis of claim: \"{}\"", original_claim);
}
Ok(ClaimVerdict {
verdict,
confidence,
reasoning,
sources,
})
}

View File

@@ -0,0 +1,307 @@
use std::collections::HashMap;
/// Domain reputation database with categories and factual ratings.
/// Phase 2: Static in-memory database. Phase 4: Cloudflare D1 + community data.
#[allow(dead_code)]
pub struct ReputationDb {
domains: HashMap<String, DomainReputation>,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct DomainReputation {
pub score: f64,
pub category: &'static str,
pub bias: &'static str,
pub factual: &'static str,
}
impl ReputationDb {
pub fn new() -> Self {
let mut domains = HashMap::new();
// Academic & Research (highest trust)
for domain in &[
"arxiv.org",
"scholar.google.com",
"pubmed.ncbi.nlm.nih.gov",
"ncbi.nlm.nih.gov",
"jstor.org",
"sciencedirect.com",
"springer.com",
"wiley.com",
"ieee.org",
"acm.org",
"researchgate.net",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.95,
category: "academic",
bias: "none",
factual: "very-high",
},
);
}
// Scientific journals
for domain in &[
"nature.com",
"science.org",
"cell.com",
"thelancet.com",
"nejm.org",
"bmj.com",
"pnas.org",
"plos.org",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.96,
category: "journal",
bias: "none",
factual: "very-high",
},
);
}
// Government sources
for domain in &[
"nasa.gov",
"nih.gov",
"cdc.gov",
"fda.gov",
"epa.gov",
"noaa.gov",
"usgs.gov",
"bls.gov",
"census.gov",
"sec.gov",
"federalreserve.gov",
"whitehouse.gov",
"congress.gov",
"supremecourt.gov",
"data.gov",
"gao.gov",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.92,
category: "government",
bias: "none",
factual: "very-high",
},
);
}
// Wire services (highest journalistic trust)
for (domain, score) in &[
("apnews.com", 0.93),
("reuters.com", 0.93),
("afp.com", 0.91),
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: *score,
category: "wire-service",
bias: "center",
factual: "very-high",
},
);
}
// Reference
for domain in &[
"wikipedia.org",
"en.wikipedia.org",
"britannica.com",
"merriam-webster.com",
"dictionary.com",
"worldbank.org",
"imf.org",
"un.org",
"who.int",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.88,
category: "reference",
bias: "none",
factual: "high",
},
);
}
// Quality journalism (center/high factual)
for (domain, score, bias) in &[
("bbc.com", 0.85, "center"),
("bbc.co.uk", 0.85, "center"),
("npr.org", 0.84, "center"),
("pbs.org", 0.84, "center"),
("economist.com", 0.83, "center"),
("ft.com", 0.83, "center"),
("wsj.com", 0.82, "center-right"),
("nytimes.com", 0.82, "center-left"),
("washingtonpost.com", 0.81, "center-left"),
("theguardian.com", 0.80, "center-left"),
("bloomberg.com", 0.83, "center"),
("propublica.org", 0.86, "center-left"),
("theintercept.com", 0.75, "left"),
("jacobin.com", 0.70, "left"),
("nationalreview.com", 0.72, "right"),
("reason.com", 0.73, "right"),
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: *score,
category: "news",
bias,
factual: if *score > 0.80 { "high" } else { "mixed" },
},
);
}
// Fact-checking sites
for domain in &[
"snopes.com",
"factcheck.org",
"politifact.com",
"fullfact.org",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.90,
category: "fact-check",
bias: "center",
factual: "very-high",
},
);
}
// Tech documentation
for domain in &[
"docs.github.com",
"developer.mozilla.org",
"docs.python.org",
"docs.rust-lang.org",
"docs.microsoft.com",
"developer.apple.com",
"cloud.google.com",
"docs.aws.amazon.com",
"stackoverflow.com",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.85,
category: "tech-docs",
bias: "none",
factual: "high",
},
);
}
// Social media (low factual, high bias potential)
for domain in &[
"twitter.com",
"x.com",
"facebook.com",
"instagram.com",
"tiktok.com",
"reddit.com",
"threads.net",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.30,
category: "social",
bias: "mixed",
factual: "low",
},
);
}
// Known misinformation/low trust
for domain in &[
"infowars.com",
"naturalnews.com",
"thegatewaypundit.com",
"zerohedge.com",
"breitbart.com",
"dailymail.co.uk",
] {
domains.insert(
domain.to_string(),
DomainReputation {
score: 0.15,
category: "low-credibility",
bias: "extreme",
factual: "very-low",
},
);
}
Self { domains }
}
/// Look up reputation for a domain, checking parent domains too
pub fn lookup(&self, url: &str) -> Option<DomainReputation> {
let domain = extract_domain(url)?;
// Direct match
if let Some(rep) = self.domains.get(&domain) {
return Some(rep.clone());
}
// Try parent domain (e.g., "en.wikipedia.org" -> "wikipedia.org")
let parts: Vec<&str> = domain.split('.').collect();
if parts.len() > 2 {
let parent = parts[parts.len() - 2..].join(".");
if let Some(rep) = self.domains.get(&parent) {
return Some(rep.clone());
}
}
// TLD-based defaults
if domain.ends_with(".gov") {
return Some(DomainReputation {
score: 0.88,
category: "government",
bias: "none",
factual: "high",
});
}
if domain.ends_with(".edu") {
return Some(DomainReputation {
score: 0.85,
category: "education",
bias: "none",
factual: "high",
});
}
if domain.ends_with(".org") {
return Some(DomainReputation {
score: 0.60,
category: "organization",
bias: "unknown",
factual: "mixed",
});
}
None
}
}
fn extract_domain(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
}

View File

@@ -0,0 +1,41 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "BlackRoad Internet",
"version": "0.1.0",
"identifier": "io.blackroad.internet",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"title": "BlackRoad Internet",
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600,
"decorations": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"targets": ["dmg", "app"],
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}

83
apps/browser/src/App.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { TabBar } from "./components/browser/TabBar";
import { AddressBar } from "./components/browser/AddressBar";
import { NavigationControls } from "./components/browser/NavigationControls";
import { BrowserFrame } from "./components/browser/BrowserFrame";
import { VerificationPanel } from "./components/sidebar/VerificationPanel";
import { StatusBar } from "./components/browser/StatusBar";
import { useTabs } from "./hooks/useTabs";
import { useVerification } from "./hooks/useVerification";
function App() {
const { tabs, activeTab, addTab, closeTab, setActiveTab, navigateTo, updateTabTitle } =
useTabs();
const { verification, isVerifying, sidebarOpen, toggleSidebar, onContentReady } =
useVerification(activeTab?.url);
return (
<div className="flex flex-col h-screen bg-black text-white font-mono">
{/* Tab bar */}
<TabBar
tabs={tabs}
activeTabId={activeTab?.id ?? ""}
onTabClick={setActiveTab}
onTabClose={closeTab}
onNewTab={addTab}
/>
{/* Navigation + Address */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border-b border-gray-800">
<NavigationControls />
<AddressBar
url={activeTab?.url ?? ""}
onNavigate={navigateTo}
verificationScore={verification?.overall_score}
/>
<button
onClick={toggleSidebar}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors"
title="Toggle verification panel"
>
<svg
className={`w-4 h-4 ${sidebarOpen ? "text-hotpink" : "text-muted"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</button>
</div>
{/* Main content area */}
<div className="flex flex-1 overflow-hidden">
<BrowserFrame
url={activeTab?.url ?? "blackroad://newtab"}
onNavigate={navigateTo}
onTitleChange={updateTabTitle}
onContentReady={onContentReady}
/>
{sidebarOpen && (
<VerificationPanel
verification={verification}
isVerifying={isVerifying}
/>
)}
</div>
{/* Status bar */}
<StatusBar
url={activeTab?.url}
isSecure={activeTab?.url?.startsWith("https") ?? false}
verificationScore={verification?.overall_score}
/>
</div>
);
}
export default App;

View File

@@ -0,0 +1,83 @@
import { useState, useCallback, useEffect, type KeyboardEvent } from "react";
interface AddressBarProps {
url: string;
onNavigate: (url: string) => void;
verificationScore?: number;
}
export function AddressBar({
url,
onNavigate,
verificationScore,
}: AddressBarProps) {
const [input, setInput] = useState(url);
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!focused) setInput(url);
}, [url, focused]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter") {
onNavigate(input);
(e.target as HTMLInputElement).blur();
}
},
[input, onNavigate],
);
const scoreColor =
verificationScore === undefined
? "bg-gray-600"
: verificationScore > 0.8
? "bg-emerald-500"
: verificationScore > 0.5
? "bg-amber-400"
: "bg-red-500";
const displayUrl = url.startsWith("blackroad://") ? "" : url;
return (
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-black/60 border border-gray-700 focus-within:border-hotpink transition-colors">
{verificationScore !== undefined && (
<div
className={`w-2 h-2 rounded-full shrink-0 ${scoreColor}`}
title={`Accuracy: ${Math.round(verificationScore * 100)}%`}
/>
)}
{url.startsWith("https") && (
<svg
className="w-3.5 h-3.5 text-emerald-400 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
)}
<input
type="text"
value={focused ? input : displayUrl}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
setFocused(true);
setInput(url.startsWith("blackroad://") ? "" : url);
}}
onBlur={() => setFocused(false)}
className="flex-1 bg-transparent text-sm text-gray-200 outline-none placeholder-gray-500"
placeholder="Search or enter URL — Accurate info. Period."
spellCheck={false}
/>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useCallback } from "react";
import { NewTabPage } from "./NewTabPage";
import { SearchPage } from "../search/SearchPage";
interface BrowserFrameProps {
url: string;
onNavigate: (url: string) => void;
onTitleChange: (title: string) => void;
onContentReady: (content: string) => void;
}
export function BrowserFrame({ url, onNavigate, onTitleChange, onContentReady }: BrowserFrameProps) {
const handleIframeLoad = useCallback(
(e: React.SyntheticEvent<HTMLIFrameElement>) => {
try {
const frame = e.target as HTMLIFrameElement;
const doc = frame.contentDocument;
if (!doc) return;
const title = doc.title;
if (title) onTitleChange(title);
// Extract text content for verification
const body = doc.body;
if (body) {
// Remove script/style elements before extracting text
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll("script, style, nav, footer, header, iframe").forEach((el) => el.remove());
const text = clone.innerText || clone.textContent || "";
// Only send if we got meaningful content
if (text.trim().length > 50) {
onContentReady(text.trim().slice(0, 10000)); // Cap at 10k chars for LLM context
}
}
} catch {
// Cross-origin: can't access content directly
// Send URL-only signal so backend knows to use reputation-only scoring
onContentReady("");
}
},
[onTitleChange, onContentReady],
);
// Internal BlackRoad pages
if (url === "blackroad://newtab" || url === "") {
return (
<div className="flex-1 overflow-auto">
<NewTabPage onNavigate={onNavigate} />
</div>
);
}
if (url.startsWith("blackroad://search")) {
const params = new URLSearchParams(url.split("?")[1] || "");
return (
<div className="flex-1 overflow-auto">
<SearchPage query={params.get("q") || ""} onNavigate={onNavigate} />
</div>
);
}
// External web pages via iframe
return (
<div className="flex-1 relative">
<iframe
src={url}
className="w-full h-full border-0 bg-white"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
onLoad={handleIframeLoad}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
interface NavigationControlsProps {
canGoBack?: boolean;
canGoForward?: boolean;
onBack?: () => void;
onForward?: () => void;
onRefresh?: () => void;
}
export function NavigationControls({
canGoBack = false,
canGoForward = false,
onBack,
onForward,
onRefresh,
}: NavigationControlsProps) {
const btnClass = (enabled: boolean) =>
`w-7 h-7 flex items-center justify-center rounded transition-colors ${
enabled
? "text-gray-300 hover:text-white hover:bg-surface-elevated cursor-pointer"
: "text-gray-700 cursor-default"
}`;
return (
<div className="flex items-center gap-0.5">
<button
className={btnClass(canGoBack)}
onClick={onBack}
disabled={!canGoBack}
title="Back"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
className={btnClass(canGoForward)}
onClick={onForward}
disabled={!canGoForward}
title="Forward"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
className={btnClass(true)}
onClick={onRefresh}
title="Refresh"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useState, type KeyboardEvent } from "react";
interface NewTabPageProps {
onNavigate: (url: string) => void;
}
export function NewTabPage({ onNavigate }: NewTabPageProps) {
const [query, setQuery] = useState("");
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && query.trim()) {
onNavigate(query.trim());
}
};
const quickLinks = [
{ title: "Wikipedia", url: "https://wikipedia.org", icon: "W" },
{ title: "arXiv", url: "https://arxiv.org", icon: "a" },
{ title: "Reuters", url: "https://reuters.com", icon: "R" },
{ title: "AP News", url: "https://apnews.com", icon: "AP" },
{ title: "Nature", url: "https://nature.com", icon: "N" },
{ title: "PubMed", url: "https://pubmed.ncbi.nlm.nih.gov", icon: "P" },
];
return (
<div className="flex flex-col items-center justify-center min-h-full bg-black px-4">
{/* Logo */}
<div className="mb-10">
<h1 className="text-6xl font-black br-gradient-text tracking-tight">
BlackRoad
</h1>
<p className="text-center text-muted text-sm mt-3 tracking-widest uppercase">
Accurate info. Period.
</p>
</div>
{/* Search bar */}
<div className="w-full max-w-2xl">
<div className="relative">
<svg
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full pl-12 pr-6 py-4 rounded-2xl bg-surface-elevated border border-gray-700 text-white text-lg outline-none focus:border-hotpink focus:shadow-glow transition-all placeholder-gray-500"
placeholder="Search for anything..."
autoFocus
/>
</div>
</div>
{/* Quick links */}
<div className="mt-10 grid grid-cols-3 sm:grid-cols-6 gap-4">
{quickLinks.map((link) => (
<button
key={link.url}
onClick={() => onNavigate(link.url)}
className="flex flex-col items-center gap-2 p-3 rounded-xl hover:bg-surface-elevated transition-colors group"
>
<div className="w-10 h-10 rounded-lg bg-surface-elevated group-hover:bg-gray-700 flex items-center justify-center text-sm font-bold text-hotpink transition-colors">
{link.icon}
</div>
<span className="text-xs text-muted group-hover:text-gray-300 transition-colors">
{link.title}
</span>
</button>
))}
</div>
{/* Tagline */}
<div className="mt-16 text-center">
<p className="text-xs text-gray-700">
Verification powered by AI. Sources ranked by accuracy.
</p>
<p className="text-xs text-gray-800 mt-1">
BlackRoad Internet v0.1.0
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
interface StatusBarProps {
url?: string;
isSecure: boolean;
verificationScore?: number;
}
export function StatusBar({ url, isSecure, verificationScore }: StatusBarProps) {
const scoreLabel =
verificationScore === undefined
? ""
: `Accuracy: ${Math.round(verificationScore * 100)}%`;
const scoreColor =
verificationScore === undefined
? "text-muted"
: verificationScore > 0.8
? "text-emerald-400"
: verificationScore > 0.5
? "text-amber-400"
: "text-red-400";
return (
<div className="flex items-center justify-between px-3 py-1 bg-surface border-t border-gray-800 text-[10px] select-none">
<div className="flex items-center gap-3">
{isSecure && (
<span className="text-emerald-400 flex items-center gap-1">
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
Secure
</span>
)}
<span className="text-muted truncate max-w-md">
{url?.startsWith("blackroad://") ? "" : url}
</span>
</div>
<div className="flex items-center gap-3">
{scoreLabel && <span className={scoreColor}>{scoreLabel}</span>}
<span className="text-gray-700">BlackRoad Internet</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import type { Tab } from "../../types/browser";
interface TabBarProps {
tabs: Tab[];
activeTabId: string;
onTabClick: (id: string) => void;
onTabClose: (id: string) => void;
onNewTab: () => void;
}
export function TabBar({
tabs,
activeTabId,
onTabClick,
onTabClose,
onNewTab,
}: TabBarProps) {
return (
<div className="flex items-center bg-black border-b border-gray-800 select-none">
<div className="flex flex-1 overflow-x-auto">
{tabs.map((tab) => (
<div
key={tab.id}
className={`group flex items-center gap-2 px-4 py-2 min-w-[140px] max-w-[240px] cursor-pointer border-r border-gray-800 transition-colors ${
tab.id === activeTabId
? "bg-surface-elevated text-white"
: "bg-black text-muted hover:bg-surface hover:text-gray-300"
}`}
onClick={() => onTabClick(tab.id)}
>
{tab.isLoading && (
<div className="w-3 h-3 border border-hotpink border-t-transparent rounded-full animate-spin shrink-0" />
)}
<span className="text-xs truncate flex-1">
{tab.title || extractDomain(tab.url) || "New Tab"}
</span>
<button
className="w-4 h-4 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-gray-600 transition-opacity text-[10px]"
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
>
x
</button>
</div>
))}
</div>
<button
onClick={onNewTab}
className="px-3 py-2 text-muted hover:text-white hover:bg-surface transition-colors text-sm"
title="New tab"
>
+
</button>
</div>
);
}
function extractDomain(url: string): string {
if (url.startsWith("blackroad://")) {
return url.replace("blackroad://", "");
}
try {
return new URL(url).hostname;
} catch {
return url;
}
}

View File

@@ -0,0 +1,288 @@
import { useState, useEffect, type KeyboardEvent } from "react";
interface SearchPageProps {
query: string;
onNavigate: (url: string) => void;
}
interface SearchResult {
title: string;
url: string;
description: string;
domain: string;
relevance_score: number;
accuracy_score: number;
source_reputation: number;
freshness_score: number;
final_score: number;
source_category: string;
source_bias: string;
}
interface SearchResponse {
query: string;
results: SearchResult[];
total: number;
search_time_ms: number;
engine: string;
error?: string;
fallback_url?: string;
}
const SEARCH_API = "https://blackroad-search-api.amundsonalexa.workers.dev";
const categoryIcons: Record<string, string> = {
academic: "A",
journal: "J",
government: "G",
"wire-service": "W",
reference: "R",
news: "N",
"fact-check": "F",
"tech-docs": "T",
social: "S",
"low-credibility": "!",
};
export function SearchPage({ query, onNavigate }: SearchPageProps) {
const [searchInput, setSearchInput] = useState(query);
const [results, setResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchTime, setSearchTime] = useState(0);
const [engine, setEngine] = useState("");
const [searchError, setSearchError] = useState<string | null>(null);
const [fallbackUrl, setFallbackUrl] = useState<string | null>(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && searchInput.trim()) {
onNavigate(`blackroad://search?q=${encodeURIComponent(searchInput.trim())}`);
}
};
useEffect(() => {
if (!query) return;
let cancelled = false;
const doSearch = async () => {
setIsSearching(true);
setSearchError(null);
setFallbackUrl(null);
try {
const resp = await fetch(`${SEARCH_API}/search?q=${encodeURIComponent(query)}&limit=20`);
if (resp.ok && !cancelled) {
const data: SearchResponse = await resp.json();
setResults(data.results);
setSearchTime(data.search_time_ms);
setEngine(data.engine);
if (data.error) setSearchError(data.error);
if (data.fallback_url) setFallbackUrl(data.fallback_url);
} else if (!cancelled) {
setResults([]);
setSearchError("Search API returned an error.");
}
} catch {
if (!cancelled) {
setResults([]);
setSearchError("Search API offline.");
setFallbackUrl(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`);
}
} finally {
if (!cancelled) setIsSearching(false);
}
};
doSearch();
return () => { cancelled = true; };
}, [query]);
return (
<div className="min-h-full bg-black p-6">
<div className="max-w-3xl mx-auto">
{/* Search header */}
<div className="flex items-center gap-4 mb-8">
<h1
className="text-2xl font-bold br-gradient-text shrink-0 cursor-pointer"
onClick={() => onNavigate("blackroad://newtab")}
>
BlackRoad
</h1>
<div className="flex-1">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full px-4 py-2 rounded-lg bg-surface-elevated border border-gray-700 text-white text-sm outline-none focus:border-hotpink transition-colors placeholder-gray-500"
placeholder="Search the accurate web..."
autoFocus
/>
</div>
</div>
{/* Search status bar */}
{query && (
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted">
{isSearching
? "Searching & ranking by accuracy..."
: results.length > 0
? `${results.length} results ranked by accuracy (${searchTime}ms via ${engine})`
: null}
</p>
{!isSearching && results.length > 0 && (
<p className="text-[10px] text-gray-700">
0.4*Relevance + 0.3*Accuracy + 0.2*Source + 0.1*Fresh
</p>
)}
</div>
)}
{/* Loading */}
{isSearching && (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-hotpink border-t-transparent rounded-full mx-auto" />
<p className="text-xs text-muted mt-4">Ranking by accuracy...</p>
</div>
</div>
)}
{/* Results */}
{!isSearching && results.length > 0 && (
<div className="space-y-5">
{results.map((result, i) => (
<div
key={i}
className="group cursor-pointer"
onClick={() => onNavigate(result.url)}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`text-[10px] font-mono font-bold px-1.5 py-0.5 rounded ${
result.final_score > 0.7
? "text-emerald-400 bg-emerald-400/10"
: result.final_score > 0.45
? "text-amber-400 bg-amber-400/10"
: "text-red-400 bg-red-400/10"
}`}
>
{Math.round(result.final_score * 100)}
</span>
{result.source_category !== "unknown" && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-gray-800 text-gray-500 font-mono"
title={result.source_category}
>
{categoryIcons[result.source_category] || "?"}
</span>
)}
<span className="text-[11px] text-gray-600 truncate">
{result.domain}
</span>
</div>
<h3 className="text-sm text-electricblue group-hover:underline">
{result.title}
</h3>
<p className="text-xs text-gray-400 mt-1 leading-relaxed line-clamp-2">
{result.description}
</p>
<div className="flex items-center gap-3 mt-1.5">
<span className="text-[9px] text-gray-700">
rel:{Math.round(result.relevance_score * 100)}
</span>
<span className="text-[9px] text-gray-700">
acc:{Math.round(result.accuracy_score * 100)}
</span>
<span className="text-[9px] text-gray-700">
src:{Math.round(result.source_reputation * 100)}
</span>
<span className="text-[9px] text-gray-700">
fresh:{Math.round(result.freshness_score * 100)}
</span>
{result.source_bias !== "unknown" && result.source_bias !== "none" && (
<span className="text-[9px] text-amber-600">
bias:{result.source_bias}
</span>
)}
</div>
</div>
))}
</div>
)}
{/* No Brave key - DuckDuckGo fallback with accuracy overlay */}
{!isSearching && results.length === 0 && query && (
<div className="space-y-6">
{/* Accuracy-scored quick links for this query */}
<div className="p-4 rounded-lg border border-gray-800 bg-surface">
<h3 className="text-xs text-muted font-semibold mb-3 uppercase tracking-wider">
Accuracy-Ranked Sources
</h3>
<p className="text-[10px] text-gray-600 mb-3">
{searchError || "Searching high-accuracy sources for your query:"}
</p>
<div className="space-y-2">
{[
{ name: "Wikipedia", url: `https://en.wikipedia.org/w/index.php?search=${encodeURIComponent(query)}`, score: 88, cat: "R" },
{ name: "Google Scholar", url: `https://scholar.google.com/scholar?q=${encodeURIComponent(query)}`, score: 95, cat: "A" },
{ name: "PubMed", url: `https://pubmed.ncbi.nlm.nih.gov/?term=${encodeURIComponent(query)}`, score: 95, cat: "A" },
{ name: "Reuters", url: `https://www.reuters.com/search/news?query=${encodeURIComponent(query)}`, score: 93, cat: "W" },
{ name: "AP News", url: `https://apnews.com/search#?q=${encodeURIComponent(query)}`, score: 93, cat: "W" },
{ name: "BBC", url: `https://www.bbc.co.uk/search?q=${encodeURIComponent(query)}`, score: 85, cat: "N" },
{ name: "Nature", url: `https://www.nature.com/search?q=${encodeURIComponent(query)}`, score: 96, cat: "J" },
{ name: "arXiv", url: `https://arxiv.org/search/?query=${encodeURIComponent(query)}`, score: 95, cat: "A" },
].map((src, i) => (
<div
key={i}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-elevated cursor-pointer transition-colors"
onClick={() => onNavigate(src.url)}
>
<span className="text-[10px] font-mono font-bold text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded w-8 text-center">
{src.score}
</span>
<span className="text-[9px] px-1 py-0.5 rounded bg-gray-800 text-gray-500 font-mono">
{src.cat}
</span>
<span className="text-sm text-electricblue hover:underline">
{src.name}
</span>
<span className="text-[10px] text-gray-700 ml-auto truncate max-w-[200px]">
Search "{query}"
</span>
</div>
))}
</div>
</div>
{/* DuckDuckGo full search fallback */}
<div className="text-center">
<button
onClick={() => onNavigate(fallbackUrl || `https://duckduckgo.com/?q=${encodeURIComponent(query)}`)}
className="text-xs text-electricblue hover:underline"
>
Or search with DuckDuckGo (unranked)
</button>
<p className="text-[9px] text-gray-700 mt-2">
Add a Brave Search API key to enable full accuracy-ranked search
</p>
</div>
</div>
)}
{/* Empty state */}
{!query && (
<div className="text-center mt-20">
<p className="text-muted text-sm">
Enter a search query to get accuracy-ranked results
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
interface AccuracyScoreProps {
score: number;
}
export function AccuracyScore({ score }: AccuracyScoreProps) {
const percentage = Math.round(score * 100);
const radius = 40;
const circumference = 2 * Math.PI * radius;
const offset = circumference - score * circumference;
const color =
score > 0.8
? "#4ade80"
: score > 0.6
? "#F5A623"
: score > 0.4
? "#ff8c00"
: "#ef4444";
const label =
score > 0.8
? "High Accuracy"
: score > 0.6
? "Moderate"
: score > 0.4
? "Mixed"
: "Low Accuracy";
return (
<div className="p-6 flex flex-col items-center border-b border-gray-800">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="#1a1a1a"
strokeWidth="6"
/>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 50 50)"
style={{ transition: "stroke-dashoffset 0.8s ease" }}
/>
<text
x="50"
y="46"
textAnchor="middle"
dominantBaseline="central"
fill="white"
fontSize="24"
fontWeight="bold"
fontFamily="monospace"
>
{percentage}
</text>
<text
x="50"
y="62"
textAnchor="middle"
dominantBaseline="central"
fill="#666"
fontSize="8"
fontFamily="monospace"
>
{label}
</text>
</svg>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import type { ClaimResult } from "../../types/verification";
interface ClaimsListProps {
claims: ClaimResult[];
}
const verdictColors: Record<string, string> = {
Verified: "text-emerald-400 bg-emerald-400/10",
Likely: "text-green-300 bg-green-300/10",
Uncertain: "text-amber-400 bg-amber-400/10",
Disputed: "text-orange-400 bg-orange-400/10",
False: "text-red-400 bg-red-400/10",
};
const verdictIcons: Record<string, string> = {
Verified: "\u2713",
Likely: "~",
Uncertain: "?",
Disputed: "!",
False: "\u2715",
};
export function ClaimsList({ claims }: ClaimsListProps) {
if (claims.length === 0) {
return (
<div className="p-4">
<h3 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
Claims Analysis
</h3>
<p className="text-[10px] text-gray-700">
No verifiable claims extracted. This may be because the content is too
short, cross-origin restricted, or contains only opinions.
</p>
</div>
);
}
return (
<div className="p-4">
<h3 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
Claims ({claims.length})
</h3>
<div className="space-y-2">
{claims.map((claim, i) => (
<div
key={i}
className="p-2 rounded-lg bg-surface-elevated border border-gray-800"
>
<div className="flex items-start gap-2">
<span
className={`shrink-0 w-5 h-5 rounded flex items-center justify-center text-[10px] font-bold ${verdictColors[claim.verdict] || "text-muted bg-gray-800"}`}
>
{verdictIcons[claim.verdict] || "?"}
</span>
<div className="flex-1 min-w-0">
<p className="text-[11px] text-gray-300 leading-relaxed">
{claim.text}
</p>
{claim.reasoning && (
<p className="text-[10px] text-gray-500 mt-1 leading-snug">
{claim.reasoning}
</p>
)}
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-[9px] text-gray-600 uppercase">
{claim.claim_type}
</span>
{claim.confidence > 0 && (
<span className="text-[9px] text-gray-700">
{Math.round(claim.confidence * 100)}% conf
</span>
)}
</div>
{claim.sources.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{claim.sources.map((src, j) => (
<span
key={j}
className="text-[8px] text-electricblue truncate max-w-[200px]"
title={src}
>
[{j + 1}] {src}
</span>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
interface SourceReputationProps {
score: number;
url: string;
method: string;
category: string;
bias: string;
}
const categoryLabels: Record<string, string> = {
academic: "Academic",
journal: "Scientific Journal",
government: "Government",
"wire-service": "Wire Service",
reference: "Reference",
news: "News",
"fact-check": "Fact-Checker",
"tech-docs": "Tech Docs",
social: "Social Media",
"low-credibility": "Low Credibility",
education: "Education",
organization: "Organization",
unknown: "Unknown",
};
const biasColors: Record<string, string> = {
none: "text-emerald-400",
center: "text-emerald-400",
"center-left": "text-blue-400",
"center-right": "text-orange-300",
left: "text-blue-500",
right: "text-orange-400",
extreme: "text-red-400",
mixed: "text-amber-400",
unknown: "text-gray-500",
};
export function SourceReputation({ score, url, method, category, bias }: SourceReputationProps) {
let domain: string;
try {
domain = new URL(url).hostname;
} catch {
domain = url;
}
const percentage = Math.round(score * 100);
return (
<div className="p-4 border-b border-gray-800">
<h3 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
Source Reputation
</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400 truncate max-w-[160px]">
{domain}
</span>
<span
className={`text-xs font-mono font-bold ${
score > 0.8
? "text-emerald-400"
: score > 0.5
? "text-amber-400"
: "text-red-400"
}`}
>
{percentage}%
</span>
</div>
{/* Reputation bar */}
<div className="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700"
style={{
width: `${percentage}%`,
background:
score > 0.8
? "#4ade80"
: score > 0.5
? "#F5A623"
: "#ef4444",
}}
/>
</div>
{/* Category & Bias */}
<div className="flex items-center gap-2 flex-wrap">
{category && category !== "unknown" && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-gray-800 text-gray-400 uppercase">
{categoryLabels[category] || category}
</span>
)}
{bias && bias !== "unknown" && (
<span className={`text-[9px] px-1.5 py-0.5 rounded bg-gray-800 uppercase ${biasColors[bias] || "text-gray-500"}`}>
{bias}
</span>
)}
</div>
<p className="text-[10px] text-gray-700">
Method: {method}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import type { VerificationResult } from "../../types/verification";
import { AccuracyScore } from "./AccuracyScore";
import { SourceReputation } from "./SourceReputation";
import { ClaimsList } from "./ClaimsList";
interface VerificationPanelProps {
verification: VerificationResult | null;
isVerifying: boolean;
}
export function VerificationPanel({
verification,
isVerifying,
}: VerificationPanelProps) {
return (
<div className="w-72 bg-surface border-l border-gray-800 overflow-y-auto flex flex-col shrink-0">
<div className="p-4 border-b border-gray-800">
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
<svg
className="w-4 h-4 text-hotpink"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
Verification
{verification?.ai_available && (
<span className="ml-auto text-[9px] px-1.5 py-0.5 rounded bg-hotpink/20 text-hotpink font-medium">
AI
</span>
)}
</h2>
<p className="text-[10px] text-muted mt-1 tracking-wider uppercase">
Accurate info. Period.
</p>
</div>
{isVerifying ? (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-hotpink border-t-transparent rounded-full mx-auto" />
<p className="text-xs text-muted mt-4">Analyzing page...</p>
<p className="text-[10px] text-gray-700 mt-1">
Checking sources & claims
</p>
</div>
</div>
) : verification ? (
<>
<AccuracyScore score={verification.overall_score} />
<SourceReputation
score={verification.source_reputation}
url={verification.url}
method={verification.method}
category={verification.source_category}
bias={verification.source_bias}
/>
{verification.claims_checked > 0 && (
<div className="px-4 py-2 border-b border-gray-800">
<p className="text-[10px] text-muted">
{verification.claims_checked} claims verified in{" "}
{verification.verification_time_ms}ms
</p>
</div>
)}
<ClaimsList claims={verification.claims} />
</>
) : (
<div className="flex-1 flex items-center justify-center p-6">
<div className="text-center">
<svg
className="w-10 h-10 text-gray-800 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<p className="text-xs text-muted">
Navigate to a page to see verification results
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Tab, PageInfo } from "../types/browser";
let tabCounter = 1;
function makeTab(): Tab {
const id = `tab-${++tabCounter}`;
return { id, url: "blackroad://newtab", title: "New Tab", isLoading: false };
}
export function useTabs() {
const [tabs, setTabs] = useState<Tab[]>([
{ id: "tab-1", url: "blackroad://newtab", title: "New Tab", isLoading: false },
]);
const [activeTabId, setActiveTabId] = useState("tab-1");
const activeTab = tabs.find((t) => t.id === activeTabId) ?? null;
const addTab = useCallback(() => {
const tab = makeTab();
setTabs((prev) => [...prev, tab]);
setActiveTabId(tab.id);
}, []);
const closeTab = useCallback(
(id: string) => {
setTabs((prev) => {
const next = prev.filter((t) => t.id !== id);
if (next.length === 0) {
const tab = makeTab();
setActiveTabId(tab.id);
return [tab];
}
return next;
});
if (activeTabId === id) {
setTabs((prev) => {
const remaining = prev.filter((t) => t.id !== id);
if (remaining.length > 0) {
setActiveTabId(remaining[remaining.length - 1].id);
}
return prev;
});
}
},
[activeTabId],
);
const navigateTo = useCallback(
async (url: string) => {
if (!activeTabId) return;
try {
const pageInfo = await invoke<PageInfo>("navigate_to", { url });
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? { ...t, url: pageInfo.url, isLoading: true }
: t,
),
);
} catch (e) {
console.error("Navigation failed:", e);
}
},
[activeTabId],
);
const updateTabTitle = useCallback(
(title: string) => {
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId ? { ...t, title, isLoading: false } : t,
),
);
},
[activeTabId],
);
return {
tabs,
activeTab,
addTab,
closeTab,
setActiveTab: setActiveTabId,
navigateTo,
updateTabTitle,
};
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { VerificationResult } from "../types/verification";
export function useVerification(url?: string) {
const [verification, setVerification] =
useState<VerificationResult | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [pageContent, setPageContent] = useState("");
// Called by BrowserFrame when page content is available
const onContentReady = useCallback((content: string) => {
setPageContent(content);
}, []);
useEffect(() => {
if (!url || url.startsWith("blackroad://")) {
setVerification(null);
return;
}
let cancelled = false;
const verify = async () => {
setIsVerifying(true);
try {
// Check cache first
const cached = await invoke<VerificationResult | null>(
"get_verification_status",
{ url },
);
if (!cancelled && cached) {
setVerification(cached);
setIsVerifying(false);
return;
}
// Run verification with whatever content we have
const result = await invoke<VerificationResult>("verify_page", {
url,
content: pageContent,
});
if (!cancelled) {
setVerification(result);
}
} catch (err) {
console.error("Verification failed:", err);
} finally {
if (!cancelled) {
setIsVerifying(false);
}
}
};
verify();
return () => {
cancelled = true;
};
}, [url, pageContent]);
return {
verification,
isVerifying,
sidebarOpen,
toggleSidebar: () => setSidebarOpen((v) => !v),
onContentReady,
};
}

10
apps/browser/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: "JetBrains Mono", "SF Mono", "Fira Code", monospace;
background: #000000;
color: #ffffff;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
}
@layer components {
.br-gradient-text {
@apply bg-gradient-to-r from-amber via-hotpink to-electricblue bg-clip-text text-transparent;
}
.br-gradient-border {
border-image: linear-gradient(135deg, #f5a623, #ff1d6c, #9c27b0, #2979ff) 1;
}
.br-glow {
box-shadow: 0 0 30px rgba(255, 29, 108, 0.2);
}
}

View File

@@ -0,0 +1,21 @@
export interface Tab {
id: string;
url: string;
title: string;
favicon?: string;
isLoading: boolean;
}
export interface PageInfo {
url: string;
title: string;
is_secure: boolean;
is_search: boolean;
}
export interface BrowserSettings {
verification_enabled: boolean;
sidebar_position: "left" | "right";
theme: "dark" | "light";
default_search: string;
}

View File

@@ -0,0 +1,35 @@
export type ClaimType =
| "Factual"
| "Statistical"
| "Quote"
| "Opinion"
| "Prediction";
export type Verdict =
| "Verified"
| "Likely"
| "Uncertain"
| "Disputed"
| "False";
export interface ClaimResult {
text: string;
claim_type: ClaimType;
confidence: number;
verdict: Verdict;
reasoning: string;
sources: string[];
}
export interface VerificationResult {
url: string;
overall_score: number;
source_reputation: number;
source_category: string;
source_bias: string;
claims: ClaimResult[];
claims_checked: number;
verification_time_ms: number;
method: string;
ai_available: boolean;
}

View File

@@ -0,0 +1,28 @@
import type { Config } from "tailwindcss";
export default {
content: ["./src/**/*.{tsx,ts,jsx,js}", "./index.html"],
darkMode: "class",
theme: {
extend: {
colors: {
hotpink: "#FF1D6C",
amber: "#F5A623",
electricblue: "#2979FF",
violet: "#9C27B0",
surface: "#0A0A0A",
"surface-elevated": "#1A1A1A",
muted: "#666666",
background: "#000000",
},
fontFamily: {
mono: ['"JetBrains Mono"', '"SF Mono"', '"Fira Code"', "monospace"],
},
boxShadow: {
glow: "0 0 40px rgba(255, 29, 108, 0.3)",
"glow-blue": "0 0 40px rgba(41, 121, 255, 0.3)",
},
},
},
plugins: [],
} satisfies Config;

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? { protocol: "ws", host, port: 1421 }
: undefined,
},
build: {
target: "esnext",
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
});

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "blackroad-internet",
"private": true,
"packageManager": "pnpm@10.28.2",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"dev:browser": "cd apps/browser && cargo tauri dev",
"build:browser": "cd apps/browser && cargo tauri build"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.6.3"
}
}

2655
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- "apps/*"
- "packages/*"
- "workers/*"

13
turbo.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"persistent": true,
"cache": false
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "target/**"]
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@blackroad/search-api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types"
},
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250124.0",
"wrangler": "^4.4.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,249 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
export interface Env {
BRAVE_API_KEY: string;
VERIFICATION_API: Fetcher;
ENVIRONMENT: string;
}
interface SearchResult {
title: string;
url: string;
description: string;
age?: string;
}
interface RankedResult {
title: string;
url: string;
description: string;
domain: string;
relevance_score: number;
accuracy_score: number;
source_reputation: number;
freshness_score: number;
final_score: number;
source_category: string;
source_bias: string;
}
const app = new Hono<{ Bindings: Env }>();
app.use("*", cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type"],
}));
app.get("/", (c) => {
return c.json({
service: "BlackRoad Search API",
version: "0.1.0",
status: "operational",
ranking: "0.4*Relevance + 0.3*Accuracy + 0.2*SourceReputation + 0.1*Freshness",
endpoints: {
search: "GET /search?q=query&limit=20 (requires BRAVE_API_KEY)",
enrich: "POST /enrich (enrich client-side results with reputation scores)",
reputation: "GET /reputation/:domain (via verification-api)",
},
});
});
app.get("/health", (c) => c.json({ ok: true, ts: Date.now() }));
// GET /search?q=query&limit=20 - Full search (requires Brave API key)
app.get("/search", async (c) => {
const query = c.req.query("q");
if (!query) {
return c.json({ error: "q query parameter required" }, 400);
}
const limit = Math.min(parseInt(c.req.query("limit") || "20"), 50);
const start = Date.now();
if (!c.env.BRAVE_API_KEY) {
return c.json({
query,
results: [],
total: 0,
search_time_ms: Date.now() - start,
engine: "none",
fallback_url: `https://duckduckgo.com/?q=${encodeURIComponent(query)}`,
error: "BRAVE_API_KEY not configured. Use /enrich endpoint to score client-side results, or set up Brave Search API key.",
});
}
let rawResults: SearchResult[] = [];
try {
const braveResp = await fetch(
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`,
{
headers: {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": c.env.BRAVE_API_KEY,
},
},
);
if (braveResp.ok) {
const data = (await braveResp.json()) as { web?: { results: SearchResult[] } };
rawResults = data.web?.results || [];
}
} catch {
return c.json({
query,
results: [],
total: 0,
search_time_ms: Date.now() - start,
engine: "brave",
error: "Brave Search API error",
});
}
return c.json(await rankResults(rawResults, query, start, "brave", c.env.VERIFICATION_API, limit));
});
// POST /enrich - Score/rank a set of results from client-side scraping
// Body: { query: string, results: [{ title, url, description }] }
app.post("/enrich", async (c) => {
const body = await c.req.json<{ query: string; results: SearchResult[] }>();
if (!body.query || !body.results) {
return c.json({ error: "query and results[] required" }, 400);
}
const start = Date.now();
return c.json(await rankResults(body.results, body.query, start, "enriched", c.env.VERIFICATION_API, 50));
});
// GET /reputation/:domain - Proxy to verification API
app.get("/reputation/:domain", async (c) => {
const domain = c.req.param("domain");
try {
const resp = await c.env.VERIFICATION_API.fetch(
new Request(`https://internal/reputation/${domain}`),
);
const data = await resp.json();
return c.json(data);
} catch {
return c.json({ domain, score: 0.5, category: "unknown", bias: "unknown", error: "lookup failed" });
}
});
/** Core ranking logic - shared between /search and /enrich */
async function rankResults(
rawResults: SearchResult[],
query: string,
startTime: number,
engine: string,
verificationApi: Fetcher,
limit: number,
) {
if (rawResults.length === 0) {
return {
query,
results: [],
total: 0,
search_time_ms: Date.now() - startTime,
engine,
};
}
// Batch lookup domain reputations
const domains = [...new Set(rawResults.map((r) => extractDomain(r.url)))];
const reputations = await batchLookupReputations(verificationApi, domains);
// Apply BlackRoad accuracy formula
const ranked: RankedResult[] = rawResults.map((result, index) => {
const domain = extractDomain(result.url);
const rep = reputations[domain] || { score: 0.5, category: "unknown", bias: "unknown" };
const relevance = 1.0 - (index / rawResults.length) * 0.5;
const accuracy = rep.score;
const sourceRep = rep.score;
const freshness = computeFreshness(result.age);
const finalScore = 0.4 * relevance + 0.3 * accuracy + 0.2 * sourceRep + 0.1 * freshness;
return {
title: result.title,
url: result.url,
description: result.description,
domain,
relevance_score: Math.round(relevance * 100) / 100,
accuracy_score: Math.round(accuracy * 100) / 100,
source_reputation: Math.round(sourceRep * 100) / 100,
freshness_score: Math.round(freshness * 100) / 100,
final_score: Math.round(finalScore * 100) / 100,
source_category: rep.category,
source_bias: rep.bias,
};
});
ranked.sort((a, b) => b.final_score - a.final_score);
return {
query,
results: ranked.slice(0, limit),
total: ranked.length,
search_time_ms: Date.now() - startTime,
engine,
ranking_formula: "0.4*Relevance + 0.3*Accuracy + 0.2*SourceReputation + 0.1*Freshness",
};
}
async function batchLookupReputations(
verificationApi: Fetcher,
domains: string[],
): Promise<Record<string, { score: number; category: string; bias: string }>> {
try {
const resp = await verificationApi.fetch(
new Request("https://internal/reputation/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domains }),
}),
);
if (resp.ok) {
const data = (await resp.json()) as { results: Record<string, { score: number; category: string; bias: string }> };
return data.results;
}
} catch {
// Service binding unavailable
}
const defaults: Record<string, { score: number; category: string; bias: string }> = {};
for (const d of domains) {
defaults[d] = { score: 0.5, category: "unknown", bias: "unknown" };
}
return defaults;
}
function computeFreshness(age?: string): number {
if (!age) return 0.5;
const lower = age.toLowerCase();
if (lower.includes("hour") || lower.includes("minute")) return 1.0;
if (lower.includes("day")) {
const days = parseInt(lower) || 1;
return days <= 7 ? 0.9 : days <= 30 ? 0.7 : 0.5;
}
if (lower.includes("week")) return 0.7;
if (lower.includes("month")) {
const months = parseInt(lower) || 1;
return months <= 3 ? 0.5 : months <= 12 ? 0.3 : 0.2;
}
if (lower.includes("year")) return 0.1;
return 0.5;
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return url;
}
}
export default app;

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2021"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,17 @@
name = "blackroad-search-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[vars]
ENVIRONMENT = "production"
# Brave Search API key - set via wrangler secret
# wrangler secret put BRAVE_API_KEY
# Service binding to verification API
[[services]]
binding = "VERIFICATION_API"
service = "blackroad-verification-api"
[observability]
enabled = true

View File

@@ -0,0 +1,127 @@
-- BlackRoad Verification API - D1 Schema
-- Source reputation + verification cache + community reports
CREATE TABLE IF NOT EXISTS source_reputation (
domain TEXT PRIMARY KEY,
score REAL NOT NULL DEFAULT 0.5,
category TEXT NOT NULL DEFAULT 'unknown',
bias TEXT NOT NULL DEFAULT 'unknown',
factual TEXT NOT NULL DEFAULT 'unknown',
description TEXT,
updated_at TEXT DEFAULT (datetime('now')),
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS verification_cache (
url TEXT PRIMARY KEY,
result_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS community_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
domain TEXT NOT NULL,
claim_text TEXT,
report_type TEXT NOT NULL,
details TEXT,
reporter_id TEXT DEFAULT 'anonymous',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_reports_domain ON community_reports(domain);
CREATE INDEX IF NOT EXISTS idx_reports_url ON community_reports(url);
CREATE INDEX IF NOT EXISTS idx_cache_created ON verification_cache(created_at);
-- Seed the source reputation database (same domains as Rust backend)
-- Academic & Research
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('arxiv.org', 0.95, 'academic', 'none', 'very-high'),
('scholar.google.com', 0.95, 'academic', 'none', 'very-high'),
('pubmed.ncbi.nlm.nih.gov', 0.95, 'academic', 'none', 'very-high'),
('jstor.org', 0.95, 'academic', 'none', 'very-high'),
('sciencedirect.com', 0.95, 'academic', 'none', 'very-high'),
('ieee.org', 0.95, 'academic', 'none', 'very-high'),
('acm.org', 0.95, 'academic', 'none', 'very-high'),
('researchgate.net', 0.95, 'academic', 'none', 'very-high');
-- Scientific Journals
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('nature.com', 0.96, 'journal', 'none', 'very-high'),
('science.org', 0.96, 'journal', 'none', 'very-high'),
('cell.com', 0.96, 'journal', 'none', 'very-high'),
('thelancet.com', 0.96, 'journal', 'none', 'very-high'),
('nejm.org', 0.96, 'journal', 'none', 'very-high'),
('bmj.com', 0.96, 'journal', 'none', 'very-high'),
('pnas.org', 0.96, 'journal', 'none', 'very-high'),
('plos.org', 0.96, 'journal', 'none', 'very-high');
-- Government
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('nasa.gov', 0.92, 'government', 'none', 'very-high'),
('nih.gov', 0.92, 'government', 'none', 'very-high'),
('cdc.gov', 0.92, 'government', 'none', 'very-high'),
('fda.gov', 0.92, 'government', 'none', 'very-high'),
('noaa.gov', 0.92, 'government', 'none', 'very-high'),
('census.gov', 0.92, 'government', 'none', 'very-high'),
('sec.gov', 0.92, 'government', 'none', 'very-high'),
('data.gov', 0.92, 'government', 'none', 'very-high');
-- Wire Services
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('apnews.com', 0.93, 'wire-service', 'center', 'very-high'),
('reuters.com', 0.93, 'wire-service', 'center', 'very-high'),
('afp.com', 0.91, 'wire-service', 'center', 'very-high');
-- Reference
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('wikipedia.org', 0.88, 'reference', 'none', 'high'),
('britannica.com', 0.88, 'reference', 'none', 'high'),
('who.int', 0.88, 'reference', 'none', 'high'),
('worldbank.org', 0.88, 'reference', 'none', 'high');
-- Fact-checking
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('snopes.com', 0.90, 'fact-check', 'center', 'very-high'),
('factcheck.org', 0.90, 'fact-check', 'center', 'very-high'),
('politifact.com', 0.90, 'fact-check', 'center', 'very-high'),
('fullfact.org', 0.90, 'fact-check', 'center', 'very-high');
-- Quality News
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('bbc.com', 0.85, 'news', 'center', 'high'),
('bbc.co.uk', 0.85, 'news', 'center', 'high'),
('npr.org', 0.84, 'news', 'center', 'high'),
('pbs.org', 0.84, 'news', 'center', 'high'),
('economist.com', 0.83, 'news', 'center', 'high'),
('bloomberg.com', 0.83, 'news', 'center', 'high'),
('ft.com', 0.83, 'news', 'center', 'high'),
('wsj.com', 0.82, 'news', 'center-right', 'high'),
('nytimes.com', 0.82, 'news', 'center-left', 'high'),
('washingtonpost.com', 0.81, 'news', 'center-left', 'high'),
('theguardian.com', 0.80, 'news', 'center-left', 'high'),
('propublica.org', 0.86, 'news', 'center-left', 'high');
-- Tech Docs
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('developer.mozilla.org', 0.85, 'tech-docs', 'none', 'high'),
('docs.python.org', 0.85, 'tech-docs', 'none', 'high'),
('docs.rust-lang.org', 0.85, 'tech-docs', 'none', 'high'),
('stackoverflow.com', 0.85, 'tech-docs', 'none', 'high');
-- Social Media (low trust)
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('twitter.com', 0.30, 'social', 'mixed', 'low'),
('x.com', 0.30, 'social', 'mixed', 'low'),
('facebook.com', 0.30, 'social', 'mixed', 'low'),
('instagram.com', 0.30, 'social', 'mixed', 'low'),
('tiktok.com', 0.30, 'social', 'mixed', 'low'),
('reddit.com', 0.30, 'social', 'mixed', 'low');
-- Known low credibility
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
('infowars.com', 0.15, 'low-credibility', 'extreme', 'very-low'),
('naturalnews.com', 0.15, 'low-credibility', 'extreme', 'very-low'),
('breitbart.com', 0.15, 'low-credibility', 'extreme', 'very-low'),
('dailymail.co.uk', 0.15, 'low-credibility', 'extreme', 'very-low');

View File

@@ -0,0 +1,18 @@
{
"name": "@blackroad/verification-api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types"
},
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250124.0",
"wrangler": "^4.4.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,40 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { reputation } from "./routes/reputation";
import { verify } from "./routes/verify";
import { reports } from "./routes/reports";
export interface Env {
DB: D1Database;
ENVIRONMENT: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS for browser access
app.use("*", cors({
origin: ["http://localhost:1420", "https://internet.blackroad.io", "https://search.blackroad.io"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
}));
// Health check
app.get("/", (c) => {
return c.json({
service: "BlackRoad Verification API",
version: "0.1.0",
status: "operational",
mission: "Accurate info. Period.",
});
});
app.get("/health", (c) => {
return c.json({ ok: true, ts: Date.now() });
});
// Mount route groups
app.route("/reputation", reputation);
app.route("/verify", verify);
app.route("/reports", reports);
export default app;

View File

@@ -0,0 +1,115 @@
import { Hono } from "hono";
import type { Env } from "../index";
export const reports = new Hono<{ Bindings: Env }>();
interface ReportRequest {
url: string;
claim_text?: string;
report_type: "inaccurate" | "misleading" | "satire" | "outdated" | "biased";
details?: string;
reporter_id?: string;
}
// POST /reports - Submit a community accuracy report
reports.post("/", async (c) => {
const body = await c.req.json<ReportRequest>();
if (!body.url || !body.report_type) {
return c.json({ error: "url and report_type required" }, 400);
}
const validTypes = ["inaccurate", "misleading", "satire", "outdated", "biased"];
if (!validTypes.includes(body.report_type)) {
return c.json({ error: `report_type must be one of: ${validTypes.join(", ")}` }, 400);
}
try {
const domain = extractDomain(body.url);
await c.env.DB.prepare(
`INSERT INTO community_reports (url, domain, claim_text, report_type, details, reporter_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
)
.bind(
body.url,
domain,
body.claim_text || null,
body.report_type,
body.details || null,
body.reporter_id || "anonymous",
)
.run();
return c.json({ success: true, message: "Report submitted" });
} catch (err) {
return c.json({ error: "Failed to submit report", detail: String(err) }, 500);
}
});
// GET /reports?domain=... - Get reports for a domain
reports.get("/", async (c) => {
const domain = c.req.query("domain");
const url = c.req.query("url");
const limit = Math.min(parseInt(c.req.query("limit") || "20"), 100);
let query: string;
let bindValue: string;
if (url) {
query = "SELECT * FROM community_reports WHERE url = ? ORDER BY created_at DESC LIMIT ?";
bindValue = url;
} else if (domain) {
query = "SELECT * FROM community_reports WHERE domain = ? ORDER BY created_at DESC LIMIT ?";
bindValue = domain;
} else {
return c.json({ error: "domain or url query parameter required" }, 400);
}
try {
const { results } = await c.env.DB.prepare(query).bind(bindValue, limit).all();
return c.json({ reports: results, count: results.length });
} catch (err) {
return c.json({ error: "Query failed", detail: String(err) }, 500);
}
});
// GET /reports/stats?domain=... - Report stats for a domain
reports.get("/stats", async (c) => {
const domain = c.req.query("domain");
if (!domain) {
return c.json({ error: "domain query parameter required" }, 400);
}
try {
const { results } = await c.env.DB.prepare(
`SELECT report_type, COUNT(*) as count
FROM community_reports WHERE domain = ?
GROUP BY report_type`,
)
.bind(domain)
.all();
const total = await c.env.DB.prepare(
"SELECT COUNT(*) as total FROM community_reports WHERE domain = ?",
)
.bind(domain)
.first<{ total: number }>();
return c.json({
domain,
total_reports: total?.total || 0,
by_type: results,
});
} catch (err) {
return c.json({ error: "Query failed", detail: String(err) }, 500);
}
});
function extractDomain(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return url;
}
}

View File

@@ -0,0 +1,112 @@
import { Hono } from "hono";
import type { Env } from "../index";
export const reputation = new Hono<{ Bindings: Env }>();
// GET /reputation/:domain - Look up domain reputation
reputation.get("/:domain", async (c) => {
const domain = c.req.param("domain").toLowerCase();
try {
const row = await c.env.DB.prepare(
"SELECT domain, score, category, bias, factual, updated_at FROM source_reputation WHERE domain = ?",
)
.bind(domain)
.first();
if (!row) {
// Try parent domain
const parts = domain.split(".");
if (parts.length > 2) {
const parent = parts.slice(-2).join(".");
const parentRow = await c.env.DB.prepare(
"SELECT domain, score, category, bias, factual, updated_at FROM source_reputation WHERE domain = ?",
)
.bind(parent)
.first();
if (parentRow) {
return c.json({ found: true, ...parentRow, matched_via: "parent" });
}
}
// TLD fallbacks
if (domain.endsWith(".gov")) {
return c.json({
found: true,
domain,
score: 0.88,
category: "government",
bias: "none",
factual: "high",
matched_via: "tld",
});
}
if (domain.endsWith(".edu")) {
return c.json({
found: true,
domain,
score: 0.85,
category: "education",
bias: "none",
factual: "high",
matched_via: "tld",
});
}
return c.json({ found: false, domain, score: 0.5, category: "unknown", bias: "unknown", factual: "unknown" });
}
return c.json({ found: true, ...row });
} catch (err) {
return c.json({ error: "Database error", detail: String(err) }, 500);
}
});
// POST /reputation/batch - Batch lookup with parent domain resolution
reputation.post("/batch", async (c) => {
const body = await c.req.json<{ domains: string[] }>();
if (!body.domains || !Array.isArray(body.domains)) {
return c.json({ error: "domains array required" }, 400);
}
const results: Record<string, unknown> = {};
for (const domain of body.domains.slice(0, 50)) {
const d = domain.toLowerCase();
// Direct match
let row = await c.env.DB.prepare(
"SELECT score, category, bias, factual FROM source_reputation WHERE domain = ?",
)
.bind(d)
.first();
// Try parent domain (e.g. climate.nasa.gov -> nasa.gov)
if (!row) {
const parts = d.split(".");
if (parts.length > 2) {
const parent = parts.slice(-2).join(".");
row = await c.env.DB.prepare(
"SELECT score, category, bias, factual FROM source_reputation WHERE domain = ?",
)
.bind(parent)
.first();
}
}
// TLD fallbacks
if (!row) {
if (d.endsWith(".gov")) {
row = { score: 0.88, category: "government", bias: "none", factual: "high" } as any;
} else if (d.endsWith(".edu")) {
row = { score: 0.85, category: "education", bias: "none", factual: "high" } as any;
} else if (d.endsWith(".org")) {
row = { score: 0.60, category: "organization", bias: "unknown", factual: "mixed" } as any;
}
}
results[domain] = row || { score: 0.5, category: "unknown", bias: "unknown", factual: "unknown" };
}
return c.json({ results });
});

View File

@@ -0,0 +1,100 @@
import { Hono } from "hono";
import type { Env } from "../index";
export const verify = new Hono<{ Bindings: Env }>();
interface VerifyRequest {
url: string;
content?: string;
claims?: string[];
}
// POST /verify - Verify a page or set of claims
verify.post("/", async (c) => {
const body = await c.req.json<VerifyRequest>();
if (!body.url) {
return c.json({ error: "url required" }, 400);
}
const domain = extractDomain(body.url);
const start = Date.now();
// 1. Get source reputation from D1
const repRow = await c.env.DB.prepare(
"SELECT score, category, bias, factual FROM source_reputation WHERE domain = ?",
)
.bind(domain)
.first<{ score: number; category: string; bias: string; factual: string }>();
const sourceScore = repRow?.score ?? 0.5;
const sourceCategory = repRow?.category ?? "unknown";
const sourceBias = repRow?.bias ?? "unknown";
// 2. Check verification cache
const cached = await c.env.DB.prepare(
"SELECT result_json FROM verification_cache WHERE url = ? AND created_at > datetime('now', '-1 hour')",
)
.bind(body.url)
.first<{ result_json: string }>();
if (cached) {
return c.json(JSON.parse(cached.result_json));
}
// 3. Build verification result
// In Phase 2, cloud-side AI verification uses source reputation + basic heuristics.
// Full AI pipeline runs client-side via Ollama.
const result = {
url: body.url,
overall_score: sourceScore,
source_reputation: sourceScore,
source_category: sourceCategory,
source_bias: sourceBias,
claims: [] as unknown[],
claims_checked: 0,
verification_time_ms: Date.now() - start,
method: "cloud-reputation",
ai_available: false,
};
// 4. Cache result
try {
await c.env.DB.prepare(
"INSERT OR REPLACE INTO verification_cache (url, result_json, created_at) VALUES (?, ?, datetime('now'))",
)
.bind(body.url, JSON.stringify(result))
.run();
} catch {
// Cache write failure is non-critical
}
return c.json(result);
});
// GET /verify/status?url=... - Check cached verification
verify.get("/status", async (c) => {
const url = c.req.query("url");
if (!url) {
return c.json({ error: "url query parameter required" }, 400);
}
const cached = await c.env.DB.prepare(
"SELECT result_json, created_at FROM verification_cache WHERE url = ?",
)
.bind(url)
.first<{ result_json: string; created_at: string }>();
if (!cached) {
return c.json({ found: false });
}
return c.json({ found: true, ...JSON.parse(cached.result_json), cached_at: cached.created_at });
});
function extractDomain(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return url;
}
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2021"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,14 @@
name = "blackroad-verification-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[vars]
ENVIRONMENT = "production"
[[d1_databases]]
binding = "DB"
database_name = "blackroad-verification"
database_id = "e781869d-962c-459d-91c3-f0edbf111815"
[observability]
enabled = true