Scam warning: there is no TIWD token, logo added

This repository intentionally contains a single commit to protect
the anonymity of the founding team.
This commit is contained in:
tiwd 2026-04-06 01:28:33 +00:00
commit fdcb014328
51 changed files with 23392 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# Secrets — never commit
.env
# Build artifacts
target/
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Node identity keys
*.key
identity.key

4091
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

44
Cargo.toml Normal file
View File

@ -0,0 +1,44 @@
[workspace]
members = [
"crates/tiwd-core",
"crates/tiwd-trust",
"crates/tiwd-chat",
"crates/tiwd-sdk",
"crates/tiwd-node",
]
exclude = ["app/src-tauri"]
resolver = "2"
[workspace.dependencies]
libp2p = { version = "0.54", features = [
"tokio",
"tcp",
"noise",
"yamux",
"kad",
"mdns",
"gossipsub",
"identify",
"macros",
"ed25519",
"request-response",
"cbor",
"dns",
] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_cbor = "0.11"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
base64 = "0.22"
sha2 = "0.10"
rand = "0.8"
directories = "5"
futures = "0.3"
async-trait = "0.1"
warp = { version = "0.3", features = ["tls"] }
tokio-tungstenite = "0.24"

15
FOUNDING_TEAM.gpg Normal file
View File

@ -0,0 +1,15 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEadLbFRYJKwYBBAHaRw8BAQdA9Fc+XI7m7LWd7wTI+C+mt0rGQtq3UIExRGEx
nd747Um0GXRpd2QgPHRpd2QyMDI2QHByb3Rvbi5tZT6IkAQTFggAOBYhBN5VZpAh
r/dzbrMLAEP8qxA3drGuBQJp0tsVAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
AAoJEEP8qxA3drGulFIBANDtCOADmTn8aaMqvsjReblkoScoxCHLVHmQKa4oaYvP
APoCxNi2G6FrRv6Blg2YkyHsZbRFGOT2R+Auft4L/4eECrgzBGnS2xUWCSsGAQQB
2kcPAQEHQLsirmPG36TN9RaOVfXWZkOKKp8Rpx23qnxEftrt9TFmiO8EGBYIACAW
IQTeVWaQIa/3c26zCwBD/KsQN3axrgUCadLbFQIbAgCBCRBD/KsQN3axrnYgBBkW
CAAdFiEEaim3R8XGmjfqD01IcdBy2+B2X0QFAmnS2xUACgkQcdBy2+B2X0Q9fQD/
ZqOTPDQF4/7LDx64YyRfOk+tDiV7zv0Z2gjEQZxntyMBAPARrpbYrZcMAuL7cPIT
JRNwEZQCLo0YC7VNyi/TcIsEkF0BAJTSMppNLm7DWzUl7Fp2xUkSlVXuUCX6+kUa
8EnhNYUdAQDORgJhC6ysA2Rg2gVvjGhpLd473Z5yepwrXz37MWWYDA==
=DFOl
-----END PGP PUBLIC KEY BLOCK-----

26
PROOF.asc Normal file
View File

@ -0,0 +1,26 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
We are the founding team of TIWD — The Internet We Deserve.
This GPG key (fingerprint DE55 6690 21AF F773 6EB3 0B00 43FC AB10 3776 B1AE)
is the sole proof of our identity. Whoever controls the private key
corresponding to this fingerprint is the original founding team of TIWD.
This key was generated on 2026-04-05, the same day the project was created.
Any communication signed by this key is authentic.
Any communication NOT signed by this key should not be assumed to be from us.
If we ever need to transfer leadership, we will sign a message with this key
designating our successors. Without such a signed message, no one can claim
to speak on our behalf.
This is our only identity. We have no other.
-----BEGIN PGP SIGNATURE-----
iIkEARYIADEWIQRqKbdHxcaaN+oPTUhx0HLb4HZfRAUCadLjoBMcdGl3ZDIwMjZA
cHJvdG9uLm1lAAoJEHHQctvgdl9EqaMBAIM+bJ7Zpt7kOxRW14NP1W0ST2Ez+aOt
WqQQGt5EdWuCAQD5hZ0t6Z6A/u3LyAcF+fPkrYuGAOjNVh6cGmhtxqXsBg==
=tGY/
-----END PGP SIGNATURE-----

2735
README.md Normal file

File diff suppressed because it is too large Load Diff

103
app/index.html Normal file
View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIWD</title>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app">
<!-- Top Bar: Address bar + controls -->
<div class="topbar">
<div class="topbar-left">
<button class="nav-btn" id="btnBack" title="Back">&#8592;</button>
<button class="nav-btn" id="btnForward" title="Forward">&#8594;</button>
<button class="nav-btn" id="btnRefresh" title="Refresh">&#8635;</button>
</div>
<div class="address-bar">
<div class="address-icon">&#127760;</div>
<input type="text" id="addressInput" placeholder="Enter a .tiwd address or URL..." autocomplete="off" spellcheck="false">
<button class="go-btn" id="btnGo">Go</button>
</div>
<div class="topbar-right">
<button class="nav-btn" id="btnChat" title="Chat">&#128172;</button>
<button class="nav-btn" id="btnIdentity" title="Identity">&#128100;</button>
<div class="status-indicator">
<div class="status-dot" id="statusDot"></div>
<span id="peerCount">0</span>
</div>
</div>
</div>
<!-- Tab Bar -->
<div class="tabbar" id="tabBar">
<div class="tab active" data-tab="home">
<span class="tab-title">Home</span>
<button class="tab-close">&times;</button>
</div>
<button class="tab-new" id="btnNewTab">+</button>
</div>
<!-- Main Content Area -->
<div class="content-area">
<!-- Browser View -->
<div class="browser-view" id="browserView">
<iframe id="contentFrame" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<!-- Chat Sidebar (hidden by default) -->
<div class="chat-sidebar" id="chatSidebar">
<div class="chat-header-bar">
<span>Chat</span>
<button class="nav-btn" id="btnCloseChat">&times;</button>
</div>
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-input-area">
<input type="text" id="chatInput" placeholder="Message..." autocomplete="off">
<button class="go-btn" id="btnSendChat">Send</button>
</div>
</div>
</div>
<!-- Status Bar -->
<div class="statusbar">
<span id="statusText">Ready</span>
<span class="statusbar-right">
<span id="trustBadge">Newcomer</span>
<span id="nodeName">-</span>
</span>
</div>
<!-- Identity Panel (hidden) -->
<div class="identity-panel" id="identityPanel">
<h3>Your Identity</h3>
<div class="id-field">
<label>TIWD Name</label>
<div id="idName">Not registered</div>
</div>
<div class="id-field">
<label>Peer ID</label>
<div id="idPeer" class="mono">-</div>
</div>
<div class="id-field">
<label>Trust Score</label>
<div id="idTrust">0.0 / 100</div>
</div>
<div class="id-field">
<label>Role</label>
<div id="idRole">Newcomer</div>
</div>
<div class="id-field">
<label>Peers</label>
<div id="idPeers">0 connected</div>
</div>
<div class="id-actions">
<input type="text" id="registerInput" placeholder="Register a name...">
<button class="go-btn" id="btnRegister">Register</button>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1549
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
app/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "tiwd",
"version": "0.1.0",
"description": "TIWD — The Internet We Deserve",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

4981
app/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
app/src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "tiwd-app"
version = "0.1.0"
edition = "2021"
description = "TIWD Desktop App — The Internet We Deserve"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

3
app/src-tauri/build.rs Normal file
View File

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

24
app/src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,24 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.setup(|_app| {
// TODO: Start the TIWD node in the background
// This will start:
// - P2P networking (port 9000)
// - Web server for chat UI (port 8080)
// - .tiwd proxy (port 9080)
//
// For now, the user runs the node separately via run-node.bat
// and the Tauri app connects to it via WebSocket.
//
// Future: embed the node directly into the Tauri binary
// so everything runs from one executable.
println!("TIWD Desktop App starting...");
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running TIWD");
}

View File

@ -0,0 +1,36 @@
{
"$schema": "https://raw.githubusercontent.com/nicsgithub/nicsgithub.github.io/refs/heads/main/tauri-v2.schema.json",
"productName": "TIWD",
"version": "0.1.0",
"identifier": "org.tiwd.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "TIWD — The Internet We Deserve",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"decorations": true,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.png",
"icons/icon.ico"
]
}
}

265
app/src/main.js Normal file
View File

@ -0,0 +1,265 @@
// ── TIWD Browser App ──
let ws = null;
let myPeerId = '';
let myName = '';
let peers = [];
let chatOpen = false;
let identityOpen = false;
// ── WebSocket ──
function connect() {
ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = () => {
document.getElementById('statusDot').style.background = '#00d4aa';
setStatus('Connected to TIWD network');
};
ws.onclose = () => {
document.getElementById('statusDot').style.background = '#f38ba8';
setStatus('Disconnected — reconnecting...');
setTimeout(connect, 3000);
};
ws.onmessage = (e) => {
try {
handleMessage(JSON.parse(e.data));
} catch (err) {}
};
}
function handleMessage(msg) {
switch (msg.type) {
case 'identity':
myPeerId = msg.peer_id;
myName = msg.name || '';
document.getElementById('idPeer').textContent = myPeerId;
document.getElementById('idName').textContent = myName ? myName + '.tiwd' : 'Not registered';
document.getElementById('nodeName').textContent = myName ? myName + '.tiwd' : myPeerId.substring(0, 12);
break;
case 'peer_discovered':
if (!peers.includes(msg.peer_id)) peers.push(msg.peer_id);
document.getElementById('peerCount').textContent = peers.length;
document.getElementById('idPeers').textContent = peers.length + ' connected';
addChatMsg('system', null, (msg.display || msg.peer_id.substring(0, 12)) + ' joined');
break;
case 'peer_disconnected':
peers = peers.filter(p => p !== msg.peer_id);
document.getElementById('peerCount').textContent = peers.length;
document.getElementById('idPeers').textContent = peers.length + ' connected';
break;
case 'dm_received':
addChatMsg('other', msg.from_name || msg.from.substring(0, 12), msg.text);
// Open chat if closed
if (!chatOpen) toggleChat();
break;
case 'dm_sent':
addChatMsg('self', null, msg.text);
break;
case 'name_registered':
myName = msg.name;
document.getElementById('idName').textContent = msg.name + '.tiwd';
document.getElementById('nodeName').textContent = msg.name + '.tiwd';
addChatMsg('system', null, 'Registered: ' + msg.name + '.tiwd');
break;
case 'name_resolved':
setStatus('Resolved: ' + msg.name + '.tiwd');
break;
}
}
// ── Navigation ──
function navigate(url) {
if (!url) return;
// Clean up the URL
url = url.trim();
if (!url.includes('://') && !url.startsWith('/')) {
if (url.includes('.tiwd')) {
url = 'http://localhost:8080/browse/' + url;
} else if (url.includes('.')) {
url = 'https://' + url;
} else {
url = 'http://localhost:8080/browse/' + url + '.tiwd';
}
}
document.getElementById('addressInput').value = url.replace('http://localhost:8080/browse/', '');
document.getElementById('contentFrame').src = url;
setStatus('Loading ' + url);
// Update tab title
const activeTab = document.querySelector('.tab.active .tab-title');
if (activeTab) {
const displayUrl = url.replace('http://localhost:8080/browse/', '').replace('https://', '').replace('http://', '');
activeTab.textContent = displayUrl.split('/')[0] || 'New Tab';
}
}
function navigateHome() {
const homeHtml = `
<html>
<head><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #e6edf3;
display: flex; justify-content: center; align-items: center; height: 100vh; }
.home { text-align: center; max-width: 500px; }
h1 { font-size: 48px; margin-bottom: 8px; }
h1 span { color: #00d4aa; }
p { color: #7d8590; font-size: 16px; margin-bottom: 32px; line-height: 1.5; }
.suggestions { display: flex; flex-direction: column; gap: 8px; }
.suggestion {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 12px 16px; cursor: pointer; text-align: left; transition: all 0.15s;
text-decoration: none; color: #e6edf3; display: block;
}
.suggestion:hover { border-color: #00d4aa; background: #1c2333; }
.suggestion .name { color: #00d4aa; font-weight: 600; }
.suggestion .desc { color: #7d8590; font-size: 12px; margin-top: 2px; }
</style></head>
<body>
<div class="home">
<h1>T<span>IWD</span></h1>
<p>The Internet We Deserve<br>Type a .tiwd address above to browse the decentralized web.</p>
<div class="suggestions">
<a class="suggestion" href="http://localhost:8080/browse/server.tiwd">
<div class="name">server.tiwd</div>
<div class="desc">The TIWD network's first server node</div>
</a>
<a class="suggestion" href="http://localhost:8080/chat">
<div class="name">Chat</div>
<div class="desc">Open the encrypted chat interface</div>
</a>
<a class="suggestion" href="http://localhost:8080/site/">
<div class="name">Your Site</div>
<div class="desc">View your hosted .tiwd website</div>
</a>
</div>
</div>
</body>
</html>`;
const frame = document.getElementById('contentFrame');
frame.srcdoc = homeHtml;
document.getElementById('addressInput').value = '';
setStatus('Ready');
}
// ── Chat ──
function toggleChat() {
chatOpen = !chatOpen;
document.getElementById('chatSidebar').classList.toggle('open', chatOpen);
}
function addChatMsg(type, sender, text) {
const div = document.createElement('div');
div.className = 'chat-msg ' + type;
if (sender && type !== 'system') {
div.innerHTML = '<div class="sender">' + escapeHtml(sender) + '</div>' + escapeHtml(text);
} else {
div.textContent = text;
}
document.getElementById('chatMessages').appendChild(div);
div.scrollIntoView({ behavior: 'smooth' });
}
function sendChat() {
const input = document.getElementById('chatInput');
const text = input.value.trim();
if (!text || !ws) return;
if (text.startsWith('/msg ')) {
const parts = text.substring(5).split(' ');
const name = parts.shift();
ws.send(JSON.stringify({ action: 'msg', name, text: parts.join(' ') }));
} else if (text.startsWith('/dm ')) {
const parts = text.substring(4).split(' ');
const peer = parts.shift();
ws.send(JSON.stringify({ action: 'dm', peer_id: peer, text: parts.join(' ') }));
} else {
ws.send(JSON.stringify({ action: 'chat', text }));
addChatMsg('self', null, text);
}
input.value = '';
}
// ── Identity ──
function toggleIdentity() {
identityOpen = !identityOpen;
document.getElementById('identityPanel').classList.toggle('open', identityOpen);
}
function registerName() {
const input = document.getElementById('registerInput');
const name = input.value.trim().toLowerCase().replace('.tiwd', '');
if (!name || !ws) return;
ws.send(JSON.stringify({ action: 'register', name }));
input.value = '';
}
// ── Helpers ──
function setStatus(text) {
document.getElementById('statusText').textContent = text;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ── Event Listeners ──
document.addEventListener('DOMContentLoaded', () => {
// Address bar
document.getElementById('addressInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') navigate(e.target.value);
});
document.getElementById('btnGo').addEventListener('click', () => {
navigate(document.getElementById('addressInput').value);
});
// Navigation
document.getElementById('btnBack').addEventListener('click', () => {
document.getElementById('contentFrame').contentWindow?.history.back();
});
document.getElementById('btnForward').addEventListener('click', () => {
document.getElementById('contentFrame').contentWindow?.history.forward();
});
document.getElementById('btnRefresh').addEventListener('click', () => {
const frame = document.getElementById('contentFrame');
if (frame.src) frame.src = frame.src;
});
// Chat
document.getElementById('btnChat').addEventListener('click', toggleChat);
document.getElementById('btnCloseChat').addEventListener('click', toggleChat);
document.getElementById('chatInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendChat();
});
document.getElementById('btnSendChat').addEventListener('click', sendChat);
// Identity
document.getElementById('btnIdentity').addEventListener('click', toggleIdentity);
document.getElementById('btnRegister').addEventListener('click', registerName);
// Focus address bar on Ctrl+L
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
e.preventDefault();
document.getElementById('addressInput').focus();
document.getElementById('addressInput').select();
}
});
// Show home page
navigateHome();
// Connect to TIWD node
connect();
});

387
app/src/style.css Normal file
View File

@ -0,0 +1,387 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1e1e2e;
--bg-darker: #181825;
--bg-lighter: #313244;
--surface: #45475a;
--text: #cdd6f4;
--text-dim: #6c7086;
--accent: #00d4aa;
--accent-hover: #00b894;
--border: #313244;
--red: #f38ba8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
/* ── Top Bar ── */
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-darker);
border-bottom: 1px solid var(--border);
-webkit-app-region: drag;
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.nav-btn {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 16px;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.nav-btn:hover {
background: var(--bg-lighter);
color: var(--text);
}
.address-bar {
flex: 1;
display: flex;
align-items: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0 8px;
gap: 8px;
-webkit-app-region: no-drag;
}
.address-icon {
font-size: 14px;
flex-shrink: 0;
}
.address-bar input {
flex: 1;
background: transparent;
border: none;
color: var(--text);
font-size: 14px;
padding: 8px 0;
outline: none;
font-family: inherit;
}
.address-bar input::placeholder {
color: var(--text-dim);
}
.address-bar:focus-within {
border-color: var(--accent);
}
.go-btn {
background: var(--accent);
border: none;
color: #000;
font-weight: 600;
font-size: 12px;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.go-btn:hover {
background: var(--accent-hover);
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--bg);
border-radius: 12px;
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-dim);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Tab Bar ── */
.tabbar {
display: flex;
align-items: center;
background: var(--bg-darker);
border-bottom: 1px solid var(--border);
padding: 0 8px;
gap: 2px;
min-height: 36px;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border-radius: 6px 6px 0 0;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
max-width: 200px;
transition: all 0.15s;
}
.tab:hover { background: var(--bg-lighter); color: var(--text); }
.tab.active { background: var(--bg); color: var(--text); }
.tab-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 14px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.tab-close:hover { color: var(--red); }
.tab-new {
background: none;
border: none;
color: var(--text-dim);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.tab-new:hover { background: var(--bg-lighter); color: var(--text); }
/* ── Content Area ── */
.content-area {
flex: 1;
display: flex;
overflow: hidden;
}
.browser-view {
flex: 1;
position: relative;
}
.browser-view iframe {
width: 100%;
height: 100%;
border: none;
background: #0d1117;
}
/* ── Chat Sidebar ── */
.chat-sidebar {
width: 0;
overflow: hidden;
background: var(--bg-darker);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: width 0.2s;
}
.chat-sidebar.open {
width: 320px;
}
.chat-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 14px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-msg {
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
max-width: 90%;
animation: fadeIn 0.15s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-msg.self {
align-self: flex-end;
background: #1a3a2a;
border-bottom-right-radius: 2px;
}
.chat-msg.other {
align-self: flex-start;
background: var(--bg-lighter);
border-bottom-left-radius: 2px;
}
.chat-msg .sender {
font-size: 11px;
color: var(--accent);
font-weight: 600;
margin-bottom: 2px;
}
.chat-msg.system {
align-self: center;
color: var(--text-dim);
font-size: 11px;
background: none;
}
.chat-input-area {
display: flex;
gap: 6px;
padding: 12px;
border-top: 1px solid var(--border);
}
.chat-input-area input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
}
.chat-input-area input:focus { border-color: var(--accent); }
/* ── Status Bar ── */
.statusbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px;
background: var(--bg-darker);
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
}
.statusbar-right {
display: flex;
gap: 12px;
}
/* ── Identity Panel ── */
.identity-panel {
display: none;
position: absolute;
top: 45px;
right: 12px;
background: var(--bg-darker);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
width: 320px;
z-index: 100;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.identity-panel.open { display: block; }
.identity-panel h3 {
font-size: 16px;
margin-bottom: 16px;
color: var(--accent);
}
.id-field {
margin-bottom: 12px;
}
.id-field label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
}
.id-field div {
font-size: 14px;
margin-top: 2px;
word-break: break-all;
}
.mono { font-family: monospace; font-size: 11px !important; }
.id-actions {
display: flex;
gap: 6px;
margin-top: 16px;
}
.id-actions input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
}

13
app/vite.config.js Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
export default defineConfig({
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

View File

@ -0,0 +1,18 @@
[package]
name = "tiwd-chat"
version = "0.1.0"
edition = "2021"
description = "Encrypted chat protocol for TIWD"
[dependencies]
tiwd-core = { path = "../tiwd-core" }
tiwd-trust = { path = "../tiwd-trust" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
libp2p = { workspace = true }
tokio = { workspace = true }
sha2 = { workspace = true }

View File

@ -0,0 +1,3 @@
pub mod protocol;
pub use protocol::{ChatMessage, ChatRoom, ChatService};

View File

@ -0,0 +1,242 @@
use chrono::{DateTime, Utc};
use libp2p::PeerId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tiwd_core::network::{DirectMessage, NetworkCommand};
use tokio::sync::mpsc;
use tracing::info;
// ── Chat Messages ──────────────────────────────────────────────────
/// A chat message on the TIWD network.
/// All messages are end-to-end encrypted by default.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// Unique message ID (hash of content + timestamp + sender).
pub id: String,
/// Sender's peer ID.
pub sender: String,
/// Display name of sender (optional, untrusted).
pub sender_name: Option<String>,
/// The message content (plaintext after decryption).
pub content: ChatContent,
/// When the message was created.
pub timestamp: DateTime<Utc>,
/// Signature by the sender's key.
pub signature: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChatContent {
Text(String),
File {
name: String,
size: u64,
/// Content hash for retrieval from DHT/peers.
hash: String,
},
}
// ── Chat Room ──────────────────────────────────────────────────────
/// A chat room — either a direct conversation or a group.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRoom {
/// Room identifier (hash of participants for DM, chosen name for group).
pub id: String,
/// Room display name.
pub name: String,
/// Type of room.
pub room_type: RoomType,
/// Members of this room.
pub members: Vec<String>,
/// GossipSub topic for group rooms.
pub topic: Option<String>,
/// When this room was created.
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RoomType {
/// Direct message between two peers. Uses request-response.
Direct,
/// Group chat. Uses GossipSub pub/sub.
Group,
}
// ── Chat Service ───────────────────────────────────────────────────
/// The chat service manages conversations and message routing.
/// It sits on top of the network layer and uses it to send/receive.
pub struct ChatService {
/// Channel to send commands to the network layer.
command_tx: mpsc::Sender<NetworkCommand>,
/// Active chat rooms.
rooms: HashMap<String, ChatRoom>,
/// Our peer ID.
local_peer_id: String,
/// Message history (in-memory for now, will be persisted later).
messages: HashMap<String, Vec<ChatMessage>>,
}
impl ChatService {
pub fn new(command_tx: mpsc::Sender<NetworkCommand>, local_peer_id: PeerId) -> Self {
Self {
command_tx,
rooms: HashMap::new(),
local_peer_id: local_peer_id.to_string(),
messages: HashMap::new(),
}
}
/// Send a direct message to another peer.
pub async fn send_direct_message(
&mut self,
peer: PeerId,
text: String,
) -> anyhow::Result<ChatMessage> {
let msg = ChatMessage {
id: Self::generate_message_id(&self.local_peer_id, &text),
sender: self.local_peer_id.clone(),
sender_name: None,
content: ChatContent::Text(text),
timestamp: Utc::now(),
signature: Vec::new(), // TODO: sign with node identity
};
let payload = serde_json::to_vec(&msg)?;
let dm = DirectMessage {
from: self.local_peer_id.clone(),
payload,
timestamp: msg.timestamp.timestamp(),
signature: msg.signature.clone(),
msg_type: "dm".to_string(),
};
self.command_tx
.send(NetworkCommand::SendDirectMessage {
peer,
message: dm,
})
.await?;
// Store in local history
let room_id = Self::direct_room_id(&self.local_peer_id, &peer.to_string());
self.messages
.entry(room_id)
.or_default()
.push(msg.clone());
info!("Sent DM to {}", peer);
Ok(msg)
}
/// Send a message to a group chat room via GossipSub.
pub async fn send_group_message(
&mut self,
room_id: &str,
text: String,
) -> anyhow::Result<ChatMessage> {
let room = self
.rooms
.get(room_id)
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
let topic = room
.topic
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Room has no gossip topic"))?
.clone();
let msg = ChatMessage {
id: Self::generate_message_id(&self.local_peer_id, &text),
sender: self.local_peer_id.clone(),
sender_name: None,
content: ChatContent::Text(text),
timestamp: Utc::now(),
signature: Vec::new(), // TODO: sign with node identity
};
let payload = serde_json::to_vec(&msg)?;
self.command_tx
.send(NetworkCommand::PublishGossip {
topic,
data: payload,
})
.await?;
self.messages
.entry(room_id.to_string())
.or_default()
.push(msg.clone());
Ok(msg)
}
/// Create or join a group chat room.
pub async fn join_group(&mut self, name: String) -> anyhow::Result<ChatRoom> {
let topic = format!("tiwd/chat/group/{}", name);
let room_id = format!("group:{}", name);
let room = ChatRoom {
id: room_id.clone(),
name: name.clone(),
room_type: RoomType::Group,
members: vec![self.local_peer_id.clone()],
topic: Some(topic.clone()),
created_at: Utc::now(),
};
// Subscribe to the gossipsub topic
self.command_tx
.send(NetworkCommand::Subscribe { topic })
.await?;
self.rooms.insert(room_id, room.clone());
info!("Joined group: {}", name);
Ok(room)
}
/// Handle an incoming chat message (from network events).
pub fn handle_incoming_message(
&mut self,
room_id: &str,
message: ChatMessage,
) {
self.messages
.entry(room_id.to_string())
.or_default()
.push(message);
}
/// Get message history for a room.
pub fn get_messages(&self, room_id: &str) -> &[ChatMessage] {
self.messages
.get(room_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// List all active rooms.
pub fn list_rooms(&self) -> Vec<&ChatRoom> {
self.rooms.values().collect()
}
fn generate_message_id(sender: &str, content: &str) -> String {
use sha2::{Digest, Sha256};
let now = Utc::now().timestamp_nanos_opt().unwrap_or(0);
let mut hasher = Sha256::new();
hasher.update(sender.as_bytes());
hasher.update(content.as_bytes());
hasher.update(now.to_le_bytes());
format!("{:x}", hasher.finalize())
}
fn direct_room_id(a: &str, b: &str) -> String {
let mut peers = [a, b];
peers.sort();
format!("dm:{}:{}", peers[0], peers[1])
}
}

View File

@ -0,0 +1,20 @@
[package]
name = "tiwd-core"
version = "0.1.0"
edition = "2021"
description = "Core networking layer for TIWD - The Internet We Deserve"
[dependencies]
libp2p = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
directories = { workspace = true }
futures = { workspace = true }

View File

@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Bootstrap peers — hardcoded entry points into the TIWD network.
/// These are not servers. They are just regular nodes that volunteer to be
/// the first point of contact. Anyone can run a bootstrap node.
pub const DEFAULT_BOOTSTRAP_PEERS: &[&str] = &[
// These will be populated as the network grows.
// For now, nodes discover each other via mDNS on local networks.
];
/// Configuration for a TIWD node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
/// TCP port to listen on.
pub listen_port: u16,
/// Directory for storing identity keys and local data.
pub data_dir: PathBuf,
/// Additional bootstrap peers to connect to on startup.
pub bootstrap_peers: Vec<String>,
/// Whether to enable mDNS local discovery.
pub enable_mdns: bool,
/// Display name for this node (optional, not an identity).
pub display_name: Option<String>,
}
impl Default for NodeConfig {
fn default() -> Self {
let data_dir = directories::ProjectDirs::from("net", "tiwd", "tiwd-node")
.map(|dirs| dirs.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from(".tiwd"));
Self {
listen_port: 0, // OS picks a free port
data_dir,
bootstrap_peers: Vec::new(),
enable_mdns: true,
display_name: None,
}
}
}
impl NodeConfig {
/// Load config from file, or return defaults.
pub fn load(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
/// Save config to file.
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let content = serde_json::to_string_pretty(self)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, content)?;
Ok(())
}
}

View File

@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
/// A request for content from a TIWD node.
/// This is the decentralized web protocol — one node asks another
/// for a file, and the response comes back peer-to-peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentRequest {
/// The path being requested (e.g., "/index.html", "/about.html").
pub path: String,
/// The TIWD name of the site (e.g., "thefounder").
pub site_name: String,
}
/// A response containing the requested content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentResponse {
/// HTTP-like status code (200 = OK, 404 = not found).
pub status: u16,
/// MIME type of the content.
pub content_type: String,
/// The raw content bytes.
pub body: Vec<u8>,
}
impl ContentResponse {
pub fn ok(body: Vec<u8>, content_type: &str) -> Self {
Self {
status: 200,
content_type: content_type.to_string(),
body,
}
}
pub fn not_found() -> Self {
Self {
status: 404,
content_type: "text/html".to_string(),
body: b"<h1>404 - Not Found</h1><p>This page does not exist on this node.</p>".to_vec(),
}
}
/// Guess MIME type from file extension.
pub fn mime_for_path(path: &str) -> &'static str {
if path.ends_with(".html") || path.ends_with(".htm") {
"text/html"
} else if path.ends_with(".css") {
"text/css"
} else if path.ends_with(".js") {
"application/javascript"
} else if path.ends_with(".json") {
"application/json"
} else if path.ends_with(".png") {
"image/png"
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
"image/jpeg"
} else if path.ends_with(".gif") {
"image/gif"
} else if path.ends_with(".svg") {
"image/svg+xml"
} else if path.ends_with(".ico") {
"image/x-icon"
} else if path.ends_with(".txt") {
"text/plain"
} else if path.ends_with(".xml") {
"application/xml"
} else if path.ends_with(".wasm") {
"application/wasm"
} else {
"application/octet-stream"
}
}
}

View File

@ -0,0 +1,86 @@
use anyhow::{Context, Result};
use libp2p::identity::{self, ed25519, Keypair};
use libp2p::PeerId;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::info;
/// A node's cryptographic identity on the TIWD network.
/// Generated once, stored locally, never shared. The private key never leaves the device.
/// Trust is bound to this identity and cannot be transferred.
pub struct NodeIdentity {
keypair: Keypair,
peer_id: PeerId,
identity_path: PathBuf,
}
impl NodeIdentity {
/// Load existing identity from disk, or generate a new one.
pub fn load_or_generate(data_dir: &Path) -> Result<Self> {
let identity_path = data_dir.join("identity.key");
let keypair = if identity_path.exists() {
info!("Loading existing identity from {:?}", identity_path);
let bytes = fs::read(&identity_path)
.context("Failed to read identity key")?;
let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut bytes.clone())
.context("Failed to deserialize identity key")?;
Keypair::from(ed25519_kp)
} else {
info!("Generating new node identity");
fs::create_dir_all(data_dir)
.context("Failed to create data directory")?;
let ed25519_kp = ed25519::Keypair::generate();
let bytes = ed25519_kp.to_bytes();
fs::write(&identity_path, bytes)
.context("Failed to write identity key")?;
// Restrict permissions on Unix systems
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&identity_path, fs::Permissions::from_mode(0o600))?;
}
Keypair::from(ed25519_kp)
};
let peer_id = PeerId::from_public_key(&keypair.public());
info!("Node identity: {}", peer_id);
Ok(Self {
keypair,
peer_id,
identity_path,
})
}
pub fn keypair(&self) -> &Keypair {
&self.keypair
}
pub fn peer_id(&self) -> PeerId {
self.peer_id
}
pub fn identity_path(&self) -> &Path {
&self.identity_path
}
/// Sign arbitrary data with this node's private key.
/// Used for trust vouches, moderation votes, and message authentication.
pub fn sign(&self, data: &[u8]) -> Result<Vec<u8>> {
match self.keypair.sign(data) {
Ok(sig) => Ok(sig),
Err(e) => anyhow::bail!("Signing failed: {}", e),
}
}
/// Verify a signature from another peer.
pub fn verify(peer_id: &PeerId, data: &[u8], signature: &[u8]) -> bool {
// Verification requires the public key, which we'd retrieve from the DHT
// For now, this is a placeholder that will be filled in when we implement
// the full trust verification pipeline
let _ = (peer_id, data, signature);
false
}
}

View File

@ -0,0 +1,12 @@
pub mod config;
pub mod content;
pub mod identity;
pub mod names;
pub mod network;
pub use config::NodeConfig;
pub use identity::NodeIdentity;
pub use names::{NameCache, NameRecord};
pub use network::{
DirectMessage, NetworkCommand, NetworkEvent, NodeNetwork, TiwdBehaviour,
};

View File

@ -0,0 +1,114 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use libp2p::PeerId;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use tracing::info;
/// A TIWD Name record — maps a human-readable name to a PeerId.
/// Stored in the Kademlia DHT, signed by the registrant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NameRecord {
/// The human-readable name (e.g., "alice").
pub name: String,
/// The PeerId this name resolves to.
pub peer_id: String,
/// When this name was registered.
pub registered: DateTime<Utc>,
/// When this record was last refreshed.
pub last_refreshed: DateTime<Utc>,
/// Signature by the registrant's Ed25519 key.
pub signature: Vec<u8>,
}
impl NameRecord {
/// Create a new name record.
pub fn new(name: &str, peer_id: PeerId) -> Self {
let now = Utc::now();
Self {
name: name.to_lowercase(),
peer_id: peer_id.to_string(),
registered: now,
last_refreshed: now,
signature: Vec::new(),
}
}
/// Compute the DHT key for a TIWD name.
/// All names are stored under: sha256("tiwd/name/{name}")
pub fn dht_key(name: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(format!("tiwd/name/{}", name.to_lowercase()).as_bytes());
hasher.finalize().to_vec()
}
/// Serialize this record for DHT storage.
pub fn to_bytes(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(self)?)
}
/// Deserialize a record from DHT storage.
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
/// Validate the name format.
pub fn is_valid_name(name: &str) -> bool {
let name = name.to_lowercase();
// 3-32 characters, alphanumeric and hyphens, can't start/end with hyphen
if name.len() < 3 || name.len() > 32 {
return false;
}
if name.starts_with('-') || name.ends_with('-') {
return false;
}
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
}
}
/// Local name cache — stores resolved names to avoid repeated DHT lookups.
pub struct NameCache {
cache: HashMap<String, NameRecord>,
/// Names registered by this node.
my_names: Vec<String>,
}
impl NameCache {
pub fn new() -> Self {
Self {
cache: HashMap::new(),
my_names: Vec::new(),
}
}
/// Cache a resolved name record.
pub fn insert(&mut self, record: NameRecord) {
info!("Cached name: {} → {}", record.name, record.peer_id);
self.cache.insert(record.name.clone(), record);
}
/// Look up a cached name.
pub fn get(&self, name: &str) -> Option<&NameRecord> {
self.cache.get(&name.to_lowercase())
}
/// Record that this node registered a name.
pub fn add_my_name(&mut self, name: String) {
if !self.my_names.contains(&name) {
self.my_names.push(name);
}
}
/// Get names registered by this node.
pub fn my_names(&self) -> &[String] {
&self.my_names
}
}
impl Default for NameCache {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,497 @@
use anyhow::{Context, Result};
use futures::StreamExt;
use libp2p::gossipsub::{self, IdentTopic, MessageAuthenticity};
use libp2p::kad::store::MemoryStore;
use libp2p::kad::{self, Mode};
use libp2p::mdns;
use libp2p::request_response::{self, cbor, ProtocolSupport};
use libp2p::swarm::{NetworkBehaviour, SwarmEvent};
use libp2p::{identify, Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::identity::NodeIdentity;
// ── Protocol Messages ──────────────────────────────────────────────
/// Direct message between two peers (request-response pattern).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectMessage {
pub from: String,
pub payload: Vec<u8>,
pub timestamp: i64,
pub signature: Vec<u8>,
/// Message type: "dm" for chat, "content_request" for web content
#[serde(default = "default_msg_type")]
pub msg_type: String,
}
fn default_msg_type() -> String {
"dm".to_string()
}
/// Response to a direct message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectMessageResponse {
pub accepted: bool,
/// Response payload — used for content responses
#[serde(default)]
pub payload: Vec<u8>,
/// Content type for web responses
#[serde(default)]
pub content_type: String,
/// HTTP-like status code
#[serde(default)]
pub status: u16,
}
// ── Network Behaviour ──────────────────────────────────────────────
/// Combined network behaviour for a TIWD node.
/// Every node runs ALL of these protocols simultaneously.
#[derive(NetworkBehaviour)]
pub struct TiwdBehaviour {
/// Kademlia DHT — decentralized peer discovery and data storage.
/// Every node is part of the DHT. No central directory.
pub kademlia: kad::Behaviour<MemoryStore>,
/// mDNS — automatic discovery of peers on the local network.
/// Zero configuration, works offline.
pub mdns: mdns::tokio::Behaviour,
/// GossipSub — pub/sub messaging for group chats and network announcements.
/// Messages propagate through the mesh without a central broker.
pub gossipsub: gossipsub::Behaviour,
/// Identify — exchange node information when connecting to a new peer.
pub identify: identify::Behaviour,
/// Request-Response — direct encrypted messages between two peers.
pub direct_msg: cbor::Behaviour<DirectMessage, DirectMessageResponse>,
}
// ── Network Events ─────────────────────────────────────────────────
/// Events emitted by the network layer to the application.
#[derive(Debug)]
pub enum NetworkEvent {
/// A new peer was discovered on the network.
PeerDiscovered(PeerId),
/// A peer disconnected.
PeerDisconnected(PeerId),
/// Received a direct message from a peer.
DirectMessage {
from: PeerId,
message: DirectMessage,
},
/// Received a response to a direct message we sent.
DirectMessageResponse {
from: PeerId,
response: DirectMessageResponse,
},
/// Received a gossipsub message (group chat, announcements, etc).
GossipMessage {
topic: String,
from: Option<PeerId>,
data: Vec<u8>,
},
/// A value was found in the DHT.
DhtValueFound {
key: Vec<u8>,
value: Vec<u8>,
},
}
// ── Network Commands ───────────────────────────────────────────────
/// Commands sent from the application to the network layer.
#[derive(Debug)]
pub enum NetworkCommand {
/// Send a direct message to a specific peer.
SendDirectMessage {
peer: PeerId,
message: DirectMessage,
},
/// Publish a message to a gossipsub topic.
PublishGossip {
topic: String,
data: Vec<u8>,
},
/// Subscribe to a gossipsub topic.
Subscribe {
topic: String,
},
/// Store a key-value pair in the DHT.
DhtPut {
key: Vec<u8>,
value: Vec<u8>,
},
/// Look up a value in the DHT.
DhtGet {
key: Vec<u8>,
},
/// Connect to a specific peer address (bootstrap).
Dial {
addr: Multiaddr,
},
}
// ── Node Network ───────────────────────────────────────────────────
pub struct NodeNetwork {
swarm: Swarm<TiwdBehaviour>,
event_tx: mpsc::Sender<NetworkEvent>,
command_rx: mpsc::Receiver<NetworkCommand>,
known_peers: HashSet<PeerId>,
}
impl NodeNetwork {
/// Create and start a new TIWD network node.
pub fn new(
identity: &NodeIdentity,
listen_port: u16,
event_tx: mpsc::Sender<NetworkEvent>,
command_rx: mpsc::Receiver<NetworkCommand>,
) -> Result<Self> {
let peer_id = identity.peer_id();
// Build the libp2p swarm with all protocols
let swarm = SwarmBuilder::with_existing_identity(identity.keypair().clone())
.with_tokio()
.with_tcp(
libp2p::tcp::Config::default(),
libp2p::noise::Config::new,
libp2p::yamux::Config::default,
)
.context("Failed to configure TCP transport")?
.with_dns()
.context("Failed to configure DNS")?
.with_behaviour(|key| {
// Kademlia DHT
let store = MemoryStore::new(key.public().to_peer_id());
let mut kad_config = kad::Config::new(
StreamProtocol::new("/tiwd/kad/1.0.0"),
);
kad_config.set_query_timeout(Duration::from_secs(60));
let mut kademlia = kad::Behaviour::with_config(
key.public().to_peer_id(),
store,
kad_config,
);
// Every node is a server in the DHT
kademlia.set_mode(Some(Mode::Server));
// mDNS for local discovery
let mdns = mdns::tokio::Behaviour::new(
mdns::Config::default(),
key.public().to_peer_id(),
)
.expect("Failed to create mDNS behaviour");
// GossipSub for pub/sub messaging
let gossipsub_config = gossipsub::ConfigBuilder::default()
.heartbeat_interval(Duration::from_secs(10))
.validation_mode(gossipsub::ValidationMode::Strict)
.build()
.expect("Valid gossipsub config");
let gossipsub = gossipsub::Behaviour::new(
MessageAuthenticity::Signed(key.clone()),
gossipsub_config,
)
.expect("Failed to create gossipsub behaviour");
// Identify protocol
let identify = identify::Behaviour::new(identify::Config::new(
"/tiwd/id/1.0.0".to_string(),
key.public(),
));
// Direct messaging (request-response with CBOR encoding)
let direct_msg = cbor::Behaviour::new(
[(
StreamProtocol::new("/tiwd/dm/1.0.0"),
ProtocolSupport::Full,
)],
request_response::Config::default(),
);
Ok(TiwdBehaviour {
kademlia,
mdns,
gossipsub,
identify,
direct_msg,
})
})
.context("Failed to create network behaviour")?
.with_swarm_config(|cfg| {
cfg.with_idle_connection_timeout(Duration::from_secs(300))
})
.build();
info!("Network node created: {} on port {}", peer_id, listen_port);
Ok(Self {
swarm,
event_tx,
command_rx,
known_peers: HashSet::new(),
})
}
/// Start listening and run the network event loop.
pub async fn run(&mut self, listen_port: u16) -> Result<()> {
// Listen on all interfaces
let listen_addr: Multiaddr = format!("/ip4/0.0.0.0/tcp/{}", listen_port)
.parse()
.context("Invalid listen address")?;
self.swarm.listen_on(listen_addr)?;
info!("TIWD node listening on port {}", listen_port);
loop {
tokio::select! {
// Handle swarm events
event = self.swarm.select_next_some() => {
self.handle_swarm_event(event).await;
}
// Handle application commands
Some(cmd) = self.command_rx.recv() => {
self.handle_command(cmd).await;
}
}
}
}
async fn handle_swarm_event(&mut self, event: SwarmEvent<TiwdBehaviourEvent>) {
use libp2p::swarm::SwarmEvent::*;
match event {
NewListenAddr { address, .. } => {
info!("Listening on {}", address);
}
Behaviour(TiwdBehaviourEvent::Mdns(mdns::Event::Discovered(peers))) => {
for (peer_id, addr) in peers {
info!("mDNS discovered peer: {} at {}", peer_id, addr);
self.swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
if self.known_peers.insert(peer_id) {
let _ = self.event_tx.send(NetworkEvent::PeerDiscovered(peer_id)).await;
}
}
}
Behaviour(TiwdBehaviourEvent::Mdns(mdns::Event::Expired(peers))) => {
for (peer_id, _) in peers {
debug!("mDNS peer expired: {}", peer_id);
}
}
Behaviour(TiwdBehaviourEvent::Kademlia(kad::Event::RoutingUpdated {
peer, ..
})) => {
debug!("Kademlia routing updated: {}", peer);
if self.known_peers.insert(peer) {
let _ = self.event_tx.send(NetworkEvent::PeerDiscovered(peer)).await;
}
}
Behaviour(TiwdBehaviourEvent::Kademlia(
kad::Event::OutboundQueryProgressed { result, .. },
)) => {
if let kad::QueryResult::GetRecord(Ok(kad::GetRecordOk::FoundRecord(
kad::PeerRecord { record, .. },
))) = result
{
let _ = self
.event_tx
.send(NetworkEvent::DhtValueFound {
key: record.key.to_vec(),
value: record.value,
})
.await;
}
}
Behaviour(TiwdBehaviourEvent::Gossipsub(gossipsub::Event::Message {
propagation_source,
message,
..
})) => {
let topic = message.topic.to_string();
debug!("Gossip message on topic '{}' from {:?}", topic, propagation_source);
let _ = self
.event_tx
.send(NetworkEvent::GossipMessage {
topic,
from: message.source,
data: message.data,
})
.await;
}
Behaviour(TiwdBehaviourEvent::DirectMsg(
request_response::Event::Message { peer, message },
)) => {
match message {
request_response::Message::Request {
request, channel, ..
} => {
if request.msg_type == "content_request" {
// Content request — serve from local site directory
info!("Content request from {} for {:?}", peer, String::from_utf8_lossy(&request.payload));
let path = String::from_utf8_lossy(&request.payload).to_string();
let site_dir = std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
});
let mut file_path = path.trim_start_matches('/').to_string();
if file_path.is_empty() { file_path = "index.html".to_string(); }
// Security: block traversal
if file_path.contains("..") {
let _ = self.swarm.behaviour_mut().direct_msg
.send_response(channel, DirectMessageResponse {
accepted: false, payload: b"403 Forbidden".to_vec(),
content_type: "text/plain".to_string(), status: 403,
});
} else {
let full_path = std::path::PathBuf::from(&site_dir).join(&file_path);
match std::fs::read(&full_path) {
Ok(body) => {
let mime = crate::content::ContentResponse::mime_for_path(&file_path);
info!("Serving {} ({}, {} bytes)", file_path, mime, body.len());
let _ = self.swarm.behaviour_mut().direct_msg
.send_response(channel, DirectMessageResponse {
accepted: true, payload: body,
content_type: mime.to_string(), status: 200,
});
}
Err(_) => {
let _ = self.swarm.behaviour_mut().direct_msg
.send_response(channel, DirectMessageResponse {
accepted: false,
payload: b"<h1>404 - Not Found</h1>".to_vec(),
content_type: "text/html".to_string(), status: 404,
});
}
}
}
} else {
// Regular DM
info!("Direct message from {}", peer);
let _ = self
.event_tx
.send(NetworkEvent::DirectMessage {
from: peer,
message: request,
})
.await;
let _ = self.swarm.behaviour_mut().direct_msg
.send_response(channel, DirectMessageResponse {
accepted: true, payload: Vec::new(),
content_type: String::new(), status: 200,
});
}
}
request_response::Message::Response { response, .. } => {
debug!("DM response from {}", peer);
let _ = self
.event_tx
.send(NetworkEvent::DirectMessageResponse {
from: peer,
response,
})
.await;
}
}
}
Behaviour(TiwdBehaviourEvent::Identify(identify::Event::Received {
peer_id,
info: identify_info,
..
})) => {
debug!("Identified peer {}: {:?}", peer_id, identify_info.protocol_version);
for addr in identify_info.listen_addrs {
self.swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
}
}
ConnectionClosed { peer_id, .. } => {
if self.known_peers.remove(&peer_id) {
let _ = self
.event_tx
.send(NetworkEvent::PeerDisconnected(peer_id))
.await;
}
}
_ => {}
}
}
async fn handle_command(&mut self, cmd: NetworkCommand) {
match cmd {
NetworkCommand::SendDirectMessage { peer, message } => {
info!("Sending direct message to {}", peer);
self.swarm
.behaviour_mut()
.direct_msg
.send_request(&peer, message);
}
NetworkCommand::PublishGossip { topic, data } => {
let topic = IdentTopic::new(&topic);
if let Err(e) = self.swarm.behaviour_mut().gossipsub.publish(topic, data) {
warn!("Failed to publish gossip: {}", e);
}
}
NetworkCommand::Subscribe { topic } => {
let topic = IdentTopic::new(&topic);
if let Err(e) = self.swarm.behaviour_mut().gossipsub.subscribe(&topic) {
warn!("Failed to subscribe to topic: {}", e);
}
}
NetworkCommand::DhtPut { key, value } => {
let record = kad::Record {
key: kad::RecordKey::new(&key),
value,
publisher: None,
expires: None,
};
if let Err(e) = self
.swarm
.behaviour_mut()
.kademlia
.put_record(record, kad::Quorum::One)
{
warn!("Failed to put DHT record: {}", e);
}
}
NetworkCommand::DhtGet { key } => {
self.swarm
.behaviour_mut()
.kademlia
.get_record(kad::RecordKey::new(&key));
}
NetworkCommand::Dial { addr } => {
info!("Dialing {}", addr);
if let Err(e) = self.swarm.dial(addr.clone()) {
error!("Failed to dial {}: {}", addr, e);
}
}
}
}
/// Get the number of known peers.
pub fn peer_count(&self) -> usize {
self.known_peers.len()
}
}

View File

@ -0,0 +1,19 @@
[package]
name = "tiwd-node"
version = "0.1.0"
edition = "2021"
description = "TIWD node binary - run your own node on The Internet We Deserve"
[dependencies]
tiwd-core = { path = "../tiwd-core" }
tiwd-trust = { path = "../tiwd-trust" }
tiwd-chat = { path = "../tiwd-chat" }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }
libp2p = { workspace = true }
chrono = { workspace = true }
warp = { workspace = true }
futures = { workspace = true }

View File

@ -0,0 +1,535 @@
mod proxy;
mod site;
mod web;
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tiwd_core::names::{NameCache, NameRecord};
use tiwd_core::network::{DirectMessage, NetworkCommand, NetworkEvent, NodeNetwork};
use tiwd_core::{NodeConfig, NodeIdentity};
use tiwd_trust::TrustScore;
use tokio::io::{self, AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, RwLock};
use tracing::{error, info};
/// Map PeerIds to display names for friendlier output.
struct PeerNames {
names: HashMap<String, String>,
}
impl PeerNames {
fn new() -> Self {
Self { names: HashMap::new() }
}
fn set(&mut self, peer_id: &str, name: &str) {
self.names.insert(peer_id.to_string(), name.to_string());
}
fn display(&self, peer_id: &str) -> String {
if let Some(name) = self.names.get(peer_id) {
format!("{} ({})", name, &peer_id[..12])
} else {
peer_id[..12].to_string()
}
}
fn short(&self, peer_id: &str) -> String {
if let Some(name) = self.names.get(peer_id) {
name.clone()
} else {
peer_id[..12].to_string()
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "warn".into()),
)
.init();
println!();
println!(" ╔══════════════════════════════════════════════╗");
println!(" ║ ║");
println!(" ║ TIWD - The Internet We Deserve ║");
println!(" ║ Trustless. Decentralized. Yours. ║");
println!(" ║ ║");
println!(" ╚══════════════════════════════════════════════╝");
println!();
// Load or create configuration
let mut config = NodeConfig::default();
if let Ok(dir) = std::env::var("TIWD_DATA_DIR") {
config.data_dir = PathBuf::from(dir);
}
if let Ok(port) = std::env::var("TIWD_PORT") {
config.listen_port = port.parse().unwrap_or(0);
}
let bootstrap_peer = std::env::var("TIWD_BOOTSTRAP").ok();
let auto_reply = std::env::var("TIWD_AUTO_REPLY").ok();
let node_name = std::env::var("TIWD_NAME").ok();
// Load or generate identity
let identity = NodeIdentity::load_or_generate(&config.data_dir)?;
let peer_id = identity.peer_id();
println!(" Your Peer ID: {}", peer_id);
// Initialize
let mut trust = TrustScore::new();
let mut name_cache = NameCache::new();
let mut peer_names = PeerNames::new();
println!(" Trust Score: {:.1} | Role: {:?}", trust.total(), trust.role());
println!();
// Create channels
let (event_tx, mut event_rx) = mpsc::channel::<NetworkEvent>(256);
let (command_tx, command_rx) = mpsc::channel::<NetworkCommand>(256);
let listen_port = config.listen_port;
let mut network = NodeNetwork::new(&identity, listen_port, event_tx, command_rx)?;
// Auto-register name if provided
if let Some(ref name) = node_name {
let record = NameRecord::new(name, peer_id);
let key = NameRecord::dht_key(name);
if let Ok(value) = record.to_bytes() {
let _ = command_tx.send(NetworkCommand::DhtPut { key, value }).await;
name_cache.add_my_name(name.clone());
name_cache.insert(record);
println!(" Registered: {}.tiwd", name);
// Initialize site directory
let site_dir = config.data_dir.join("site");
if let Err(e) = site::SiteManager::init_site(&site_dir, name) {
tracing::warn!("Failed to init site: {}", e);
} else {
println!(" Your site: {}.tiwd (serving from {:?})", name, site_dir);
}
}
}
println!();
println!(" ┌─────────────────────────────────────────────┐");
println!(" │ Commands: │");
println!(" │ │");
println!(" │ /msg <name> <text> Message by TIWD name │");
println!(" │ /dm <peerid> <text> Message by Peer ID │");
println!(" │ /register <name> Register a TIWD name │");
println!(" │ /resolve <name> Look up a TIWD name │");
println!(" │ /names Your registered names │");
println!(" │ /peers Connected peers │");
println!(" │ /trust Your trust score │");
println!(" │ /quit Exit │");
println!(" │ │");
println!(" │ Or just type to chat in #global │");
println!(" └─────────────────────────────────────────────┘");
println!();
// Clone for bootstrap
let bootstrap_tx = command_tx.clone();
// Create shared web state
let web_state = Arc::new(RwLock::new(web::WebState {
command_tx: command_tx.clone(),
peer_id: peer_id.to_string(),
name: node_name.clone(),
peers: Vec::new(),
ws_clients: Vec::new(),
pending_content: HashMap::new(),
name_cache: HashMap::new(),
}));
let web_state_events = web_state.clone();
// Start web UI server
let web_port: u16 = std::env::var("TIWD_WEB_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8080);
let web_state_server = web_state.clone();
tokio::spawn(async move {
web::start_web_server(web_state_server, web_port).await;
});
// Start .tiwd proxy (allows browser to resolve .tiwd domains directly)
let proxy_port: u16 = std::env::var("TIWD_PROXY_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(9080);
let web_state_proxy = web_state.clone();
tokio::spawn(async move {
proxy::start_proxy(web_state_proxy, proxy_port).await;
});
// Spawn network
let network_handle = tokio::spawn(async move {
if let Err(e) = network.run(listen_port).await {
error!("Network error: {}", e);
}
});
// Connect to bootstrap peer
if let Some(addr_str) = bootstrap_peer {
println!(" Connecting to network...");
if let Ok(addr) = addr_str.parse::<libp2p::Multiaddr>() {
let _ = bootstrap_tx.send(NetworkCommand::Dial { addr }).await;
}
}
// Spawn stdin reader
let (stdin_tx, mut stdin_rx) = mpsc::channel::<String>(32);
tokio::spawn(async move {
let reader = BufReader::new(io::stdin());
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if stdin_tx.send(line).await.is_err() {
break;
}
}
});
// Pending name resolution for /msg — maps name to pending message
let mut pending_msgs: HashMap<String, String> = HashMap::new();
// Connected peers tracking
let mut connected_peers: Vec<String> = Vec::new();
// Main loop
loop {
tokio::select! {
Some(event) = event_rx.recv() => {
match event {
NetworkEvent::PeerDiscovered(peer) => {
let pid = peer.to_string();
if !connected_peers.contains(&pid) {
connected_peers.push(pid.clone());
}
println!(" [+] Peer connected: {}", peer_names.display(&pid));
trust.record_active_day();
// Notify web UI
{
let mut ws = web_state_events.write().await;
if !ws.peers.contains(&pid) {
ws.peers.push(pid.clone());
}
let msg = serde_json::json!({
"type": "peer_discovered",
"peer_id": pid,
"display": peer_names.display(&pid),
});
web::broadcast_to_clients(&ws.ws_clients, &msg.to_string()).await;
}
}
NetworkEvent::PeerDisconnected(peer) => {
let pid = peer.to_string();
connected_peers.retain(|p| p != &pid);
println!(" [-] Peer disconnected: {}", peer_names.short(&pid));
// Notify web UI
{
let mut ws = web_state_events.write().await;
ws.peers.retain(|p| p != &pid);
let msg = serde_json::json!({
"type": "peer_disconnected",
"peer_id": pid,
});
web::broadcast_to_clients(&ws.ws_clients, &msg.to_string()).await;
}
}
NetworkEvent::DirectMessage { from, message } => {
// Try to extract the text
let text = if let Ok(chat_msg) = serde_json::from_slice::<tiwd_chat::ChatMessage>(&message.payload) {
match &chat_msg.content {
tiwd_chat::protocol::ChatContent::Text(t) => t.clone(),
_ => "<file>".to_string(),
}
} else {
String::from_utf8_lossy(&message.payload).to_string()
};
let from_str = from.to_string();
let sender = peer_names.short(&from_str);
println!();
println!(" ┌── Message from {} ──", sender);
println!("{}", text);
println!(" └────────────────────────────────");
println!();
// Notify web UI
{
let ws = web_state_events.read().await;
let msg = serde_json::json!({
"type": "dm_received",
"from": from_str,
"from_name": sender,
"text": text,
});
web::broadcast_to_clients(&ws.ws_clients, &msg.to_string()).await;
}
// Auto-reply if enabled
if let Some(ref reply_text) = auto_reply {
let reply = DirectMessage {
from: peer_id.to_string(),
payload: reply_text.as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "dm".to_string(),
};
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
peer: from,
message: reply,
}).await;
println!(" [auto-reply sent to {}]", sender);
}
}
NetworkEvent::GossipMessage { topic, from, data } => {
if let Ok(chat_msg) = serde_json::from_slice::<tiwd_chat::ChatMessage>(&data) {
let sender = from
.map(|p| {
let pid = p.to_string();
peer_names.short(&pid)
})
.unwrap_or_else(|| "unknown".to_string());
let channel = topic.split('/').last().unwrap_or(&topic);
match &chat_msg.content {
tiwd_chat::protocol::ChatContent::Text(text) => {
println!(" [#{}] {}: {}", channel, sender, text);
}
_ => {}
}
}
}
NetworkEvent::DirectMessageResponse { from, response } => {
// Check if this is a content response for a pending browse request
let from_str = from.to_string();
let mut ws = web_state_events.write().await;
// Find the pending request that matches this peer
let mut matched_key = None;
for key in ws.pending_content.keys() {
// Match by checking if this peer is the one we sent to
matched_key = Some(key.clone());
break;
}
if let Some(key) = matched_key {
if let Some(tx) = ws.pending_content.remove(&key) {
let _ = tx.send(web::ContentResult {
status: response.status,
content_type: response.content_type.clone(),
body: response.payload.clone(),
});
}
}
}
NetworkEvent::DhtValueFound { key: _, value } => {
if let Ok(record) = NameRecord::from_bytes(&value) {
println!();
println!(" ╔══════════════════════════════════════╗");
println!(" ║ Name Resolved ║");
println!("{}.tiwd → {}... ║",
format!("{:<10}", record.name),
&record.peer_id[..16]);
println!(" ╚══════════════════════════════════════╝");
println!();
// Track the name for display
peer_names.set(&record.peer_id, &format!("{}.tiwd", record.name));
// Update web state name cache for browse requests
{
let mut ws = web_state_events.write().await;
ws.name_cache.insert(record.name.clone(), record.peer_id.clone());
}
// Check if there's a pending message for this name
let name = record.name.clone();
name_cache.insert(record.clone());
if let Some(msg_text) = pending_msgs.remove(&name) {
if let Ok(target_peer) = record.peer_id.parse::<libp2p::PeerId>() {
let dm = DirectMessage {
from: peer_id.to_string(),
payload: msg_text.as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "dm".to_string(),
};
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
peer: target_peer,
message: dm,
}).await;
println!(" [sent to {}.tiwd]: {}", name, msg_text);
}
}
}
}
}
}
Some(line) = stdin_rx.recv() => {
let line = line.trim().to_string();
if line.is_empty() { continue; }
if line.starts_with('/') {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
match parts[0] {
"/quit" | "/exit" | "/q" => {
println!(" Goodbye.");
break;
}
"/peers" => {
if connected_peers.is_empty() {
println!(" No peers connected.");
} else {
println!(" Connected peers ({}):", connected_peers.len());
for p in &connected_peers {
println!(" {} ", peer_names.display(p));
}
}
}
"/trust" => {
println!(" ┌── Trust Score ──────────────────────┐");
println!(" │ Total: {:.1}/100 │", trust.total());
println!(" │ Time: {:.1}/40 │", trust.time_score);
println!(" │ Vouches: {:.1}/35 │", trust.vouch_score);
println!(" │ Activity: {:.1}/25 │", trust.activity_score);
println!(" │ Role: {:?}{}", trust.role(),
" ".repeat(22 - format!("{:?}", trust.role()).len()));
println!(" └──────────────────────────────────────┘");
}
"/msg" => {
if parts.len() < 3 {
println!(" Usage: /msg <name> <message>");
println!(" Example: /msg alice Hello!");
} else {
let name = parts[1].to_lowercase().replace(".tiwd", "");
let msg_text = parts[2].to_string();
// Check cache first
if let Some(record) = name_cache.get(&name) {
if let Ok(target) = record.peer_id.parse::<libp2p::PeerId>() {
let dm = DirectMessage {
from: peer_id.to_string(),
payload: msg_text.as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "dm".to_string(),
};
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
peer: target,
message: dm,
}).await;
println!(" [sent to {}.tiwd]: {}", name, msg_text);
}
} else {
// Resolve name first, then send
println!(" Resolving {}.tiwd...", name);
pending_msgs.insert(name.clone(), msg_text);
let key = NameRecord::dht_key(&name);
let _ = command_tx.send(NetworkCommand::DhtGet { key }).await;
}
}
}
"/dm" => {
if parts.len() < 3 {
println!(" Usage: /dm <peer_id> <message>");
} else if let Ok(peer) = parts[1].parse::<libp2p::PeerId>() {
let dm = DirectMessage {
from: peer_id.to_string(),
payload: parts[2].as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "dm".to_string(),
};
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
peer,
message: dm,
}).await;
println!(" [sent]: {}", parts[2]);
} else {
println!(" Invalid peer ID. Use /msg <name> instead.");
}
}
"/register" => {
if parts.len() < 2 {
println!(" Usage: /register <name>");
} else {
let name = parts[1].to_lowercase();
if !NameRecord::is_valid_name(&name) {
println!(" Invalid name. Use 3-32 chars, alphanumeric and hyphens.");
} else {
let record = NameRecord::new(&name, peer_id);
let key = NameRecord::dht_key(&name);
if let Ok(value) = record.to_bytes() {
let _ = command_tx.send(NetworkCommand::DhtPut { key, value }).await;
name_cache.add_my_name(name.clone());
name_cache.insert(record);
println!();
println!(" ╔══════════════════════════════════════╗");
println!(" ║ Name Registered! ║");
println!("{}.tiwd is now yours.{}", name,
" ".repeat(16 - name.len().min(16)));
println!(" ╚══════════════════════════════════════╝");
println!();
}
}
}
}
"/resolve" | "/lookup" | "/find" => {
if parts.len() < 2 {
println!(" Usage: /resolve <name>");
} else {
let name = parts[1].to_lowercase().replace(".tiwd", "");
if let Some(record) = name_cache.get(&name) {
println!(" {}.tiwd → {}", name, &record.peer_id[..20]);
} else {
println!(" Resolving {}.tiwd...", name);
let key = NameRecord::dht_key(&name);
let _ = command_tx.send(NetworkCommand::DhtGet { key }).await;
}
}
}
"/names" | "/mynames" => {
let names = name_cache.my_names();
if names.is_empty() {
println!(" No names registered. Use /register <name>");
} else {
println!(" Your TIWD names:");
for name in names {
println!(" {}.tiwd", name);
}
}
}
"/help" | "/?" => {
println!(" Commands:");
println!(" /msg <name> <text> Send message by TIWD name");
println!(" /dm <peerid> <text> Send message by Peer ID");
println!(" /register <name> Register a TIWD name");
println!(" /resolve <name> Look up a TIWD name");
println!(" /names Your registered names");
println!(" /peers Connected peers");
println!(" /trust Your trust score");
println!(" /quit Exit");
}
_ => {
println!(" Unknown command. Type /help for commands.");
}
}
} else {
// Send to #global
println!(" (Global chat coming soon. Use /msg <name> <text> for DMs)");
}
}
}
}
network_handle.abort();
Ok(())
}

View File

@ -0,0 +1,302 @@
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use crate::web::{ContentResult, SharedState};
use tiwd_core::names::NameRecord;
use tiwd_core::network::{DirectMessage, NetworkCommand};
/// Start a local HTTP proxy that intercepts .tiwd domains.
/// Only .tiwd requests go through the P2P network.
/// All other requests are forwarded to the real internet.
pub async fn start_proxy(state: SharedState, proxy_port: u16) {
let listener = match TcpListener::bind(format!("127.0.0.1:{}", proxy_port)).await {
Ok(l) => l,
Err(e) => {
tracing::error!("Failed to start proxy on port {}: {}", proxy_port, e);
return;
}
};
println!(" TIWD Proxy: localhost:{}", proxy_port);
println!();
loop {
if let Ok((stream, _)) = listener.accept().await {
let state = state.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, state).await {
tracing::debug!("Proxy connection error: {}", e);
}
});
}
}
}
async fn handle_connection(
mut stream: tokio::net::TcpStream,
state: SharedState,
) -> anyhow::Result<()> {
// Read the HTTP request
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).await?;
let request = String::from_utf8_lossy(&buf[..n]).to_string();
// Parse the first line: "GET http://server.tiwd/ HTTP/1.1"
let first_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = first_line.split_whitespace().collect();
if parts.len() < 3 {
return Ok(());
}
let method = parts[0];
let url = parts[1];
// Handle CONNECT method (HTTPS proxy — not supported for .tiwd)
if method == "CONNECT" {
if url.contains(".tiwd") {
let response = "HTTP/1.1 501 Not Implemented\r\n\r\nTIWD proxy does not support HTTPS for .tiwd domains. Use http:// instead.\r\n";
stream.write_all(response.as_bytes()).await?;
} else {
// For non-.tiwd CONNECT, tunnel through
let _ = forward_connect(&mut stream, url).await;
}
return Ok(());
}
// Extract host from URL or Host header
let host = if url.starts_with("http://") {
url.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_string()
} else {
// Try Host header
request
.lines()
.find(|l| l.to_lowercase().starts_with("host:"))
.map(|l| l.split(':').skip(1).collect::<Vec<_>>().join(":").trim().to_string())
.unwrap_or_default()
.split(':')
.next()
.unwrap_or("")
.to_string()
};
if host.ends_with(".tiwd") {
// This is a TIWD request — handle it P2P
let name = host.replace(".tiwd", "").to_lowercase();
let path = if url.starts_with("http://") {
let after_host = url
.trim_start_matches("http://")
.find('/')
.map(|i| &url.trim_start_matches("http://")[i..])
.unwrap_or("/");
after_host.to_string()
} else {
url.to_string()
};
let file_path = if path == "/" || path.is_empty() {
"index.html".to_string()
} else {
path.trim_start_matches('/').to_string()
};
let response = fetch_tiwd_content(&name, &file_path, &state).await;
stream.write_all(response.as_bytes()).await?;
} else {
// Not a .tiwd domain — forward to the real internet
let _ = forward_http(&mut stream, &request, &host, url).await;
}
Ok(())
}
/// Fetch content from a TIWD peer
async fn fetch_tiwd_content(name: &str, file_path: &str, state: &SharedState) -> String {
// Step 1: Resolve the name
let peer_id_str = {
let s = state.read().await;
s.name_cache.get(name).cloned()
};
let peer_id_str = match peer_id_str {
Some(pid) => pid,
None => {
// Resolve via DHT
{
let s = state.read().await;
let key = NameRecord::dht_key(name);
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
}
let mut resolved = None;
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let s = state.read().await;
if let Some(pid) = s.name_cache.get(name) {
resolved = Some(pid.clone());
break;
}
}
match resolved {
Some(pid) => pid,
None => {
let body = format!(
"<html><body style='font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1 style='color:#f85149'>{}.tiwd</h1><p style='color:#7d8590'>Could not resolve this name.</p></div></body></html>",
name
);
return format!(
"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
}
}
}
};
// Step 2: Send content request
let peer: libp2p::PeerId = match peer_id_str.parse() {
Ok(p) => p,
Err(_) => {
let body = "<h1>Error: Invalid PeerId</h1>";
return format!(
"HTTP/1.1 500 Error\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
}
};
let (response_tx, response_rx) = tokio::sync::oneshot::channel::<ContentResult>();
let request_id = format!("{}:{}", name, file_path);
{
let mut s = state.write().await;
s.pending_content.insert(request_id.clone(), response_tx);
let dm = DirectMessage {
from: s.peer_id.clone(),
payload: file_path.as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "content_request".to_string(),
};
let _ = s
.command_tx
.send(NetworkCommand::SendDirectMessage {
peer,
message: dm,
})
.await;
}
// Step 3: Wait for response
match tokio::time::timeout(std::time::Duration::from_secs(10), response_rx).await {
Ok(Ok(result)) => {
let status = if result.status == 200 { "200 OK" } else { "404 Not Found" };
format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
result.content_type,
result.body.len(),
String::from_utf8_lossy(&result.body)
)
}
_ => {
let mut s = state.write().await;
s.pending_content.remove(&request_id);
let body = format!(
"<html><body style='font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1 style='color:#e3b341'>{}.tiwd</h1><p style='color:#7d8590'>Request timed out.</p></div></body></html>",
name
);
format!(
"HTTP/1.1 504 Gateway Timeout\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
)
}
}
}
/// Forward a regular HTTP request to the real internet
async fn forward_http(
client: &mut tokio::net::TcpStream,
request: &str,
host: &str,
_url: &str,
) -> anyhow::Result<()> {
// Connect to the real server
let addr = format!("{}:80", host);
match tokio::net::TcpStream::connect(&addr).await {
Ok(mut server) => {
// Forward the request (rewrite absolute URL to relative)
let rewritten = request.replacen(
&format!("http://{}", host),
"",
1,
);
server.write_all(rewritten.as_bytes()).await?;
// Relay response back
let mut buf = vec![0u8; 65536];
loop {
match server.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
client.write_all(&buf[..n]).await?;
}
Err(_) => break,
}
}
}
Err(_) => {
let body = format!("<h1>502 Bad Gateway</h1><p>Could not connect to {}</p>", host);
let response = format!(
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
client.write_all(response.as_bytes()).await?;
}
}
Ok(())
}
/// Handle CONNECT (HTTPS tunneling) for non-.tiwd domains
async fn forward_connect(
client: &mut tokio::net::TcpStream,
target: &str,
) -> anyhow::Result<()> {
// Connect to the real server
match tokio::net::TcpStream::connect(target).await {
Ok(mut server) => {
// Tell client the tunnel is established
client
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await?;
// Bidirectional relay
let (mut client_read, mut client_write) = tokio::io::split(client);
let (mut server_read, mut server_write) = tokio::io::split(&mut server);
let c2s = tokio::io::copy(&mut client_read, &mut server_write);
let s2c = tokio::io::copy(&mut server_read, &mut client_write);
tokio::select! {
_ = c2s => {}
_ = s2c => {}
}
}
Err(_) => {
client
.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
.await?;
}
}
Ok(())
}

View File

@ -0,0 +1,119 @@
use std::path::{Path, PathBuf};
use tiwd_core::content::{ContentRequest, ContentResponse};
use tracing::info;
/// Manages the local site files that this node serves to the network.
/// When another node requests a page from your TIWD name, this module
/// serves the files from your local site directory.
pub struct SiteManager {
/// Directory containing the site files.
site_dir: PathBuf,
/// Whether this node is hosting a site.
enabled: bool,
}
impl SiteManager {
pub fn new(site_dir: &Path) -> Self {
let enabled = site_dir.exists() && site_dir.is_dir();
if enabled {
info!("Site hosting enabled from {:?}", site_dir);
}
Self {
site_dir: site_dir.to_path_buf(),
enabled,
}
}
/// Initialize a new site with a default index.html.
pub fn init_site(site_dir: &Path, site_name: &str) -> std::io::Result<()> {
std::fs::create_dir_all(site_dir)?;
let index_path = site_dir.join("index.html");
if !index_path.exists() {
let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name}.tiwd</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0d1117;
color: #e6edf3;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
}}
.container {{ max-width: 600px; padding: 40px; }}
h1 {{ font-size: 48px; margin-bottom: 16px; }}
h1 span {{ color: #00d4aa; }}
p {{ color: #7d8590; font-size: 18px; line-height: 1.6; margin-bottom: 24px; }}
.badge {{
display: inline-block;
background: #1c2333;
border: 1px solid #30363d;
border-radius: 20px;
padding: 8px 16px;
font-size: 14px;
color: #00d4aa;
}}
</style>
</head>
<body>
<div class="container">
<h1><span>{name}</span>.tiwd</h1>
<p>This page is served entirely peer-to-peer on the TIWD network. No servers. No DNS. No HTTP. Just cryptographic identity and a decentralized hash table.</p>
<div class="badge">Hosted on The Internet We Deserve</div>
</div>
</body>
</html>"#, name = site_name);
std::fs::write(&index_path, html)?;
info!("Created default site at {:?}", index_path);
}
Ok(())
}
/// Handle a content request from another node.
pub fn handle_request(&self, request: &ContentRequest) -> ContentResponse {
if !self.enabled {
return ContentResponse::not_found();
}
// Normalize the path
let mut path = request.path.trim_start_matches('/').to_string();
if path.is_empty() {
path = "index.html".to_string();
}
// Security: prevent directory traversal
if path.contains("..") || path.contains('\\') {
return ContentResponse::not_found();
}
let file_path = self.site_dir.join(&path);
// Try to read the file
match std::fs::read(&file_path) {
Ok(body) => {
let mime = ContentResponse::mime_for_path(&path);
info!("Serving {} ({}, {} bytes)", path, mime, body.len());
ContentResponse::ok(body, mime)
}
Err(_) => {
// Try with .html extension
let html_path = self.site_dir.join(format!("{}.html", path));
match std::fs::read(&html_path) {
Ok(body) => ContentResponse::ok(body, "text/html"),
Err(_) => ContentResponse::not_found(),
}
}
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
}

482
crates/tiwd-node/src/web.rs Normal file
View File

@ -0,0 +1,482 @@
use std::collections::HashMap;
use std::sync::Arc;
use tiwd_core::names::NameRecord;
use tiwd_core::network::{DirectMessage, NetworkCommand};
use tokio::sync::{mpsc, RwLock};
use warp::ws::{Message, WebSocket};
use warp::Filter;
use futures::{SinkExt, StreamExt};
/// Shared state between the web server and the P2P network.
pub struct WebState {
pub command_tx: mpsc::Sender<NetworkCommand>,
pub peer_id: String,
pub name: Option<String>,
pub peers: Vec<String>,
pub ws_clients: Vec<mpsc::Sender<String>>,
/// Pending content requests — waiting for P2P response
/// Maps a request ID to a oneshot sender for the response
pub pending_content: HashMap<String, tokio::sync::oneshot::Sender<ContentResult>>,
/// Resolved names cache (name → PeerId string)
pub name_cache: HashMap<String, String>,
}
/// Result of a content fetch from a peer
pub struct ContentResult {
pub status: u16,
pub content_type: String,
pub body: Vec<u8>,
}
pub type SharedState = Arc<RwLock<WebState>>;
/// Start the web server on the given port.
pub async fn start_web_server(state: SharedState, web_port: u16) {
let state_ws = state.clone();
let state_static = state.clone();
// WebSocket endpoint
let ws_route = warp::path("ws")
.and(warp::ws())
.and(warp::any().map(move || state_ws.clone()))
.map(|ws: warp::ws::Ws, state: SharedState| {
ws.on_upgrade(move |socket| handle_ws_client(socket, state))
});
// Browse TIWD sites — /browse/name.tiwd/path
let state_browse = state.clone();
let browse_route = warp::path("browse")
.and(warp::path::tail())
.and(warp::any().map(move || state_browse.clone()))
.and_then(handle_browse);
// Local site preview — /site/path (view your own hosted site)
let state_site = state.clone();
let site_route = warp::path("site")
.and(warp::path::tail())
.and(warp::any().map(move || state_site.clone()))
.and_then(handle_local_site);
// PAC file — tells the browser to use TIWD proxy only for .tiwd domains
let proxy_port_for_pac = std::env::var("TIWD_PROXY_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(9080);
let pac_route = warp::path("proxy.pac")
.map(move || {
let pac = format!(
r#"function FindProxyForURL(url, host) {{
if (shExpMatch(host, "*.tiwd") || dnsDomainIs(host, ".tiwd")) {{
return "PROXY localhost:{}";
}}
return "DIRECT";
}}"#,
proxy_port_for_pac
);
warp::http::Response::builder()
.header("Content-Type", "application/x-ns-proxy-autoconfig")
.body(pac)
.unwrap()
});
// Chat UI — served at /chat
let chat_html = warp::path("chat")
.and(warp::path::end())
.map(|| {
warp::reply::html(include_str!("../static/index.html"))
});
// Homepage — serve site index.html if it exists, otherwise chat UI
let state_home = state.clone();
let static_html = warp::path::end()
.and(warp::any().map(move || state_home.clone()))
.map(|_state: SharedState| {
let site_dir = std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
});
let index_path = std::path::PathBuf::from(&site_dir).join("index.html");
if index_path.exists() {
if let Ok(content) = std::fs::read_to_string(&index_path) {
return warp::reply::html(content);
}
}
warp::reply::html(include_str!("../static/index.html").to_string())
});
// Serve static assets (images, etc) from site directory
let site_dir_env = std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
});
let static_files = warp::path("static")
.and(warp::fs::dir(site_dir_env.clone()));
// Serve images at root level (for website compatibility)
let root_files = warp::fs::dir(site_dir_env);
let routes = ws_route
.or(pac_route)
.or(chat_html)
.or(browse_route)
.or(site_route)
.or(static_html)
.or(static_files)
.or(root_files);
// Check for TLS certificates
let tls_cert = std::env::var("TIWD_TLS_CERT").ok();
let tls_key = std::env::var("TIWD_TLS_KEY").ok();
match (tls_cert, tls_key) {
(Some(cert_path), Some(key_path)) => {
println!(" HTTPS: https://0.0.0.0:{}", web_port);
println!(" Your site: https://localhost:{}/site/", web_port);
println!(" Browse TIWD: https://localhost:{}/browse/name.tiwd", web_port);
println!();
// Serve with TLS — no Nginx needed
warp::serve(routes)
.tls()
.cert_path(&cert_path)
.key_path(&key_path)
.run(([0, 0, 0, 0], web_port))
.await;
}
_ => {
// No TLS — serve HTTP (local development or behind a proxy)
let bind_addr = if std::env::var("TIWD_PUBLIC").is_ok() {
[0, 0, 0, 0]
} else {
[127, 0, 0, 1]
};
println!(" Chat UI: http://localhost:{}", web_port);
println!(" Your site: http://localhost:{}/site/", web_port);
println!(" Browse TIWD: http://localhost:{}/browse/name.tiwd", web_port);
println!();
warp::serve(routes).run((bind_addr, web_port)).await;
}
}
}
/// Handle a WebSocket client connection.
async fn handle_ws_client(ws: WebSocket, state: SharedState) {
let (mut ws_tx, mut ws_rx) = ws.split();
let (client_tx, mut client_rx) = mpsc::channel::<String>(64);
// Register this client
{
let mut s = state.write().await;
s.ws_clients.push(client_tx.clone());
// Send identity info
let identity = serde_json::json!({
"type": "identity",
"peer_id": s.peer_id,
"name": s.name,
});
let _ = ws_tx.send(Message::text(identity.to_string())).await;
// Send existing peers
for peer in &s.peers {
let msg = serde_json::json!({
"type": "peer_discovered",
"peer_id": peer,
"display": &peer[..12.min(peer.len())],
});
let _ = ws_tx.send(Message::text(msg.to_string())).await;
}
}
// Spawn task to forward messages from P2P to WebSocket
let forward_task = tokio::spawn(async move {
while let Some(msg) = client_rx.recv().await {
if ws_tx.send(Message::text(msg)).await.is_err() {
break;
}
}
});
// Handle incoming WebSocket messages (from the UI)
while let Some(Ok(msg)) = ws_rx.next().await {
if let Ok(text) = msg.to_str() {
if let Ok(action) = serde_json::from_str::<serde_json::Value>(text) {
handle_ws_action(action, &state).await;
}
}
}
// Client disconnected — remove from list
{
let mut s = state.write().await;
s.ws_clients.retain(|tx| !tx.is_closed());
}
forward_task.abort();
}
/// Handle an action from the web UI.
async fn handle_ws_action(action: serde_json::Value, state: &SharedState) {
let action_type = action["action"].as_str().unwrap_or("");
match action_type {
"dm" => {
if let (Some(peer_id_str), Some(text)) = (
action["peer_id"].as_str(),
action["text"].as_str(),
) {
if let Ok(peer) = peer_id_str.parse::<libp2p::PeerId>() {
let s = state.read().await;
let dm = DirectMessage {
from: s.peer_id.clone(),
payload: text.as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "dm".to_string(),
};
let _ = s.command_tx.send(NetworkCommand::SendDirectMessage {
peer,
message: dm,
}).await;
// Confirm to UI
let confirm = serde_json::json!({
"type": "dm_sent",
"to": peer_id_str,
"text": text,
});
broadcast_to_clients(&s.ws_clients, &confirm.to_string()).await;
}
}
}
"msg" => {
if let (Some(name), Some(text)) = (
action["name"].as_str(),
action["text"].as_str(),
) {
let name = name.to_lowercase().replace(".tiwd", "");
let s = state.read().await;
// Resolve name via DHT
let key = NameRecord::dht_key(&name);
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
// TODO: queue the message to send after resolution
}
}
"register" => {
if let Some(name) = action["name"].as_str() {
let name = name.to_lowercase();
if NameRecord::is_valid_name(&name) {
let mut s = state.write().await;
let record = NameRecord::new(&name, s.peer_id.parse().unwrap_or(
libp2p::PeerId::random()
));
let key = NameRecord::dht_key(&name);
if let Ok(value) = record.to_bytes() {
let _ = s.command_tx.send(NetworkCommand::DhtPut { key, value }).await;
s.name = Some(name.clone());
let confirm = serde_json::json!({
"type": "name_registered",
"name": name,
});
broadcast_to_clients(&s.ws_clients, &confirm.to_string()).await;
}
}
}
}
"resolve" => {
if let Some(name) = action["name"].as_str() {
let name = name.to_lowercase().replace(".tiwd", "");
let s = state.read().await;
let key = NameRecord::dht_key(&name);
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
}
}
"chat" => {
// Global chat — for now just echo back
// TODO: publish via GossipSub
}
_ => {}
}
}
/// Send a message to all connected WebSocket clients.
pub async fn broadcast_to_clients(clients: &[mpsc::Sender<String>], msg: &str) {
for tx in clients {
let _ = tx.send(msg.to_string()).await;
}
}
/// Notify all web UI clients about a network event.
pub async fn notify_event(state: &SharedState, event_json: &str) {
let s = state.read().await;
broadcast_to_clients(&s.ws_clients, event_json).await;
}
/// Handle browsing a TIWD site — /browse/name.tiwd/path
async fn handle_browse(
tail: warp::path::Tail,
state: SharedState,
) -> Result<impl warp::Reply, warp::Rejection> {
let full_path = tail.as_str().to_string();
// Parse: "name.tiwd/path/to/file" or just "name.tiwd"
let parts: Vec<&str> = full_path.splitn(2, '/').collect();
let name = parts[0].replace(".tiwd", "").to_lowercase();
let path = if parts.len() > 1 && !parts[1].is_empty() {
parts[1].to_string()
} else {
"index.html".to_string()
};
// Step 1: Check if we already have the PeerId cached
let peer_id_str = {
let s = state.read().await;
s.name_cache.get(&name).cloned()
};
let peer_id_str = match peer_id_str {
Some(pid) => pid,
None => {
// Resolve via DHT — trigger the lookup
{
let s = state.read().await;
let key = NameRecord::dht_key(&name);
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
}
// Wait for resolution (poll the cache for up to 5 seconds)
let mut resolved = None;
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let s = state.read().await;
if let Some(pid) = s.name_cache.get(&name) {
resolved = Some(pid.clone());
break;
}
}
match resolved {
Some(pid) => pid,
None => {
return Ok(warp::reply::with_status(
warp::reply::html(format!(
r#"<!DOCTYPE html><html><head><title>{name}.tiwd</title>
<style>body{{font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;}}
.c{{text-align:center;}}.c h1{{color:#f85149;}}.c p{{color:#7d8590;margin-top:12px;}}</style></head>
<body><div class="c"><h1>{name}.tiwd</h1><p>Could not resolve this name. The peer may be offline or the name may not be registered.</p></div></body></html>"#,
name = name
)),
warp::http::StatusCode::NOT_FOUND,
));
}
}
}
};
// Step 2: Send content request to the peer
let peer: libp2p::PeerId = match peer_id_str.parse() {
Ok(p) => p,
Err(_) => {
return Ok(warp::reply::with_status(
warp::reply::html("<h1>Error: Invalid PeerId</h1>".to_string()),
warp::http::StatusCode::INTERNAL_SERVER_ERROR,
));
}
};
// Create a oneshot channel to receive the content response
let (response_tx, response_rx) = tokio::sync::oneshot::channel::<ContentResult>();
let request_id = format!("{}:{}", name, path);
{
let mut s = state.write().await;
s.pending_content.insert(request_id.clone(), response_tx);
// Send content request as a DirectMessage with type "content_request"
let dm = DirectMessage {
from: s.peer_id.clone(),
payload: path.as_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(),
msg_type: "content_request".to_string(),
};
let _ = s.command_tx.send(NetworkCommand::SendDirectMessage {
peer,
message: dm,
}).await;
}
// Step 3: Wait for the response (up to 10 seconds)
match tokio::time::timeout(
std::time::Duration::from_secs(10),
response_rx,
).await {
Ok(Ok(result)) => {
Ok(warp::reply::with_status(
warp::reply::html(String::from_utf8_lossy(&result.body).to_string()),
if result.status == 200 {
warp::http::StatusCode::OK
} else {
warp::http::StatusCode::NOT_FOUND
},
))
}
_ => {
// Timeout or channel error
let mut s = state.write().await;
s.pending_content.remove(&request_id);
Ok(warp::reply::with_status(
warp::reply::html(format!(
r#"<!DOCTYPE html><html><head><title>{name}.tiwd</title>
<style>body{{font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;}}
.c{{text-align:center;}}.c h1{{color:#e3b341;}}.c p{{color:#7d8590;margin-top:12px;}}</style></head>
<body><div class="c"><h1>{name}.tiwd</h1><p>Request timed out. The peer may be slow or unreachable.</p></div></body></html>"#,
name = name
)),
warp::http::StatusCode::GATEWAY_TIMEOUT,
))
}
}
}
/// Handle viewing your own local site — /site/path
async fn handle_local_site(
tail: warp::path::Tail,
state: SharedState,
) -> Result<impl warp::Reply, warp::Rejection> {
let path = tail.as_str();
let path = if path.is_empty() { "index.html" } else { path };
// Security: prevent directory traversal
if path.contains("..") {
return Ok(warp::reply::with_status(
warp::reply::html("<h1>403 Forbidden</h1>".to_string()),
warp::http::StatusCode::FORBIDDEN,
));
}
let s = state.read().await;
let site_dir = std::path::PathBuf::from(
std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
})
);
let file_path = site_dir.join(path);
match std::fs::read(&file_path) {
Ok(body) => {
let mime = tiwd_core::content::ContentResponse::mime_for_path(path);
Ok(warp::reply::with_status(
warp::reply::html(String::from_utf8_lossy(&body).to_string()),
warp::http::StatusCode::OK,
))
}
Err(_) => {
Ok(warp::reply::with_status(
warp::reply::html("<h1>404 - Page not found</h1><p>No site files found. Create files in your site directory.</p>".to_string()),
warp::http::StatusCode::NOT_FOUND,
))
}
}
}

View File

@ -0,0 +1,717 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIWD Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0d1117;
--bg-secondary: #161b22;
--bg-input: #0d1117;
--border: #30363d;
--text: #e6edf3;
--text-dim: #7d8590;
--accent: #00d4aa;
--accent-dim: #00a88a;
--msg-self: #1a2a3a;
--msg-other: #1c1c2e;
--danger: #f85149;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
color: var(--accent);
font-weight: 800;
font-size: 18px;
letter-spacing: 2px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse 2s infinite;
}
.status-dot.offline { background: var(--danger); animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--text-dim);
}
.peer-count {
background: var(--bg);
padding: 4px 10px;
border-radius: 12px;
border: 1px solid var(--border);
}
.trust-badge {
background: var(--bg);
padding: 4px 10px;
border-radius: 12px;
border: 1px solid var(--accent);
color: var(--accent);
}
/* ── Main Layout ── */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar ── */
.sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-section {
padding: 16px;
}
.sidebar-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-bottom: 10px;
}
.identity-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.identity-name {
color: var(--accent);
font-weight: 600;
font-size: 16px;
}
.identity-peer {
color: var(--text-dim);
font-size: 10px;
font-family: monospace;
word-break: break-all;
margin-top: 4px;
}
.peer-list {
list-style: none;
}
.peer-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.peer-item:hover {
background: var(--bg);
}
.peer-item.active {
background: var(--bg);
border: 1px solid var(--accent);
}
.peer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
.peer-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Chat Area ── */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.chat-header span {
color: var(--text-dim);
font-weight: 400;
font-size: 12px;
margin-left: 8px;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.message {
max-width: 70%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.message.self {
align-self: flex-end;
background: var(--msg-self);
border-bottom-right-radius: 4px;
}
.message.other {
align-self: flex-start;
background: var(--msg-other);
border-bottom-left-radius: 4px;
}
.message.system {
align-self: center;
background: transparent;
color: var(--text-dim);
font-size: 12px;
padding: 4px;
}
.message .sender {
font-size: 11px;
color: var(--accent);
font-weight: 600;
margin-bottom: 2px;
}
.message .time {
font-size: 10px;
color: var(--text-dim);
margin-top: 4px;
}
/* ── Input ── */
.input-area {
padding: 16px 20px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.input-row {
display: flex;
gap: 8px;
}
.input-row input {
flex: 1;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus {
border-color: var(--accent);
}
.input-row input::placeholder {
color: var(--text-dim);
}
.send-btn {
background: var(--accent);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #000;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.send-btn:hover {
background: var(--accent-dim);
}
/* ── Name Registration Modal ── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
width: 400px;
max-width: 90vw;
}
.modal h2 {
margin-bottom: 8px;
font-size: 20px;
}
.modal p {
color: var(--text-dim);
font-size: 13px;
margin-bottom: 16px;
}
.modal input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
color: var(--text);
font-size: 16px;
outline: none;
margin-bottom: 16px;
}
.modal input:focus { border-color: var(--accent); }
.modal-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.modal-btn {
padding: 8px 20px;
border-radius: 6px;
border: none;
font-size: 14px;
cursor: pointer;
}
.modal-btn.primary {
background: var(--accent);
color: #000;
font-weight: 600;
}
.modal-btn.secondary {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
}
.tiwd-suffix {
color: var(--text-dim);
font-size: 14px;
}
/* ── Log panel ── */
.log-panel {
max-height: 120px;
overflow-y: auto;
padding: 8px 16px;
font-family: monospace;
font-size: 11px;
color: var(--text-dim);
border-top: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.log-entry { padding: 1px 0; }
.log-entry.info { color: var(--accent); }
.log-entry.warn { color: #e3b341; }
.log-entry.error { color: var(--danger); }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<div class="logo">TIWD</div>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
</div>
<div class="header-right">
<div class="peer-count" id="peerCount">0 peers</div>
<div class="trust-badge" id="trustBadge">Newcomer</div>
</div>
</div>
<div class="main">
<div class="sidebar">
<div class="sidebar-section">
<div class="sidebar-title">Your Identity</div>
<div class="identity-card">
<div class="identity-name" id="myName">Loading...</div>
<div class="identity-peer" id="myPeerId">...</div>
</div>
</div>
<div class="sidebar-section" style="flex: 1; overflow-y: auto;">
<div class="sidebar-title">Peers</div>
<ul class="peer-list" id="peerList">
<li class="peer-item" style="color: var(--text-dim); font-style: italic; font-size: 12px;">
Discovering peers...
</li>
</ul>
</div>
<div class="sidebar-section">
<div class="sidebar-title">TIWD Names</div>
<ul class="peer-list" id="nameList">
</ul>
<button class="send-btn" style="width: 100%; margin-top: 8px; font-size: 12px;" onclick="showRegisterModal()">
Register a Name
</button>
</div>
</div>
<div class="chat-area">
<div class="chat-header">
#global <span>Encrypted peer-to-peer chat</span>
</div>
<div class="messages" id="messages">
<div class="message system">
Welcome to TIWD — The Internet We Deserve. All messages are end-to-end encrypted.
</div>
</div>
<div class="input-area">
<div class="input-row">
<input type="text" id="msgInput" placeholder="Type a message..." autocomplete="off"
onkeydown="if(event.key==='Enter')sendMessage()">
<button class="send-btn" onclick="sendMessage()">Send</button>
</div>
</div>
</div>
</div>
<div class="log-panel" id="logPanel">
<div class="log-entry info">TIWD node starting...</div>
</div>
<!-- Register Name Modal -->
<div class="modal-overlay" id="registerModal">
<div class="modal">
<h2>Register a TIWD Name</h2>
<p>Choose a unique name for your identity on the network. This name is free, permanent, and yours.</p>
<div style="display: flex; align-items: center; gap: 4px;">
<input type="text" id="nameInput" placeholder="yourname" autocomplete="off"
onkeydown="if(event.key==='Enter')registerName()">
<span class="tiwd-suffix">.tiwd</span>
</div>
<div class="modal-buttons">
<button class="modal-btn secondary" onclick="hideRegisterModal()">Cancel</button>
<button class="modal-btn primary" onclick="registerName()">Register</button>
</div>
</div>
</div>
<script>
// ── State ──
let ws = null;
let myPeerId = '';
let myName = '';
let peers = [];
let connected = false;
// ── WebSocket Connection ──
function connect() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.onopen = () => {
connected = true;
updateStatus(true);
log('Connected to TIWD node', 'info');
};
ws.onclose = () => {
connected = false;
updateStatus(false);
log('Disconnected. Reconnecting...', 'warn');
setTimeout(connect, 3000);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
handleMessage(msg);
} catch (err) {
log('Invalid message: ' + e.data, 'error');
}
};
}
function handleMessage(msg) {
switch (msg.type) {
case 'identity':
myPeerId = msg.peer_id;
myName = msg.name || '';
document.getElementById('myPeerId').textContent = myPeerId;
document.getElementById('myName').textContent = myName ? myName + '.tiwd' : myPeerId.substring(0, 16) + '...';
break;
case 'peer_discovered':
if (!peers.includes(msg.peer_id)) {
peers.push(msg.peer_id);
updatePeerList();
addSystemMessage(msg.display || msg.peer_id.substring(0, 12) + ' joined the network');
log('Peer discovered: ' + (msg.display || msg.peer_id.substring(0, 12)), 'info');
}
document.getElementById('peerCount').textContent = peers.length + ' peer' + (peers.length !== 1 ? 's' : '');
break;
case 'peer_disconnected':
peers = peers.filter(p => p !== msg.peer_id);
updatePeerList();
document.getElementById('peerCount').textContent = peers.length + ' peer' + (peers.length !== 1 ? 's' : '');
break;
case 'dm_received':
addMessage(msg.from_name || msg.from.substring(0, 12), msg.text, false);
break;
case 'dm_sent':
addMessage('You', msg.text, true);
break;
case 'name_registered':
myName = msg.name;
document.getElementById('myName').textContent = msg.name + '.tiwd';
addSystemMessage('Registered: ' + msg.name + '.tiwd');
updateNameList();
hideRegisterModal();
break;
case 'name_resolved':
addSystemMessage(msg.name + '.tiwd → ' + msg.peer_id.substring(0, 16) + '...');
break;
case 'error':
log(msg.text, 'error');
break;
default:
log('Unknown message type: ' + msg.type, 'warn');
}
}
// ── Actions ──
function sendMessage() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text || !ws) return;
// Check if it's a command
if (text.startsWith('/msg ')) {
const parts = text.substring(5).split(' ');
const name = parts.shift();
const msg = parts.join(' ');
ws.send(JSON.stringify({ action: 'msg', name: name, text: msg }));
} else if (text.startsWith('/dm ')) {
const parts = text.substring(4).split(' ');
const peer = parts.shift();
const msg = parts.join(' ');
ws.send(JSON.stringify({ action: 'dm', peer_id: peer, text: msg }));
} else if (text.startsWith('/resolve ')) {
ws.send(JSON.stringify({ action: 'resolve', name: text.substring(9) }));
} else {
// Send as chat message
ws.send(JSON.stringify({ action: 'chat', text: text }));
addMessage('You', text, true);
}
input.value = '';
input.focus();
}
function registerName() {
const input = document.getElementById('nameInput');
const name = input.value.trim().toLowerCase().replace('.tiwd', '');
if (!name || !ws) return;
ws.send(JSON.stringify({ action: 'register', name: name }));
}
function messagePeer(peerId) {
const text = prompt('Message to ' + peerId.substring(0, 12) + ':');
if (text && ws) {
ws.send(JSON.stringify({ action: 'dm', peer_id: peerId, text: text }));
}
}
// ── UI Updates ──
function addMessage(sender, text, isSelf) {
const div = document.createElement('div');
div.className = 'message ' + (isSelf ? 'self' : 'other');
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.innerHTML = (isSelf ? '' : '<div class="sender">' + escapeHtml(sender) + '</div>') +
escapeHtml(text) +
'<div class="time">' + now + '</div>';
document.getElementById('messages').appendChild(div);
div.scrollIntoView({ behavior: 'smooth' });
}
function addSystemMessage(text) {
const div = document.createElement('div');
div.className = 'message system';
div.textContent = text;
document.getElementById('messages').appendChild(div);
div.scrollIntoView({ behavior: 'smooth' });
}
function updatePeerList() {
const list = document.getElementById('peerList');
if (peers.length === 0) {
list.innerHTML = '<li class="peer-item" style="color: var(--text-dim); font-style: italic; font-size: 12px;">Discovering peers...</li>';
return;
}
list.innerHTML = peers.map(p =>
'<li class="peer-item" onclick="messagePeer(\'' + p + '\')">' +
'<div class="peer-dot"></div>' +
'<span class="peer-name">' + p.substring(0, 16) + '...</span>' +
'</li>'
).join('');
}
function updateNameList() {
const list = document.getElementById('nameList');
if (myName) {
list.innerHTML = '<li class="peer-item"><div class="peer-dot"></div><span class="peer-name">' + myName + '.tiwd</span></li>';
}
}
function updateStatus(online) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
dot.className = 'status-dot' + (online ? '' : ' offline');
text.textContent = online ? 'Connected' : 'Offline';
}
function showRegisterModal() {
document.getElementById('registerModal').classList.add('active');
document.getElementById('nameInput').focus();
}
function hideRegisterModal() {
document.getElementById('registerModal').classList.remove('active');
}
function log(text, level) {
const panel = document.getElementById('logPanel');
const entry = document.createElement('div');
entry.className = 'log-entry ' + (level || '');
entry.textContent = new Date().toLocaleTimeString() + ' ' + text;
panel.appendChild(entry);
panel.scrollTop = panel.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ── Start ──
connect();
// Focus input on any keypress
document.addEventListener('keydown', (e) => {
if (e.target.tagName !== 'INPUT' && !e.ctrlKey && !e.metaKey && !e.altKey) {
document.getElementById('msgInput').focus();
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,24 @@
[package]
name = "tiwd-sdk"
version = "0.1.0"
edition = "2021"
description = "SDK for building apps on the TIWD decentralized network"
[dependencies]
tiwd-core = { path = "../tiwd-core" }
tiwd-trust = { path = "../tiwd-trust" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
libp2p = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
tracing-subscriber = { workspace = true }
[[example]]
name = "pastebin"
path = "examples/pastebin.rs"

View File

@ -0,0 +1,108 @@
/// Example: A decentralized pastebin built on TIWD.
///
/// This demonstrates how a third-party developer would build an app
/// on the TIWD network using the SDK. The entire networking, encryption,
/// peer discovery, and trust system is handled by the platform.
///
/// Run with:
/// cargo run --example pastebin
use tiwd_sdk::prelude::*;
// ── The App ────────────────────────────────────────────────────────
struct PastebinApp {
pastes: std::collections::HashMap<String, String>,
}
impl PastebinApp {
fn new() -> Self {
Self {
pastes: std::collections::HashMap::new(),
}
}
}
#[async_trait]
impl TiwdApp for PastebinApp {
fn manifest(&self) -> AppManifest {
AppManifest::new("pastebin", "1.0.0")
.with_description("Decentralized paste sharing")
.with_gossip_topics(vec!["new-paste"])
.with_dht_access(true)
.with_direct_messaging(true)
}
async fn on_start(&mut self, ctx: &AppContext) -> Result<()> {
// Subscribe to paste announcements
ctx.subscribe("new-paste").await?;
println!("[pastebin] App started! Listening for pastes...");
Ok(())
}
async fn on_gossip_message(
&mut self,
_ctx: &AppContext,
topic: &str,
from: Option<PeerId>,
data: &[u8],
) -> Result<()> {
if let Ok(text) = String::from_utf8(data.to_vec()) {
let sender = from
.map(|p| p.to_string()[..8].to_string())
.unwrap_or_else(|| "unknown".into());
println!("[pastebin] New paste from {}: {}", sender, text);
// Store locally
let id = format!("paste-{}", self.pastes.len());
self.pastes.insert(id.clone(), text);
println!("[pastebin] Stored as {}", id);
}
Ok(())
}
async fn on_direct_message(
&mut self,
ctx: &AppContext,
from: PeerId,
data: &[u8],
) -> Result<()> {
if let Ok(request) = String::from_utf8(data.to_vec()) {
if request.starts_with("GET ") {
let id = request.trim_start_matches("GET ");
if let Some(paste) = self.pastes.get(id) {
println!("[pastebin] {} requested paste {}", from, id);
ctx.send_message(from, paste.as_bytes()).await?;
}
}
}
Ok(())
}
async fn on_peer_discovered(&mut self, _ctx: &AppContext, peer: PeerId) -> Result<()> {
println!("[pastebin] New peer: {}", peer);
Ok(())
}
}
// ── Main ───────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info")
.init();
println!("╔══════════════════════════════════════╗");
println!("║ TIWD Pastebin — Example App ║");
println!("╚══════════════════════════════════════╝");
println!();
// Create the runtime with default config
let mut runtime = AppRuntime::new(NodeConfig::default()).await?;
// Register our app — that's it!
runtime.register(Box::new(PastebinApp::new()))?;
// Run forever
runtime.run().await
}

196
crates/tiwd-sdk/src/app.rs Normal file
View File

@ -0,0 +1,196 @@
use crate::context::AppContext;
use anyhow::Result;
use libp2p::PeerId;
use serde::{Deserialize, Serialize};
// ── App Trait ──────────────────────────────────────────────────────
/// The trait every TIWD app must implement.
///
/// This is the only interface a developer needs to build an app on TIWD.
/// The network handles discovery, encryption, trust, and transport —
/// the app just handles its own logic.
///
/// # Example
///
/// ```rust,ignore
/// use tiwd_sdk::prelude::*;
///
/// struct Pastebin;
///
/// #[async_trait]
/// impl TiwdApp for Pastebin {
/// fn manifest(&self) -> AppManifest {
/// AppManifest::new("pastebin", "1.0.0")
/// .with_description("Decentralized paste sharing")
/// .with_gossip_topics(vec!["pastes"])
/// .with_dht_access(true)
/// .with_direct_messaging(true)
/// }
///
/// async fn on_start(&mut self, ctx: &AppContext) -> Result<()> {
/// println!("Pastebin app started!");
/// Ok(())
/// }
///
/// async fn on_direct_message(
/// &mut self, ctx: &AppContext, from: PeerId, data: &[u8],
/// ) -> Result<()> {
/// // Someone sent us a paste request
/// Ok(())
/// }
/// }
/// ```
#[async_trait::async_trait]
pub trait TiwdApp: Send + Sync {
/// Declare what this app needs from the network.
/// This is checked at registration time.
fn manifest(&self) -> AppManifest;
/// Called when the app is started and the network is ready.
/// Use this to subscribe to topics, set up state, etc.
async fn on_start(&mut self, ctx: &AppContext) -> Result<()> {
let _ = ctx;
Ok(())
}
/// Called when the app is shutting down.
async fn on_stop(&mut self, ctx: &AppContext) -> Result<()> {
let _ = ctx;
Ok(())
}
/// Called when a direct message arrives for this app.
async fn on_direct_message(
&mut self,
ctx: &AppContext,
from: PeerId,
data: &[u8],
) -> Result<()> {
let _ = (ctx, from, data);
Ok(())
}
/// Called when a gossipsub message arrives on one of this app's topics.
async fn on_gossip_message(
&mut self,
ctx: &AppContext,
topic: &str,
from: Option<PeerId>,
data: &[u8],
) -> Result<()> {
let _ = (ctx, topic, from, data);
Ok(())
}
/// Called when a new peer is discovered on the network.
async fn on_peer_discovered(&mut self, ctx: &AppContext, peer: PeerId) -> Result<()> {
let _ = (ctx, peer);
Ok(())
}
/// Called when a peer disconnects.
async fn on_peer_disconnected(&mut self, ctx: &AppContext, peer: PeerId) -> Result<()> {
let _ = (ctx, peer);
Ok(())
}
/// Called when a DHT lookup completes.
async fn on_dht_value_found(
&mut self,
ctx: &AppContext,
key: &[u8],
value: &[u8],
) -> Result<()> {
let _ = (ctx, key, value);
Ok(())
}
}
// ── App Manifest ───────────────────────────────────────────────────
/// Declares what an app needs from the network.
/// This is the "contract" between the app and the TIWD platform.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
/// Unique app identifier (e.g., "chat", "fileshare", "pastebin").
/// Used to namespace protocols: `/tiwd/app/{app_id}/1.0.0`
pub app_id: String,
/// Semantic version of this app.
pub version: String,
/// Human-readable description.
pub description: String,
/// GossipSub topics this app wants to use.
/// Will be namespaced: `tiwd/app/{app_id}/{topic}`
pub gossip_topics: Vec<String>,
/// Whether this app needs direct peer-to-peer messaging.
pub uses_direct_messaging: bool,
/// Whether this app needs to read/write the DHT.
pub uses_dht: bool,
/// Whether this app needs access to trust scores.
pub uses_trust: bool,
/// Minimum trust score required to use this app (0 = anyone).
pub min_trust_score: f64,
}
impl AppManifest {
pub fn new(app_id: &str, version: &str) -> Self {
Self {
app_id: app_id.to_string(),
version: version.to_string(),
description: String::new(),
gossip_topics: Vec::new(),
uses_direct_messaging: false,
uses_dht: false,
uses_trust: false,
min_trust_score: 0.0,
}
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = desc.to_string();
self
}
pub fn with_gossip_topics(mut self, topics: Vec<&str>) -> Self {
self.gossip_topics = topics.into_iter().map(String::from).collect();
self
}
pub fn with_direct_messaging(mut self, enabled: bool) -> Self {
self.uses_direct_messaging = enabled;
self
}
pub fn with_dht_access(mut self, enabled: bool) -> Self {
self.uses_dht = enabled;
self
}
pub fn with_trust_access(mut self, enabled: bool) -> Self {
self.uses_trust = enabled;
self
}
pub fn with_min_trust(mut self, score: f64) -> Self {
self.min_trust_score = score;
self
}
/// Get the fully namespaced gossip topic for this app.
pub fn full_topic(&self, topic: &str) -> String {
format!("tiwd/app/{}/{}", self.app_id, topic)
}
/// Get the protocol ID for direct messaging.
pub fn protocol_id(&self) -> String {
format!("/tiwd/app/{}/{}", self.app_id, self.version)
}
}

View File

@ -0,0 +1,201 @@
use anyhow::Result;
use libp2p::PeerId;
use tiwd_core::network::NetworkCommand;
use tiwd_core::NodeIdentity;
use tiwd_trust::{NodeRole, TrustScore};
use tokio::sync::mpsc;
use tracing::debug;
// ── App Context ────────────────────────────────────────────────────
/// The API surface available to every TIWD app.
///
/// This is what the app uses to interact with the network.
/// All networking details are abstracted away — the app just calls methods.
///
/// ```rust,ignore
/// // Send a message to a peer
/// ctx.send_message(peer_id, b"hello").await?;
///
/// // Publish to a gossip topic
/// ctx.publish("announcements", b"new version available").await?;
///
/// // Store data in the DHT
/// ctx.dht_put(b"my-key", b"my-value").await?;
///
/// // Check a peer's trust
/// let trust = ctx.get_peer_trust(&peer_id);
/// ```
pub struct AppContext {
/// The app's namespaced ID (e.g., "chat", "fileshare").
app_id: String,
/// Channel to send commands to the network layer.
command_tx: mpsc::Sender<NetworkCommand>,
/// This node's peer ID.
local_peer_id: PeerId,
/// This node's trust score.
local_trust: TrustScore,
}
impl AppContext {
pub fn new(
app_id: String,
command_tx: mpsc::Sender<NetworkCommand>,
local_peer_id: PeerId,
local_trust: TrustScore,
) -> Self {
Self {
app_id,
command_tx,
local_peer_id,
local_trust,
}
}
// ── Identity ───────────────────────────────────────────────
/// Get this node's peer ID.
pub fn peer_id(&self) -> PeerId {
self.local_peer_id
}
/// Get this app's ID.
pub fn app_id(&self) -> &str {
&self.app_id
}
// ── Messaging ──────────────────────────────────────────────
/// Send a direct message to a specific peer.
/// The message is namespaced to this app automatically.
pub async fn send_message(&self, peer: PeerId, data: &[u8]) -> Result<()> {
let message = tiwd_core::network::DirectMessage {
from: self.local_peer_id.to_string(),
payload: self.wrap_app_message(data),
timestamp: chrono::Utc::now().timestamp(),
signature: Vec::new(), // TODO: sign with node identity
};
self.command_tx
.send(NetworkCommand::SendDirectMessage { peer, message })
.await?;
debug!("[{}] Sent DM to {}", self.app_id, peer);
Ok(())
}
/// Publish a message to a gossip topic.
/// Topic is automatically namespaced: `tiwd/app/{app_id}/{topic}`
pub async fn publish(&self, topic: &str, data: &[u8]) -> Result<()> {
let full_topic = self.full_topic(topic);
self.command_tx
.send(NetworkCommand::PublishGossip {
topic: full_topic.clone(),
data: self.wrap_app_message(data),
})
.await?;
debug!("[{}] Published to {}", self.app_id, full_topic);
Ok(())
}
/// Subscribe to a gossip topic.
/// Topic is automatically namespaced.
pub async fn subscribe(&self, topic: &str) -> Result<()> {
let full_topic = self.full_topic(topic);
self.command_tx
.send(NetworkCommand::Subscribe {
topic: full_topic.clone(),
})
.await?;
debug!("[{}] Subscribed to {}", self.app_id, full_topic);
Ok(())
}
// ── DHT (Distributed Storage) ──────────────────────────────
/// Store a key-value pair in the DHT.
/// Key is automatically namespaced to this app.
pub async fn dht_put(&self, key: &[u8], value: &[u8]) -> Result<()> {
let full_key = self.namespaced_key(key);
self.command_tx
.send(NetworkCommand::DhtPut {
key: full_key,
value: value.to_vec(),
})
.await?;
Ok(())
}
/// Look up a value in the DHT.
/// Key is automatically namespaced to this app.
/// Result arrives via `on_dht_value_found` callback.
pub async fn dht_get(&self, key: &[u8]) -> Result<()> {
let full_key = self.namespaced_key(key);
self.command_tx
.send(NetworkCommand::DhtGet { key: full_key })
.await?;
Ok(())
}
// ── Trust ──────────────────────────────────────────────────
/// Get this node's current trust score.
pub fn local_trust(&self) -> &TrustScore {
&self.local_trust
}
/// Get this node's role.
pub fn local_role(&self) -> NodeRole {
self.local_trust.role()
}
/// Check if this node meets a minimum trust threshold.
pub fn meets_trust_threshold(&self, min_score: f64) -> bool {
self.local_trust.total() >= min_score
}
// ── Connectivity ───────────────────────────────────────────
/// Connect to a specific peer by multiaddress.
pub async fn dial(&self, addr: &str) -> Result<()> {
let multiaddr: libp2p::Multiaddr = addr.parse()?;
self.command_tx
.send(NetworkCommand::Dial { addr: multiaddr })
.await?;
Ok(())
}
// ── Internal helpers ───────────────────────────────────────
fn full_topic(&self, topic: &str) -> String {
format!("tiwd/app/{}/{}", self.app_id, topic)
}
fn namespaced_key(&self, key: &[u8]) -> Vec<u8> {
let prefix = format!("tiwd/app/{}/", self.app_id);
let mut full_key = prefix.into_bytes();
full_key.extend_from_slice(key);
full_key
}
fn wrap_app_message(&self, data: &[u8]) -> Vec<u8> {
// Wrap with app metadata so the receiving node knows which app to route to
let wrapper = AppMessage {
app_id: self.app_id.clone(),
sender: self.local_peer_id.to_string(),
data: data.to_vec(),
};
serde_json::to_vec(&wrapper).unwrap_or_else(|_| data.to_vec())
}
}
/// Internal message wrapper that tags every message with its source app.
/// The node uses this to route incoming messages to the correct app.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AppMessage {
pub app_id: String,
pub sender: String,
pub data: Vec<u8>,
}

View File

@ -0,0 +1,19 @@
pub mod app;
pub mod context;
pub mod runtime;
pub use app::{AppManifest, TiwdApp};
pub use context::AppContext;
pub use runtime::AppRuntime;
/// Re-exports for convenience.
pub mod prelude {
pub use crate::app::{AppManifest, TiwdApp};
pub use crate::context::AppContext;
pub use crate::runtime::AppRuntime;
pub use anyhow::Result;
pub use async_trait::async_trait;
pub use libp2p::PeerId;
pub use tiwd_core::NodeConfig;
pub use tiwd_trust::{NodeRole, TrustScore};
}

View File

@ -0,0 +1,249 @@
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::Path;
use tiwd_core::network::{NetworkCommand, NetworkEvent, NodeNetwork};
use tiwd_core::{NodeConfig, NodeIdentity};
use tiwd_trust::TrustScore;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use crate::app::TiwdApp;
use crate::context::{AppContext, AppMessage};
// ── App Runtime ────────────────────────────────────────────────────
/// The TIWD app runtime. Manages the network and routes events to registered apps.
///
/// This is what ties everything together. Developers create a runtime,
/// register their apps, and call `run()`. The runtime handles the rest.
///
/// # Example
///
/// ```rust,ignore
/// use tiwd_sdk::prelude::*;
///
/// #[tokio::main]
/// async fn main() -> Result<()> {
/// let mut runtime = AppRuntime::new(NodeConfig::default()).await?;
/// runtime.register(MyApp::new())?;
/// runtime.run().await
/// }
/// ```
pub struct AppRuntime {
config: NodeConfig,
identity: NodeIdentity,
trust: TrustScore,
apps: HashMap<String, Box<dyn TiwdApp>>,
event_rx: mpsc::Receiver<NetworkEvent>,
command_tx: mpsc::Sender<NetworkCommand>,
command_rx_for_network: Option<mpsc::Receiver<NetworkCommand>>,
}
impl AppRuntime {
/// Create a new app runtime with the given config.
pub async fn new(config: NodeConfig) -> Result<Self> {
let identity = NodeIdentity::load_or_generate(&config.data_dir)?;
let trust = TrustScore::new();
let (event_tx, event_rx) = mpsc::channel::<NetworkEvent>(256);
let (command_tx, command_rx) = mpsc::channel::<NetworkCommand>(256);
Ok(Self {
config,
identity,
trust,
apps: HashMap::new(),
event_rx,
command_tx,
command_rx_for_network: Some(command_rx),
})
}
/// Register an app with the runtime.
/// The app's manifest is validated and its topics are set up.
pub fn register(&mut self, app: Box<dyn TiwdApp>) -> Result<()> {
let manifest = app.manifest();
let app_id = manifest.app_id.clone();
// Check for duplicate registration
if self.apps.contains_key(&app_id) {
anyhow::bail!("App '{}' is already registered", app_id);
}
// Check trust requirements
if self.trust.total() < manifest.min_trust_score {
anyhow::bail!(
"App '{}' requires trust score {}, but this node has {}",
app_id,
manifest.min_trust_score,
self.trust.total()
);
}
info!(
"Registered app: {} v{} ({})",
app_id, manifest.version, manifest.description
);
self.apps.insert(app_id, app);
Ok(())
}
/// Get this node's peer ID.
pub fn peer_id(&self) -> libp2p::PeerId {
self.identity.peer_id()
}
/// Get this node's trust score.
pub fn trust(&self) -> &TrustScore {
&self.trust
}
/// Run the runtime. This starts the network and routes events to apps.
/// This method runs forever (until the process is killed).
pub async fn run(&mut self) -> Result<()> {
let peer_id = self.identity.peer_id();
let listen_port = self.config.listen_port;
// Take the command receiver (can only be taken once)
let command_rx = self
.command_rx_for_network
.take()
.context("Runtime already started")?;
// Create the network
let event_tx = {
let (tx, rx) = mpsc::channel::<NetworkEvent>(256);
// Replace our event_rx with the new one
// We need a fresh event_tx for the network
self.event_rx = rx;
tx
};
let mut network =
NodeNetwork::new(&self.identity, listen_port, event_tx, command_rx)?;
// Start all registered apps
for (app_id, app) in self.apps.iter_mut() {
let ctx = AppContext::new(
app_id.clone(),
self.command_tx.clone(),
peer_id,
self.trust.clone(),
);
if let Err(e) = app.on_start(&ctx).await {
error!("App '{}' failed to start: {}", app_id, e);
} else {
info!("App '{}' started", app_id);
}
}
// Spawn the network event loop
tokio::spawn(async move {
if let Err(e) = network.run(listen_port).await {
error!("Network error: {}", e);
}
});
info!("TIWD runtime started with {} app(s)", self.apps.len());
// Route events to apps
while let Some(event) = self.event_rx.recv().await {
self.dispatch_event(event).await;
}
Ok(())
}
async fn dispatch_event(&mut self, event: NetworkEvent) {
// Extract fields needed for AppContext so we don't borrow self while iterating apps
let cmd_tx = &self.command_tx;
let peer_id = self.identity.peer_id();
let trust = &self.trust;
let make_ctx = |app_id: &str| -> AppContext {
AppContext::new(
app_id.to_string(),
cmd_tx.clone(),
peer_id,
trust.clone(),
)
};
match event {
NetworkEvent::PeerDiscovered(peer) => {
for (app_id, app) in self.apps.iter_mut() {
let ctx = make_ctx(app_id);
if let Err(e) = app.on_peer_discovered(&ctx, peer).await {
warn!("App '{}' error on peer discovered: {}", app_id, e);
}
}
}
NetworkEvent::PeerDisconnected(peer) => {
for (app_id, app) in self.apps.iter_mut() {
let ctx = make_ctx(app_id);
if let Err(e) = app.on_peer_disconnected(&ctx, peer).await {
warn!("App '{}' error on peer disconnected: {}", app_id, e);
}
}
}
NetworkEvent::DirectMessage { from, message } => {
if let Ok(app_msg) = serde_json::from_slice::<AppMessage>(&message.payload) {
if let Some(app) = self.apps.get_mut(&app_msg.app_id) {
let ctx = make_ctx(&app_msg.app_id);
if let Err(e) = app.on_direct_message(&ctx, from, &app_msg.data).await {
warn!("App '{}' error on DM: {}", app_msg.app_id, e);
}
}
} else {
for (app_id, app) in self.apps.iter_mut() {
let ctx = make_ctx(app_id);
if let Err(e) = app.on_direct_message(&ctx, from, &message.payload).await {
warn!("App '{}' error on DM: {}", app_id, e);
}
}
}
}
NetworkEvent::GossipMessage { topic, from, data } => {
if let Ok(app_msg) = serde_json::from_slice::<AppMessage>(&data) {
if let Some(app) = self.apps.get_mut(&app_msg.app_id) {
let ctx = make_ctx(&app_msg.app_id);
if let Err(e) = app
.on_gossip_message(&ctx, &topic, from, &app_msg.data)
.await
{
warn!("App '{}' error on gossip: {}", app_msg.app_id, e);
}
}
} else {
for (app_id, app) in self.apps.iter_mut() {
let prefix = format!("tiwd/app/{}/", app_id);
if topic.starts_with(&prefix) || topic.contains(app_id.as_str()) {
let ctx = make_ctx(app_id);
if let Err(e) =
app.on_gossip_message(&ctx, &topic, from, &data).await
{
warn!("App '{}' error on gossip: {}", app_id, e);
}
}
}
}
}
NetworkEvent::DhtValueFound { key, value } => {
for (app_id, app) in self.apps.iter_mut() {
let prefix = format!("tiwd/app/{}/", app_id);
if key.starts_with(prefix.as_bytes()) {
let ctx = make_ctx(app_id);
if let Err(e) = app.on_dht_value_found(&ctx, &key, &value).await {
warn!("App '{}' error on DHT value: {}", app_id, e);
}
}
}
}
}
}
}

View File

@ -0,0 +1,16 @@
[package]
name = "tiwd-trust"
version = "0.1.0"
edition = "2021"
description = "Trustless reputation engine for TIWD"
[dependencies]
tiwd-core = { path = "../tiwd-core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
libp2p = { workspace = true }

View File

@ -0,0 +1,6 @@
pub mod score;
pub use score::{
FlagReason, FlagRecord, JuryVote, ModerationOutcome, ModerationVerdict, NodeRole,
TrustScore, Vote, VouchRecord,
};

View File

@ -0,0 +1,457 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// ── Trust Score Constants ──────────────────────────────────────────
/// Maximum points from time on the network.
const MAX_TIME_SCORE: f64 = 40.0;
/// Maximum points from vouches.
const MAX_VOUCH_SCORE: f64 = 35.0;
/// Maximum points from network activity.
const MAX_ACTIVITY_SCORE: f64 = 25.0;
/// Trust threshold to become a Guardian (can participate in moderation).
const GUARDIAN_THRESHOLD: f64 = 60.0;
/// Trust threshold to become an Elder (votes count 2x).
const ELDER_THRESHOLD: f64 = 80.0;
/// Multiplier for trust LOSS (trust is lost 10x faster than gained).
const TRUST_LOSS_MULTIPLIER: f64 = 10.0;
/// Maximum vouches a node can give per 30-day period.
const MAX_VOUCHES_PER_PERIOD: u32 = 5;
/// Trust cost to the voucher when vouching for someone.
const VOUCH_COST: f64 = 2.0;
/// Trust penalty to the voucher if their vouchee is banned.
const VOUCH_PENALTY_ON_BAN: f64 = 10.0;
/// Trust cost to file a flag against another node.
const FLAG_COST: f64 = 1.0;
/// Number of jurors for a standard moderation case.
const JURY_SIZE: usize = 7;
/// Number of jurors for an appeal.
const APPEAL_JURY_SIZE: usize = 11;
// ── Node Role ──────────────────────────────────────────────────────
/// The role a node holds on the network, determined solely by trust score.
/// Roles cannot be assigned, bought, or transferred — only earned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeRole {
/// Just joined. Cannot vouch, cannot moderate. Score < 20.
Newcomer,
/// Active participant. Can vouch for others. Score 20-59.
Member,
/// Trusted node. Can participate in moderation juries. Score 60-79.
Guardian,
/// Highly trusted. Votes count 2x in moderation. Score 80+.
Elder,
}
impl NodeRole {
pub fn from_score(score: f64) -> Self {
if score >= ELDER_THRESHOLD {
NodeRole::Elder
} else if score >= GUARDIAN_THRESHOLD {
NodeRole::Guardian
} else if score >= 20.0 {
NodeRole::Member
} else {
NodeRole::Newcomer
}
}
pub fn can_vouch(&self) -> bool {
matches!(self, NodeRole::Member | NodeRole::Guardian | NodeRole::Elder)
}
pub fn can_moderate(&self) -> bool {
matches!(self, NodeRole::Guardian | NodeRole::Elder)
}
pub fn vote_weight(&self) -> u32 {
match self {
NodeRole::Elder => 2,
NodeRole::Guardian => 1,
_ => 0,
}
}
}
// ── Trust Score ────────────────────────────────────────────────────
/// The complete trust profile for a node on the TIWD network.
/// This is computed locally by every node — no central authority.
/// Any node can independently verify any other node's trust score
/// by reading the signed records in the DHT.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustScore {
/// Points earned from continuous time on the network (0-40).
/// Grows logarithmically: 10 * log2(days + 1), capped at 40.
/// This is the dominant factor and CANNOT be purchased.
pub time_score: f64,
/// Points earned from vouches by other trusted nodes (0-35).
/// Each vouch is worth: voucher_trust * 0.1 (max 5 per vouch).
/// Vouching COSTS the voucher 2 points (skin in the game).
pub vouch_score: f64,
/// Points earned from network participation (0-25).
/// Based on consistent uptime, DHT routing, and message relay.
/// Measures CONSISTENCY, not volume (prevents gaming).
pub activity_score: f64,
/// Accumulated penalties from moderation actions.
pub penalties: f64,
/// When this node first appeared on the network.
pub first_seen: DateTime<Utc>,
/// Total days of active participation.
pub active_days: u64,
/// Number of vouches received.
pub vouches_received: u32,
/// Number of vouches given (rate-limited).
pub vouches_given_this_period: u32,
/// When the current vouch period started.
pub vouch_period_start: DateTime<Utc>,
}
impl TrustScore {
/// Create a trust profile for a brand new node.
/// Starts at zero — trust must be earned.
pub fn new() -> Self {
let now = Utc::now();
Self {
time_score: 0.0,
vouch_score: 0.0,
activity_score: 0.0,
penalties: 0.0,
first_seen: now,
active_days: 0,
vouches_received: 0,
vouches_given_this_period: 0,
vouch_period_start: now,
}
}
/// The total trust score (0-100, minus penalties).
pub fn total(&self) -> f64 {
let raw = self.time_score + self.vouch_score + self.activity_score;
(raw - self.penalties).max(0.0)
}
/// The node's current role based on trust score.
pub fn role(&self) -> NodeRole {
NodeRole::from_score(self.total())
}
/// Recalculate the time score based on active days.
/// Formula: 10 * log2(days + 1), capped at 40.
/// This gives:
/// Day 1: 10.0
/// Day 7: 30.0
/// Day 30: 34.9
/// Day 365: 38.7
/// Day 1000: 40.0 (cap)
pub fn recalculate_time_score(&mut self) {
let days = self.active_days as f64;
self.time_score = (10.0 * (days + 1.0).log2()).min(MAX_TIME_SCORE);
}
/// Record one more active day. Called once per day when the node
/// is online and participating.
pub fn record_active_day(&mut self) {
self.active_days += 1;
self.recalculate_time_score();
}
/// Receive a vouch from another node.
/// The value depends on the voucher's trust score.
pub fn receive_vouch(&mut self, voucher_trust: f64) -> f64 {
let vouch_value = (voucher_trust * 0.1).min(5.0);
self.vouch_score = (self.vouch_score + vouch_value).min(MAX_VOUCH_SCORE);
self.vouches_received += 1;
vouch_value
}
/// Check if this node can give a vouch (rate limited).
pub fn can_give_vouch(&self) -> bool {
if !self.role().can_vouch() {
return false;
}
let now = Utc::now();
let period_elapsed = now
.signed_duration_since(self.vouch_period_start)
.num_days();
if period_elapsed >= 30 {
// Period has reset
return true;
}
self.vouches_given_this_period < MAX_VOUCHES_PER_PERIOD
}
/// Record that this node gave a vouch. Costs trust points.
pub fn record_vouch_given(&mut self) {
let now = Utc::now();
let period_elapsed = now
.signed_duration_since(self.vouch_period_start)
.num_days();
if period_elapsed >= 30 {
self.vouches_given_this_period = 0;
self.vouch_period_start = now;
}
self.vouches_given_this_period += 1;
// Vouching costs trust — skin in the game
self.apply_penalty(VOUCH_COST);
}
/// Apply penalty when a vouchee gets banned.
/// The voucher suffers for vouching for a bad actor.
pub fn vouchee_banned(&mut self) {
self.apply_penalty(VOUCH_PENALTY_ON_BAN);
}
/// Update activity score based on network participation metrics.
/// Score is based on consistency (days active / days since joined)
/// and routing participation, NOT raw volume.
pub fn update_activity_score(&mut self, consistency_ratio: f64, routing_score: f64) {
// Consistency: 0.0 to 1.0 (fraction of days active since joining)
let consistency = consistency_ratio.clamp(0.0, 1.0);
// Routing: 0.0 to 1.0 (how well this node serves DHT requests)
let routing = routing_score.clamp(0.0, 1.0);
// Weight consistency more than raw routing
self.activity_score = ((consistency * 15.0) + (routing * 10.0)).min(MAX_ACTIVITY_SCORE);
}
/// Apply a trust penalty. Penalties are multiplied — trust is lost fast.
pub fn apply_penalty(&mut self, amount: f64) {
self.penalties += amount * TRUST_LOSS_MULTIPLIER;
}
/// Apply a direct (non-multiplied) penalty for minor costs like flagging.
pub fn apply_minor_penalty(&mut self, amount: f64) {
self.penalties += amount;
}
}
impl Default for TrustScore {
fn default() -> Self {
Self::new()
}
}
// ── Vouch Record ───────────────────────────────────────────────────
/// A signed vouch from one node for another.
/// Stored in the DHT, verifiable by anyone.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VouchRecord {
/// The node giving the vouch.
pub voucher: String,
/// The node receiving the vouch.
pub vouchee: String,
/// When the vouch was given.
pub timestamp: DateTime<Utc>,
/// When the vouch expires (must be renewed yearly).
pub expires: DateTime<Utc>,
/// The voucher's trust score at time of vouching.
pub voucher_trust_at_time: f64,
/// Cryptographic signature by the voucher.
pub signature: Vec<u8>,
}
// ── Flag Record ────────────────────────────────────────────────────
/// A flag raised against a node for bad behavior.
/// Costs the flagger 1 trust point to prevent spam.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlagRecord {
/// The node raising the flag.
pub flagger: String,
/// The node being flagged.
pub flagged: String,
/// Reason category.
pub reason: FlagReason,
/// Free-text evidence/description.
pub evidence: String,
/// When the flag was raised.
pub timestamp: DateTime<Utc>,
/// Signature by the flagger.
pub signature: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FlagReason {
Spam,
Harassment,
IllegalContent,
NetworkAbuse,
SybilAttack,
Other(String),
}
// ── Moderation Verdict ─────────────────────────────────────────────
/// The outcome of a moderation jury vote.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModerationVerdict {
/// The flagged node.
pub accused: String,
/// The jury members and their votes.
pub jury_votes: Vec<JuryVote>,
/// The verdict.
pub outcome: ModerationOutcome,
/// When the verdict was reached.
pub timestamp: DateTime<Utc>,
/// How many prior offenses this node has.
pub offense_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JuryVote {
pub juror: String,
pub vote: Vote,
pub signature: Vec<u8>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum Vote {
Guilty,
NotGuilty,
Abstain,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ModerationOutcome {
/// Not enough evidence. Flagger may lose trust for frivolous flag.
Acquitted,
/// 24-hour mute (1st offense).
Muted { until: DateTime<Utc> },
/// 7-day suspension (2nd offense).
Suspended { until: DateTime<Utc> },
/// 30-day ban (3rd offense).
Banned { until: DateTime<Utc> },
/// Permanent ban (4th+ offense or extreme content).
PermanentBan,
}
impl ModerationOutcome {
/// Determine punishment based on offense count.
pub fn for_offense(count: u32) -> Self {
let now = Utc::now();
match count {
0 | 1 => ModerationOutcome::Muted {
until: now + chrono::Duration::hours(24),
},
2 => ModerationOutcome::Suspended {
until: now + chrono::Duration::days(7),
},
3 => ModerationOutcome::Banned {
until: now + chrono::Duration::days(30),
},
_ => ModerationOutcome::PermanentBan,
}
}
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_node_starts_at_zero() {
let score = TrustScore::new();
assert_eq!(score.total(), 0.0);
assert_eq!(score.role(), NodeRole::Newcomer);
}
#[test]
fn time_score_grows_logarithmically() {
let mut score = TrustScore::new();
// Day 1
score.active_days = 1;
score.recalculate_time_score();
assert!((score.time_score - 10.0).abs() < 0.01);
// Day 7
score.active_days = 7;
score.recalculate_time_score();
assert!((score.time_score - 30.0).abs() < 0.01);
// Day 1000 — should cap at 40
score.active_days = 1000;
score.recalculate_time_score();
assert_eq!(score.time_score, MAX_TIME_SCORE);
}
#[test]
fn vouch_score_capped() {
let mut score = TrustScore::new();
// Receive many vouches from highly trusted nodes
for _ in 0..100 {
score.receive_vouch(100.0);
}
assert!(score.vouch_score <= MAX_VOUCH_SCORE);
}
#[test]
fn trust_lost_faster_than_gained() {
let mut score = TrustScore::new();
score.active_days = 30;
score.recalculate_time_score();
let before = score.total();
// One penalty wipes a lot of trust
score.apply_penalty(3.0); // 3 * 10x = 30 points lost
assert!(score.total() < before - 25.0);
}
#[test]
fn newcomer_cannot_vouch() {
let score = TrustScore::new();
assert!(!score.role().can_vouch());
}
#[test]
fn guardian_can_moderate() {
let mut score = TrustScore::new();
score.active_days = 365;
score.recalculate_time_score();
score.vouch_score = 20.0;
score.activity_score = 10.0;
// Total ~69 — Guardian level
assert!(score.role().can_moderate());
}
#[test]
fn sybil_attack_worthless() {
// 1000 brand new nodes = 1000 * 0 trust = 0 power
let accounts: Vec<TrustScore> = (0..1000).map(|_| TrustScore::new()).collect();
let total_power: f64 = accounts.iter().map(|a| a.total()).sum();
assert_eq!(total_power, 0.0);
}
#[test]
fn graduated_punishment() {
assert!(matches!(
ModerationOutcome::for_offense(1),
ModerationOutcome::Muted { .. }
));
assert!(matches!(
ModerationOutcome::for_offense(2),
ModerationOutcome::Suspended { .. }
));
assert!(matches!(
ModerationOutcome::for_offense(3),
ModerationOutcome::Banned { .. }
));
assert!(matches!(
ModerationOutcome::for_offense(4),
ModerationOutcome::PermanentBan
));
}
}

BIN
first-message.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

9
run-node.bat Normal file
View File

@ -0,0 +1,9 @@
@echo off
set TIWD_DATA_DIR=C:\Users\Admin\tiwd-local
set TIWD_PORT=9004
set TIWD_BOOTSTRAP=/ip4/185.193.125.79/tcp/9000
set TIWD_NAME=thefounder
set RUST_LOG=warn
cd C:\Users\Admin\tiwd
target\debug\tiwd-node.exe
pause