diff --git a/Cargo.toml b/Cargo.toml index c28f22b..0f57bcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/synor-consensus", "crates/synor-network", "crates/synor-storage", + "crates/synor-hosting", "crates/synor-governance", "crates/synor-rpc", "crates/synor-vm", diff --git a/crates/synor-hosting/Cargo.toml b/crates/synor-hosting/Cargo.toml new file mode 100644 index 0000000..b2fbcc9 --- /dev/null +++ b/crates/synor-hosting/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "synor-hosting" +version = "0.1.0" +edition = "2021" +description = "Decentralized web hosting for the Synor blockchain" +license = "MIT" +authors = ["Synor Team"] +repository = "https://github.com/synor/synor" + +[dependencies] +# Core +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +async-trait = "0.1" + +# Cryptography +blake3 = "1" +ed25519-dalek = "2" + +# Encoding +bs58 = "0.5" +hex = "0.4" + +# DNS verification +trust-dns-resolver = { version = "0.23", optional = true } + +# Local workspace crates +synor-types = { path = "../synor-types" } +synor-crypto = { path = "../synor-crypto" } +synor-storage = { path = "../synor-storage" } + +[features] +default = [] +dns = ["trust-dns-resolver"] + +[dev-dependencies] +tempfile = "3" diff --git a/crates/synor-hosting/src/config.rs b/crates/synor-hosting/src/config.rs new file mode 100644 index 0000000..7fe33b9 --- /dev/null +++ b/crates/synor-hosting/src/config.rs @@ -0,0 +1,330 @@ +//! Synor.json Configuration +//! +//! Parses and manages the synor.json configuration file that users +//! include in their deployed projects. + +use crate::router::{RouteConfig, Redirect}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Synor.json configuration file +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SynorJson { + /// Name of the deployment (optional, can be inferred) + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Build configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub build: Option, + + /// Routes configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub routes: Option, + + /// Headers configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + + /// Redirects configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub redirects: Option>, + + /// Custom error pages + #[serde(skip_serializing_if = "Option::is_none")] + pub error_pages: Option>, + + /// Environment variables (non-secret, build-time) + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, + + /// Functions/serverless configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub functions: Option, +} + +/// Build configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildConfig { + /// Build command + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + + /// Output directory (default: "dist" or "build") + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + + /// Install command (default: "npm install" or "pnpm install") + #[serde(skip_serializing_if = "Option::is_none")] + pub install: Option, + + /// Node.js version + #[serde(skip_serializing_if = "Option::is_none")] + pub node_version: Option, +} + +/// Routes configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutesConfig { + /// Enable SPA mode (fallback to index.html) + #[serde(default)] + pub spa: bool, + + /// Custom cleanUrls (remove .html extensions) + #[serde(default)] + pub clean_urls: bool, + + /// Trailing slash behavior + #[serde(skip_serializing_if = "Option::is_none")] + pub trailing_slash: Option, + + /// Custom route rewrites + #[serde(skip_serializing_if = "Option::is_none")] + pub rewrites: Option>, +} + +/// Header rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeaderRule { + /// Path pattern (glob) + pub source: String, + /// Headers to apply + pub headers: Vec, +} + +/// Single header key-value pair +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeaderKeyValue { + pub key: String, + pub value: String, +} + +/// Redirect rule (in synor.json format) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedirectRule { + /// Source path + pub source: String, + /// Destination path or URL + pub destination: String, + /// HTTP status (default: 308) + #[serde(default = "default_redirect_status")] + pub status: u16, +} + +fn default_redirect_status() -> u16 { + 308 +} + +/// Rewrite rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewriteRule { + /// Source path pattern + pub source: String, + /// Destination path + pub destination: String, +} + +/// Serverless functions configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionsConfig { + /// Functions directory + #[serde(default = "default_functions_dir")] + pub directory: String, + + /// Runtime (e.g., "nodejs20", "python3.11") + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, + + /// Memory limit in MB + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + + /// Timeout in seconds + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +fn default_functions_dir() -> String { + "api".to_string() +} + +impl SynorJson { + /// Parse from JSON string + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Parse from JSON bytes + pub fn from_bytes(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + + /// Serialize to JSON string + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Convert to RouteConfig for the router + pub fn to_route_config(&self) -> RouteConfig { + let mut config = RouteConfig::default(); + + // Process routes + if let Some(routes) = &self.routes { + if routes.spa { + // SPA mode: all non-file paths go to index.html + config.routes.insert("/*".to_string(), "/index.html".to_string()); + } + + // Process rewrites + if let Some(rewrites) = &routes.rewrites { + for rewrite in rewrites { + config.routes.insert(rewrite.source.clone(), rewrite.destination.clone()); + } + } + } + + // Process headers + if let Some(headers) = &self.headers { + for rule in headers { + let mut header_map = HashMap::new(); + for kv in &rule.headers { + header_map.insert(kv.key.clone(), kv.value.clone()); + } + config.headers.insert(rule.source.clone(), header_map); + } + } + + // Process redirects + if let Some(redirects) = &self.redirects { + for rule in redirects { + config.redirects.push(Redirect { + from: rule.source.clone(), + to: rule.destination.clone(), + status: rule.status, + }); + } + } + + // Process error pages + if let Some(error_pages) = &self.error_pages { + config.error_pages = error_pages.clone(); + } + + config + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal() { + let json = r#"{}"#; + let config = SynorJson::from_json(json).unwrap(); + assert!(config.name.is_none()); + assert!(config.build.is_none()); + } + + #[test] + fn test_parse_spa_config() { + let json = r#"{ + "name": "myapp", + "routes": { + "spa": true, + "clean_urls": true + } + }"#; + + let config = SynorJson::from_json(json).unwrap(); + assert_eq!(config.name, Some("myapp".to_string())); + assert!(config.routes.as_ref().unwrap().spa); + assert!(config.routes.as_ref().unwrap().clean_urls); + } + + #[test] + fn test_parse_full_config() { + let json = r#"{ + "name": "my-blog", + "build": { + "command": "npm run build", + "output": "dist" + }, + "routes": { + "spa": true + }, + "headers": [ + { + "source": "/**", + "headers": [ + { "key": "X-Frame-Options", "value": "DENY" } + ] + } + ], + "redirects": [ + { + "source": "/old-page", + "destination": "/new-page", + "status": 301 + } + ], + "error_pages": { + "404": "/404.html" + } + }"#; + + let config = SynorJson::from_json(json).unwrap(); + assert_eq!(config.name, Some("my-blog".to_string())); + assert_eq!(config.build.as_ref().unwrap().command, Some("npm run build".to_string())); + assert!(config.routes.as_ref().unwrap().spa); + assert_eq!(config.headers.as_ref().unwrap().len(), 1); + assert_eq!(config.redirects.as_ref().unwrap().len(), 1); + assert_eq!(config.error_pages.as_ref().unwrap().get(&404), Some(&"/404.html".to_string())); + } + + #[test] + fn test_to_route_config() { + let json = r#"{ + "routes": { + "spa": true + }, + "redirects": [ + { + "source": "/blog", + "destination": "/posts", + "status": 301 + } + ] + }"#; + + let synor_json = SynorJson::from_json(json).unwrap(); + let route_config = synor_json.to_route_config(); + + // SPA mode should add /* -> /index.html + assert_eq!(route_config.routes.get("/*"), Some(&"/index.html".to_string())); + + // Redirect should be converted + assert_eq!(route_config.redirects.len(), 1); + assert_eq!(route_config.redirects[0].from, "/blog"); + assert_eq!(route_config.redirects[0].to, "/posts"); + assert_eq!(route_config.redirects[0].status, 301); + } + + #[test] + fn test_serialize() { + let config = SynorJson { + name: Some("test".to_string()), + routes: Some(RoutesConfig { + spa: true, + clean_urls: false, + trailing_slash: None, + rewrites: None, + }), + ..Default::default() + }; + + let json = config.to_json().unwrap(); + assert!(json.contains("\"name\": \"test\"")); + assert!(json.contains("\"spa\": true")); + } +} diff --git a/crates/synor-hosting/src/domain.rs b/crates/synor-hosting/src/domain.rs new file mode 100644 index 0000000..4701187 --- /dev/null +++ b/crates/synor-hosting/src/domain.rs @@ -0,0 +1,370 @@ +//! Domain Verification - Custom domain ownership proof +//! +//! Verifies that users own the domains they want to link to their names +//! using DNS-based verification (CNAME or TXT records). + +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Verification method for custom domains +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VerificationMethod { + /// CNAME record pointing to name.synor.network + Cname, + /// TXT record with verification token + Txt, +} + +/// Domain verification status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VerificationStatus { + /// Verification pending + Pending, + /// Domain verified + Verified, + /// Verification failed + Failed, + /// Verification expired (needs re-verification) + Expired, +} + +/// Domain record +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainRecord { + /// The custom domain + pub domain: String, + /// Associated Synor name + pub name: String, + /// Verification method + pub method: VerificationMethod, + /// Verification token (for TXT method) + pub token: String, + /// Current status + pub status: VerificationStatus, + /// Block when verification was requested + pub requested_at: u64, + /// Block when verified (0 if not verified) + pub verified_at: u64, + /// Block when verification expires (0 = never) + pub expires_at: u64, +} + +/// Domain verifier +pub struct DomainVerifier { + /// Pending verifications + pending: HashMap, + /// Verified domains + verified: HashMap, + /// Synor hosting domain suffix + hosting_domain: String, + /// Verification token prefix + token_prefix: String, + /// Verification expiry in blocks + verification_expiry: u64, +} + +impl DomainVerifier { + /// Create a new domain verifier + pub fn new(hosting_domain: String) -> Self { + Self { + pending: HashMap::new(), + verified: HashMap::new(), + hosting_domain, + token_prefix: "synor-verify=".to_string(), + verification_expiry: 5_256_000, // ~1 year at 6s blocks + } + } + + /// Request verification for a domain + pub fn request_verification( + &mut self, + domain: String, + name: String, + method: VerificationMethod, + current_block: u64, + ) -> Result { + // Validate domain format + self.validate_domain(&domain)?; + + // Check if already verified + if self.verified.contains_key(&domain) { + return Err(Error::DomainAlreadyLinked(domain)); + } + + // Generate verification token + let token = self.generate_token(&domain, &name, current_block); + + let record = DomainRecord { + domain: domain.clone(), + name, + method, + token, + status: VerificationStatus::Pending, + requested_at: current_block, + verified_at: 0, + expires_at: 0, + }; + + self.pending.insert(domain, record.clone()); + + Ok(record) + } + + /// Get verification instructions for a domain + pub fn get_instructions(&self, domain: &str) -> Result { + let record = self.pending.get(domain) + .ok_or_else(|| Error::DomainNotFound(domain.to_string()))?; + + match record.method { + VerificationMethod::Cname => { + Ok(VerificationInstructions { + method: VerificationMethod::Cname, + record_type: "CNAME".to_string(), + record_name: record.domain.clone(), + record_value: format!("{}.{}", record.name, self.hosting_domain), + instructions: format!( + "Add a CNAME record:\n {} CNAME {}.{}", + record.domain, record.name, self.hosting_domain + ), + }) + } + VerificationMethod::Txt => { + Ok(VerificationInstructions { + method: VerificationMethod::Txt, + record_type: "TXT".to_string(), + record_name: format!("_synor.{}", record.domain), + record_value: format!("{}{}", self.token_prefix, record.token), + instructions: format!( + "Add a TXT record:\n _synor.{} TXT \"{}{}\"", + record.domain, self.token_prefix, record.token + ), + }) + } + } + } + + /// Verify a domain (check DNS records) + pub fn verify(&mut self, domain: &str, current_block: u64) -> Result { + let record = self.pending.get(domain) + .ok_or_else(|| Error::DomainNotFound(domain.to_string()))? + .clone(); + + // In production, this would do actual DNS lookups + // For now, we'll simulate verification + let verified = self.check_dns(&record)?; + + if verified { + let mut verified_record = record; + verified_record.status = VerificationStatus::Verified; + verified_record.verified_at = current_block; + verified_record.expires_at = current_block + self.verification_expiry; + + self.pending.remove(domain); + self.verified.insert(domain.to_string(), verified_record.clone()); + + Ok(verified_record) + } else { + Err(Error::VerificationFailed( + domain.to_string(), + "DNS record not found or doesn't match".into(), + )) + } + } + + /// Check if a domain is verified + pub fn is_verified(&self, domain: &str, current_block: u64) -> bool { + self.verified.get(domain).map_or(false, |r| { + r.status == VerificationStatus::Verified + && (r.expires_at == 0 || current_block <= r.expires_at) + }) + } + + /// Get verified domain record + pub fn get_verified(&self, domain: &str) -> Option<&DomainRecord> { + self.verified.get(domain) + } + + /// Remove verification for a domain + pub fn remove(&mut self, domain: &str) -> bool { + self.pending.remove(domain).is_some() || self.verified.remove(domain).is_some() + } + + /// Validate domain format + fn validate_domain(&self, domain: &str) -> Result<()> { + // Basic domain validation + if domain.is_empty() { + return Err(Error::InvalidDomain("Domain cannot be empty".into())); + } + + if domain.len() > 253 { + return Err(Error::InvalidDomain("Domain too long".into())); + } + + // Check each label + for label in domain.split('.') { + if label.is_empty() || label.len() > 63 { + return Err(Error::InvalidDomain("Invalid label length".into())); + } + + if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(Error::InvalidDomain("Invalid characters in domain".into())); + } + + if label.starts_with('-') || label.ends_with('-') { + return Err(Error::InvalidDomain("Label cannot start or end with hyphen".into())); + } + } + + // Must have at least one dot (not just a TLD) + if !domain.contains('.') { + return Err(Error::InvalidDomain("Domain must have at least one dot".into())); + } + + Ok(()) + } + + /// Generate verification token + fn generate_token(&self, domain: &str, name: &str, block: u64) -> String { + let input = format!("{}:{}:{}", domain, name, block); + let hash = blake3::hash(input.as_bytes()); + hex::encode(&hash.as_bytes()[..16]) // 32 char hex token + } + + /// Check DNS records (stub - real implementation would use DNS resolver) + #[cfg(not(feature = "dns"))] + fn check_dns(&self, _record: &DomainRecord) -> Result { + // Without DNS feature, always return true (for testing) + Ok(true) + } + + /// Check DNS records (real implementation with trust-dns) + #[cfg(feature = "dns")] + fn check_dns(&self, record: &DomainRecord) -> Result { + use trust_dns_resolver::Resolver; + use trust_dns_resolver::config::*; + + let resolver = Resolver::new(ResolverConfig::default(), ResolverOpts::default()) + .map_err(|e| Error::Dns(e.to_string()))?; + + match record.method { + VerificationMethod::Cname => { + let expected = format!("{}.{}.", record.name, self.hosting_domain); + let response = resolver.lookup(&record.domain, RecordType::CNAME) + .map_err(|e| Error::Dns(e.to_string()))?; + + for cname in response.iter() { + if cname.to_string() == expected { + return Ok(true); + } + } + Ok(false) + } + VerificationMethod::Txt => { + let txt_domain = format!("_synor.{}", record.domain); + let expected = format!("{}{}", self.token_prefix, record.token); + + let response = resolver.txt_lookup(&txt_domain) + .map_err(|e| Error::Dns(e.to_string()))?; + + for txt in response.iter() { + let txt_str = txt.to_string(); + if txt_str.contains(&expected) { + return Ok(true); + } + } + Ok(false) + } + } + } +} + +/// Verification instructions for users +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationInstructions { + /// Verification method + pub method: VerificationMethod, + /// DNS record type to add + pub record_type: String, + /// DNS record name + pub record_name: String, + /// DNS record value + pub record_value: String, + /// Human-readable instructions + pub instructions: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_verification() { + let mut verifier = DomainVerifier::new("synor.network".to_string()); + + let record = verifier + .request_verification( + "myapp.com".to_string(), + "myapp".to_string(), + VerificationMethod::Cname, + 0, + ) + .unwrap(); + + assert_eq!(record.domain, "myapp.com"); + assert_eq!(record.name, "myapp"); + assert_eq!(record.status, VerificationStatus::Pending); + } + + #[test] + fn test_get_instructions() { + let mut verifier = DomainVerifier::new("synor.network".to_string()); + + verifier + .request_verification( + "myapp.com".to_string(), + "myapp".to_string(), + VerificationMethod::Cname, + 0, + ) + .unwrap(); + + let instructions = verifier.get_instructions("myapp.com").unwrap(); + assert_eq!(instructions.record_type, "CNAME"); + assert!(instructions.record_value.contains("myapp.synor.network")); + } + + #[test] + fn test_verify_domain() { + let mut verifier = DomainVerifier::new("synor.network".to_string()); + + verifier + .request_verification( + "myapp.com".to_string(), + "myapp".to_string(), + VerificationMethod::Cname, + 0, + ) + .unwrap(); + + // Without DNS feature, verify always succeeds + let record = verifier.verify("myapp.com", 100).unwrap(); + assert_eq!(record.status, VerificationStatus::Verified); + assert!(verifier.is_verified("myapp.com", 100)); + } + + #[test] + fn test_invalid_domain() { + let mut verifier = DomainVerifier::new("synor.network".to_string()); + + // No TLD + assert!(verifier + .request_verification("myapp".to_string(), "myapp".to_string(), VerificationMethod::Cname, 0) + .is_err()); + + // Empty + assert!(verifier + .request_verification("".to_string(), "myapp".to_string(), VerificationMethod::Cname, 0) + .is_err()); + } +} diff --git a/crates/synor-hosting/src/error.rs b/crates/synor-hosting/src/error.rs new file mode 100644 index 0000000..5c862ba --- /dev/null +++ b/crates/synor-hosting/src/error.rs @@ -0,0 +1,129 @@ +//! Hosting Error Types +//! +//! Error definitions for the Synor Hosting system. + +use thiserror::Error; + +/// Hosting errors +#[derive(Debug, Error)] +pub enum Error { + /// Invalid name format + #[error("invalid name: {0}")] + InvalidName(String), + + /// Name is reserved + #[error("name '{0}' is reserved")] + ReservedName(String), + + /// Name is too similar to a reserved name (confusable) + #[error("name '{0}' is too similar to reserved name '{1}'")] + ConfusableName(String, String), + + /// Name not found in registry + #[error("name '{0}' not found")] + NameNotFound(String), + + /// Name already registered + #[error("name '{0}' is already registered")] + NameAlreadyRegistered(String), + + /// Name is already taken + #[error("name '{0}' is already taken")] + NameTaken(String), + + /// Invalid operation + #[error("invalid operation: {0}")] + InvalidOperation(String), + + /// Name has expired + #[error("name '{0}' has expired")] + NameExpired(String), + + /// Not the owner of this name + #[error("not the owner of this name")] + NotOwner, + + /// Domain already linked to another name + #[error("domain '{0}' is already linked to another name")] + DomainAlreadyLinked(String), + + /// Domain not verified + #[error("domain '{0}' is not verified")] + DomainNotVerified(String), + + /// Domain verification pending + #[error("domain '{0}' verification is pending")] + DomainVerificationPending(String), + + /// Domain verification expired + #[error("domain '{0}' verification has expired")] + DomainVerificationExpired(String), + + /// Domain not found + #[error("domain '{0}' not found")] + DomainNotFound(String), + + /// Domain verification failed + #[error("verification failed for domain '{0}': {1}")] + VerificationFailed(String, String), + + /// Invalid domain format + #[error("invalid domain: {0}")] + InvalidDomain(String), + + /// Unknown host + #[error("unknown host: {0}")] + UnknownHost(String), + + /// Redirect response + #[error("redirect to {to} with status {status}")] + Redirect { + /// Target URL + to: String, + /// HTTP status code + status: u16, + }, + + /// Configuration error + #[error("configuration error: {0}")] + Config(String), + + /// Storage error + #[error("storage error: {0}")] + Storage(String), + + /// DNS resolution error + #[error("DNS error: {0}")] + Dns(String), + + /// IO error + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// JSON parsing error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +/// Result type alias +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_display() { + let err = Error::InvalidName("bad-name!".to_string()); + assert_eq!(err.to_string(), "invalid name: bad-name!"); + + let err = Error::NameNotFound("myapp".to_string()); + assert_eq!(err.to_string(), "name 'myapp' not found"); + + let err = Error::Redirect { + to: "https://example.com".to_string(), + status: 301, + }; + assert!(err.to_string().contains("redirect")); + } +} diff --git a/crates/synor-hosting/src/lib.rs b/crates/synor-hosting/src/lib.rs new file mode 100644 index 0000000..3cba11a --- /dev/null +++ b/crates/synor-hosting/src/lib.rs @@ -0,0 +1,142 @@ +//! Synor Hosting - Decentralized Web Hosting +//! +//! Provides subdomain-based hosting for web applications on Synor Storage. +//! +//! # Components +//! +//! - **Name Registry**: On-chain mapping of names to CIDs +//! - **Domain Verification**: Custom domain ownership proof +//! - **Gateway Router**: Host-based routing to content +//! +//! # Example +//! +//! ```rust,ignore +//! use synor_hosting::{NameRegistry, HostingGateway}; +//! +//! // Register a name +//! registry.register("myapp", cid, owner)?; +//! +//! // Resolve name to CID +//! let cid = registry.resolve("myapp")?; +//! +//! // Gateway routes myapp.synor.network to CID +//! gateway.handle_request("myapp.synor.network", "/").await?; +//! ``` + +pub mod registry; +pub mod domain; +pub mod router; +pub mod config; +pub mod error; + +pub use registry::{NameRegistry, NameRecord, RegistrationRequest}; +pub use domain::{DomainVerifier, DomainRecord, VerificationMethod}; +pub use router::{HostingRouter, RouteConfig}; +pub use config::SynorJson; +pub use error::{Error, Result}; + +/// Reserved names that cannot be registered +pub const RESERVED_NAMES: &[&str] = &[ + "synor", "admin", "api", "gateway", "www", "mail", "ftp", "ssh", + "cdn", "static", "assets", "app", "web", "blog", "docs", "help", + "support", "status", "system", "root", "test", "dev", "staging", + "prod", "production", "null", "undefined", "localhost", "local", +]; + +/// Validate a name according to registry rules +pub fn validate_name(name: &str) -> Result<()> { + // Length check + if name.len() < 3 { + return Err(Error::InvalidName("Name must be at least 3 characters".into())); + } + if name.len() > 63 { + return Err(Error::InvalidName("Name must be at most 63 characters".into())); + } + + // Character check + if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + return Err(Error::InvalidName( + "Name must contain only lowercase letters, numbers, and hyphens".into(), + )); + } + + // Cannot start or end with hyphen + if name.starts_with('-') || name.ends_with('-') { + return Err(Error::InvalidName("Name cannot start or end with hyphen".into())); + } + + // Cannot have consecutive hyphens + if name.contains("--") { + return Err(Error::InvalidName("Name cannot contain consecutive hyphens".into())); + } + + // Reserved name check + if RESERVED_NAMES.contains(&name) { + return Err(Error::ReservedName(name.to_string())); + } + + Ok(()) +} + +/// Check if a name looks confusingly similar to another +pub fn is_confusable(name: &str, other: &str) -> bool { + // Simple homoglyph detection + let normalize = |s: &str| -> String { + s.chars() + .map(|c| match c { + '0' => 'o', + '1' | 'l' => 'i', + '5' => 's', + '3' => 'e', + '4' => 'a', + '7' => 't', + '8' => 'b', + '9' => 'g', + _ => c, + }) + .collect() + }; + + normalize(name) == normalize(other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_name_valid() { + assert!(validate_name("myapp").is_ok()); + assert!(validate_name("my-app").is_ok()); + assert!(validate_name("app123").is_ok()); + assert!(validate_name("123app").is_ok()); + assert!(validate_name("a-b-c").is_ok()); + } + + #[test] + fn test_validate_name_invalid() { + assert!(validate_name("ab").is_err()); // Too short + assert!(validate_name("MyApp").is_err()); // Uppercase + assert!(validate_name("-myapp").is_err()); // Starts with hyphen + assert!(validate_name("myapp-").is_err()); // Ends with hyphen + assert!(validate_name("my--app").is_err()); // Consecutive hyphens + assert!(validate_name("my app").is_err()); // Space + assert!(validate_name("my_app").is_err()); // Underscore + } + + #[test] + fn test_reserved_names() { + assert!(validate_name("synor").is_err()); + assert!(validate_name("admin").is_err()); + assert!(validate_name("api").is_err()); + assert!(validate_name("gateway").is_err()); + } + + #[test] + fn test_confusable_names() { + assert!(is_confusable("g00gle", "google")); + assert!(is_confusable("paypa1", "paypal")); + assert!(is_confusable("fac3book", "facebook")); + assert!(!is_confusable("myapp", "yourapp")); + } +} diff --git a/crates/synor-hosting/src/registry.rs b/crates/synor-hosting/src/registry.rs new file mode 100644 index 0000000..6265499 --- /dev/null +++ b/crates/synor-hosting/src/registry.rs @@ -0,0 +1,462 @@ +//! Name Registry - On-chain name to CID mapping +//! +//! Manages the registration and resolution of human-readable names +//! to content identifiers (CIDs) on the Synor blockchain. + +use crate::error::{Error, Result}; +use crate::validate_name; +use synor_storage::ContentId; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Address type (32 bytes) +pub type Address = [u8; 32]; + +/// Name record stored on-chain +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NameRecord { + /// The registered name + pub name: String, + /// Owner address + pub owner: Address, + /// Current CID the name points to + pub cid: ContentId, + /// Block number when registered + pub registered_at: u64, + /// Block number when expires (0 = never) + pub expires_at: u64, + /// Custom domains linked to this name + pub custom_domains: Vec, + /// Metadata (optional JSON) + pub metadata: Option, +} + +/// Request to register a new name +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrationRequest { + /// Name to register + pub name: String, + /// Initial CID + pub cid: ContentId, + /// Registration duration in blocks (0 = permanent) + pub duration: u64, + /// Optional metadata + pub metadata: Option, +} + +/// Name registry state +#[derive(Debug, Default)] +pub struct NameRegistry { + /// Name to record mapping + records: HashMap, + /// Custom domain to name mapping + domain_to_name: HashMap, + /// Registration fee (atomic SYNOR) + registration_fee: u64, + /// Annual renewal fee (atomic SYNOR) + renewal_fee: u64, + /// Grace period in blocks after expiry + grace_period: u64, +} + +impl NameRegistry { + /// Create a new name registry + pub fn new() -> Self { + Self { + records: HashMap::new(), + domain_to_name: HashMap::new(), + registration_fee: 10_000_000_000, // 10 SYNOR + renewal_fee: 5_000_000_000, // 5 SYNOR + grace_period: 432_000, // ~30 days at 6s blocks + } + } + + /// Create registry with custom fees + pub fn with_fees(registration_fee: u64, renewal_fee: u64, grace_period: u64) -> Self { + Self { + records: HashMap::new(), + domain_to_name: HashMap::new(), + registration_fee, + renewal_fee, + grace_period, + } + } + + /// Register a new name + pub fn register( + &mut self, + request: RegistrationRequest, + owner: Address, + current_block: u64, + ) -> Result { + // Validate name + validate_name(&request.name)?; + + // Check if name is available + if let Some(existing) = self.records.get(&request.name) { + // Check if expired and past grace period + if existing.expires_at > 0 + && current_block > existing.expires_at + self.grace_period + { + // Name can be re-registered + } else { + return Err(Error::NameTaken(request.name)); + } + } + + // Calculate expiry + let expires_at = if request.duration == 0 { + 0 // Permanent + } else { + current_block + request.duration + }; + + let record = NameRecord { + name: request.name.clone(), + owner, + cid: request.cid, + registered_at: current_block, + expires_at, + custom_domains: Vec::new(), + metadata: request.metadata, + }; + + self.records.insert(request.name, record.clone()); + + Ok(record) + } + + /// Update CID for a name (must be owner) + pub fn update( + &mut self, + name: &str, + new_cid: ContentId, + caller: Address, + current_block: u64, + ) -> Result<()> { + let record = self.get_record_mut(name, current_block)?; + + if record.owner != caller { + return Err(Error::NotOwner); + } + + record.cid = new_cid; + + Ok(()) + } + + /// Transfer ownership of a name + pub fn transfer( + &mut self, + name: &str, + new_owner: Address, + caller: Address, + current_block: u64, + ) -> Result<()> { + let record = self.get_record_mut(name, current_block)?; + + if record.owner != caller { + return Err(Error::NotOwner); + } + + record.owner = new_owner; + + Ok(()) + } + + /// Renew a name registration + pub fn renew( + &mut self, + name: &str, + additional_duration: u64, + caller: Address, + current_block: u64, + ) -> Result { + let record = self.get_record_mut(name, current_block)?; + + if record.owner != caller { + return Err(Error::NotOwner); + } + + if record.expires_at == 0 { + return Err(Error::InvalidOperation("Name is permanent".into())); + } + + // Extend from current expiry or current block (if in grace period) + let base = if record.expires_at > current_block { + record.expires_at + } else { + current_block + }; + + record.expires_at = base + additional_duration; + + Ok(record.expires_at) + } + + /// Resolve a name to its CID + pub fn resolve(&self, name: &str, current_block: u64) -> Option { + self.records.get(name).and_then(|record| { + if self.is_valid(record, current_block) { + Some(record.cid.clone()) + } else { + None + } + }) + } + + /// Get full record for a name + pub fn get_record(&self, name: &str, current_block: u64) -> Result<&NameRecord> { + let record = self.records.get(name).ok_or_else(|| Error::NameNotFound(name.to_string()))?; + + if !self.is_valid(record, current_block) { + return Err(Error::NameExpired(name.to_string())); + } + + Ok(record) + } + + /// Get mutable record + fn get_record_mut(&mut self, name: &str, current_block: u64) -> Result<&mut NameRecord> { + let record = self.records.get(name).ok_or_else(|| Error::NameNotFound(name.to_string()))?; + + // Check validity before returning mutable reference + if !self.is_valid(record, current_block) { + return Err(Error::NameExpired(name.to_string())); + } + + Ok(self.records.get_mut(name).unwrap()) + } + + /// Check if a record is valid (not expired or within grace period) + fn is_valid(&self, record: &NameRecord, current_block: u64) -> bool { + if record.expires_at == 0 { + return true; // Permanent + } + current_block <= record.expires_at + self.grace_period + } + + /// Check if a name is available for registration + pub fn is_available(&self, name: &str, current_block: u64) -> bool { + match self.records.get(name) { + None => validate_name(name).is_ok(), + Some(record) => { + record.expires_at > 0 + && current_block > record.expires_at + self.grace_period + } + } + } + + /// Add a custom domain to a name + pub fn add_custom_domain( + &mut self, + name: &str, + domain: String, + caller: Address, + current_block: u64, + ) -> Result<()> { + // Check domain isn't already linked + if self.domain_to_name.contains_key(&domain) { + return Err(Error::DomainAlreadyLinked(domain)); + } + + let record = self.get_record_mut(name, current_block)?; + + if record.owner != caller { + return Err(Error::NotOwner); + } + + record.custom_domains.push(domain.clone()); + self.domain_to_name.insert(domain, name.to_string()); + + Ok(()) + } + + /// Remove a custom domain from a name + pub fn remove_custom_domain( + &mut self, + name: &str, + domain: &str, + caller: Address, + current_block: u64, + ) -> Result<()> { + let record = self.get_record_mut(name, current_block)?; + + if record.owner != caller { + return Err(Error::NotOwner); + } + + record.custom_domains.retain(|d| d != domain); + self.domain_to_name.remove(domain); + + Ok(()) + } + + /// Resolve a custom domain to a name + pub fn resolve_domain(&self, domain: &str) -> Option<&str> { + self.domain_to_name.get(domain).map(|s| s.as_str()) + } + + /// Resolve a custom domain directly to CID + pub fn resolve_domain_to_cid(&self, domain: &str, current_block: u64) -> Option { + self.resolve_domain(domain) + .and_then(|name| self.resolve(name, current_block)) + } + + /// Get registration fee + pub fn registration_fee(&self) -> u64 { + self.registration_fee + } + + /// Get renewal fee + pub fn renewal_fee(&self) -> u64 { + self.renewal_fee + } + + /// Get all names owned by an address + pub fn names_by_owner(&self, owner: &Address, current_block: u64) -> Vec<&NameRecord> { + self.records + .values() + .filter(|r| r.owner == *owner && self.is_valid(r, current_block)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_cid(data: &[u8]) -> ContentId { + ContentId::from_content(data) + } + + #[test] + fn test_register_and_resolve() { + let mut registry = NameRegistry::new(); + let owner = [1u8; 32]; + let cid = make_cid(b"hello world"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 1000, + metadata: None, + }; + + registry.register(request, owner, 0).unwrap(); + + let resolved = registry.resolve("myapp", 0).unwrap(); + assert_eq!(resolved.digest, cid.digest); + } + + #[test] + fn test_update_cid() { + let mut registry = NameRegistry::new(); + let owner = [1u8; 32]; + let cid1 = make_cid(b"version 1"); + let cid2 = make_cid(b"version 2"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid1.clone(), + duration: 1000, + metadata: None, + }; + + registry.register(request, owner, 0).unwrap(); + registry.update("myapp", cid2.clone(), owner, 0).unwrap(); + + let resolved = registry.resolve("myapp", 0).unwrap(); + assert_eq!(resolved.digest, cid2.digest); + } + + #[test] + fn test_transfer_ownership() { + let mut registry = NameRegistry::new(); + let owner1 = [1u8; 32]; + let owner2 = [2u8; 32]; + let cid = make_cid(b"hello"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 1000, + metadata: None, + }; + + registry.register(request, owner1, 0).unwrap(); + registry.transfer("myapp", owner2, owner1, 0).unwrap(); + + let record = registry.get_record("myapp", 0).unwrap(); + assert_eq!(record.owner, owner2); + } + + #[test] + fn test_name_expiry() { + let mut registry = NameRegistry::new(); + let owner = [1u8; 32]; + let cid = make_cid(b"hello"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 100, + metadata: None, + }; + + registry.register(request, owner, 0).unwrap(); + + // Should resolve before expiry + assert!(registry.resolve("myapp", 50).is_some()); + + // Should still resolve during grace period + assert!(registry.resolve("myapp", 150).is_some()); + + // Should not resolve after grace period + let after_grace = 100 + registry.grace_period + 1; + assert!(registry.resolve("myapp", after_grace).is_none()); + } + + #[test] + fn test_custom_domain() { + let mut registry = NameRegistry::new(); + let owner = [1u8; 32]; + let cid = make_cid(b"hello"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 1000, + metadata: None, + }; + + registry.register(request, owner, 0).unwrap(); + registry.add_custom_domain("myapp", "myapp.com".to_string(), owner, 0).unwrap(); + + assert_eq!(registry.resolve_domain("myapp.com"), Some("myapp")); + + let resolved = registry.resolve_domain_to_cid("myapp.com", 0).unwrap(); + assert_eq!(resolved.digest, cid.digest); + } + + #[test] + fn test_not_owner_error() { + let mut registry = NameRegistry::new(); + let owner = [1u8; 32]; + let other = [2u8; 32]; + let cid = make_cid(b"hello"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 1000, + metadata: None, + }; + + registry.register(request, owner, 0).unwrap(); + + // Other user cannot update + let result = registry.update("myapp", make_cid(b"new"), other, 0); + assert!(matches!(result, Err(Error::NotOwner))); + } +} diff --git a/crates/synor-hosting/src/router.rs b/crates/synor-hosting/src/router.rs new file mode 100644 index 0000000..2b7bacb --- /dev/null +++ b/crates/synor-hosting/src/router.rs @@ -0,0 +1,303 @@ +//! Hosting Router - Route requests to content based on host +//! +//! Handles subdomain-based routing, SPA support, and custom domains. + +use crate::registry::NameRegistry; +use crate::domain::DomainVerifier; +use crate::error::{Error, Result}; +use synor_storage::ContentId; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Route configuration for a hosted site +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteConfig { + /// Fallback routes (pattern -> target) + /// e.g., "/*" -> "/index.html" for SPAs + pub routes: HashMap, + /// Custom headers (pattern -> headers) + pub headers: HashMap>, + /// Redirects (from -> (to, status)) + pub redirects: Vec, + /// Error pages (status -> path) + pub error_pages: HashMap, +} + +impl Default for RouteConfig { + fn default() -> Self { + let mut routes = HashMap::new(); + // Default SPA routing + routes.insert("/*".to_string(), "/index.html".to_string()); + + Self { + routes, + headers: HashMap::new(), + redirects: Vec::new(), + error_pages: HashMap::new(), + } + } +} + +/// Redirect rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Redirect { + /// Source path (can include wildcards) + pub from: String, + /// Target path or URL + pub to: String, + /// HTTP status code (301, 302, 307, 308) + pub status: u16, +} + +/// Resolved route result +#[derive(Debug, Clone)] +pub struct ResolvedRoute { + /// CID of the content + pub cid: ContentId, + /// Path within the content + pub path: String, + /// Additional headers to set + pub headers: HashMap, + /// Is this a fallback route (SPA)? + pub is_fallback: bool, +} + +/// Hosting router +pub struct HostingRouter { + /// Synor hosting domain (e.g., "synor.network") + hosting_domain: String, + /// Name registry for subdomain resolution + registry: NameRegistry, + /// Domain verifier for custom domains + verifier: DomainVerifier, + /// Per-name route configurations + configs: HashMap, + /// Current block number (for expiry checks) + current_block: u64, +} + +impl HostingRouter { + /// Create a new hosting router + pub fn new(hosting_domain: String) -> Self { + Self { + hosting_domain: hosting_domain.clone(), + registry: NameRegistry::new(), + verifier: DomainVerifier::new(hosting_domain), + configs: HashMap::new(), + current_block: 0, + } + } + + /// Set current block number + pub fn set_block(&mut self, block: u64) { + self.current_block = block; + } + + /// Get the name registry + pub fn registry(&self) -> &NameRegistry { + &self.registry + } + + /// Get mutable name registry + pub fn registry_mut(&mut self) -> &mut NameRegistry { + &mut self.registry + } + + /// Get the domain verifier + pub fn verifier(&self) -> &DomainVerifier { + &self.verifier + } + + /// Get mutable domain verifier + pub fn verifier_mut(&mut self) -> &mut DomainVerifier { + &mut self.verifier + } + + /// Set route configuration for a name + pub fn set_config(&mut self, name: &str, config: RouteConfig) { + self.configs.insert(name.to_string(), config); + } + + /// Route a request based on host and path + pub fn route(&self, host: &str, path: &str) -> Result { + // Parse host to determine name + let name = self.parse_host(host)?; + + // Resolve name to CID + let cid = self.registry.resolve(&name, self.current_block) + .ok_or_else(|| Error::NameNotFound(name.clone()))?; + + // Get route config (or default) + let config = self.configs.get(&name).cloned().unwrap_or_default(); + + // Check for redirects + if let Some(redirect) = self.find_redirect(path, &config.redirects) { + return Err(Error::Redirect { + to: redirect.to.clone(), + status: redirect.status, + }); + } + + // Resolve path + let (resolved_path, is_fallback) = self.resolve_path(path, &config); + + // Get headers for this path + let headers = self.get_headers(&resolved_path, &config); + + Ok(ResolvedRoute { + cid, + path: resolved_path, + headers, + is_fallback, + }) + } + + /// Parse host header to extract name + fn parse_host(&self, host: &str) -> Result { + // Remove port if present + let host = host.split(':').next().unwrap_or(host); + + // Check if it's a subdomain of hosting domain + if let Some(subdomain) = host.strip_suffix(&format!(".{}", self.hosting_domain)) { + // Validate subdomain is a valid name + crate::validate_name(subdomain)?; + return Ok(subdomain.to_string()); + } + + // Check if it's a custom domain + if let Some(name) = self.registry.resolve_domain(host) { + // Verify domain is still verified + if self.verifier.is_verified(host, self.current_block) { + return Ok(name.to_string()); + } else { + return Err(Error::DomainNotVerified(host.to_string())); + } + } + + Err(Error::UnknownHost(host.to_string())) + } + + /// Resolve path using route config + fn resolve_path(&self, path: &str, config: &RouteConfig) -> (String, bool) { + // Normalize path + let path = if path.is_empty() || path == "/" { + "/index.html".to_string() + } else { + path.to_string() + }; + + // Check for exact match in routes (would be file existence check in real impl) + // For now, assume files with extensions exist, others need fallback + if path.contains('.') { + return (path, false); + } + + // Check route patterns + for (pattern, target) in &config.routes { + if self.matches_pattern(&path, pattern) { + return (target.clone(), true); + } + } + + // Default: return path as-is + (path, false) + } + + /// Check if path matches a route pattern + fn matches_pattern(&self, path: &str, pattern: &str) -> bool { + if pattern == "/*" { + return true; + } + + if pattern.ends_with("/*") { + let prefix = pattern.trim_end_matches("/*"); + return path.starts_with(prefix); + } + + path == pattern + } + + /// Find matching redirect + fn find_redirect<'a>(&self, path: &str, redirects: &'a [Redirect]) -> Option<&'a Redirect> { + for redirect in redirects { + if self.matches_pattern(path, &redirect.from) { + return Some(redirect); + } + } + None + } + + /// Get headers for a path + fn get_headers(&self, path: &str, config: &RouteConfig) -> HashMap { + let mut headers = HashMap::new(); + + // Find matching header patterns + for (pattern, pattern_headers) in &config.headers { + if self.matches_pattern(path, pattern) { + headers.extend(pattern_headers.clone()); + } + } + + headers + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::RegistrationRequest; + use synor_storage::ContentId; + + fn make_cid(data: &[u8]) -> ContentId { + ContentId::from_content(data) + } + + #[test] + fn test_route_subdomain() { + let mut router = HostingRouter::new("synor.network".to_string()); + let owner = [1u8; 32]; + let cid = make_cid(b"my app content"); + + // Register name + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 1000, + metadata: None, + }; + router.registry_mut().register(request, owner, 0).unwrap(); + + // Route request + let resolved = router.route("myapp.synor.network", "/").unwrap(); + assert_eq!(resolved.cid.digest, cid.digest); + assert_eq!(resolved.path, "/index.html"); + } + + #[test] + fn test_spa_routing() { + let mut router = HostingRouter::new("synor.network".to_string()); + let owner = [1u8; 32]; + let cid = make_cid(b"spa content"); + + let request = RegistrationRequest { + name: "myapp".to_string(), + cid: cid.clone(), + duration: 1000, + metadata: None, + }; + router.registry_mut().register(request, owner, 0).unwrap(); + + // Deep path should fallback to index.html + let resolved = router.route("myapp.synor.network", "/dashboard/settings").unwrap(); + assert_eq!(resolved.path, "/index.html"); + assert!(resolved.is_fallback); + } + + #[test] + fn test_unknown_host() { + let router = HostingRouter::new("synor.network".to_string()); + + let result = router.route("unknown.synor.network", "/"); + assert!(result.is_err()); + } +} diff --git a/docs/ARCHITECTURE_HOSTING.md b/docs/ARCHITECTURE_HOSTING.md new file mode 100644 index 0000000..fdee422 --- /dev/null +++ b/docs/ARCHITECTURE_HOSTING.md @@ -0,0 +1,430 @@ +# Synor Hosting Architecture + +Decentralized web hosting powered by Synor Storage Layer. + +## Overview + +Synor Hosting enables users to deploy web applications to permanent, censorship-resistant infrastructure with human-readable URLs. It combines: + +- **Synor Storage L2**: Decentralized content storage +- **Name Registry**: On-chain name → CID mapping +- **Enhanced Gateway**: Subdomain-based routing with SSL +- **Custom Domains**: User-owned domain integration + +## User Journey + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Deployment Flow │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. BUILD 2. UPLOAD 3. REGISTER │ +│ ───────────── ───────────── ───────────── │ +│ $ next build $ synor upload $ synor register │ +│ $ synor pack ./out myfirstapp │ +│ │ │ │ +│ ▼ ▼ │ +│ CID: synor1abc... TX confirmed │ +│ │ +│ 4. ACCESS │ +│ ───────────── │ +│ https://myfirstapp.synor.network ──────────────────────▶ Your App! │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Architecture Components + +### 1. Name Registry (On-Chain) + +The name registry is a smart contract on Synor L1 that maps human-readable names to CIDs. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Name Registry Contract │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Names Table │ +│ ┌────────────────┬──────────────────┬───────────────────────┐ │ +│ │ Name │ Owner │ CID │ │ +│ ├────────────────┼──────────────────┼───────────────────────┤ │ +│ │ myfirstapp │ synor1user123... │ synor1abc789xyz... │ │ +│ │ coolsite │ synor1user456... │ synor1def012uvw... │ │ +│ │ myportfolio │ synor1user789... │ synor1ghi345rst... │ │ +│ └────────────────┴──────────────────┴───────────────────────┘ │ +│ │ +│ Custom Domains Table │ +│ ┌────────────────────┬────────────────┬─────────────────────┐ │ +│ │ Domain │ Name │ Verified │ │ +│ ├────────────────────┼────────────────┼─────────────────────┤ │ +│ │ myfirstapp.com │ myfirstapp │ true │ │ +│ │ www.coolsite.io │ coolsite │ true │ │ +│ └────────────────────┴────────────────┴─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Contract Interface + +```rust +/// Name Registry Contract +pub trait NameRegistry { + /// Register a new name (must be unique, pay registration fee) + fn register(name: String, cid: ContentId) -> Result<()>; + + /// Update CID for a name you own (deploy new version) + fn update(name: String, new_cid: ContentId) -> Result<()>; + + /// Transfer ownership to another address + fn transfer(name: String, new_owner: Address) -> Result<()>; + + /// Resolve name to CID + fn resolve(name: String) -> Option; + + /// Add custom domain (requires verification) + fn add_custom_domain(name: String, domain: String) -> Result<()>; + + /// Verify custom domain ownership + fn verify_domain(name: String, domain: String, proof: DomainProof) -> Result<()>; + + /// Remove custom domain + fn remove_custom_domain(name: String, domain: String) -> Result<()>; + + /// Resolve custom domain to name + fn resolve_domain(domain: String) -> Option; +} +``` + +#### Name Rules + +| Rule | Description | +|------|-------------| +| Length | 3-63 characters | +| Characters | a-z, 0-9, hyphen (not at start/end) | +| Reserved | synor, admin, api, gateway, www, etc. | +| Fee | Registration fee + annual renewal | +| Grace Period | 30 days after expiry before release | + +### 2. Enhanced Gateway + +The gateway routes requests based on the `Host` header instead of just the path. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Gateway Routing Flow │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Incoming Request │ +│ ───────────────── │ +│ GET / HTTP/1.1 │ +│ Host: myfirstapp.synor.network │ +│ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Extract Host │ │ +│ │ header │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Is it │ YES │ Parse subdomain │ │ +│ │ *.synor.network?├────▶│ "myfirstapp" │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ NO │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Check custom │ │ Query Name │ │ +│ │ domain table │ │ Registry │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Resolve to CID │ │ +│ │ synor1abc789xyz... │ │ +│ └────────────────────┬────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Fetch from Storage │ │ +│ │ Serve with correct MIME type │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### SPA Routing + +For Single Page Applications (React, Next.js, Vue, etc.), the gateway handles client-side routing: + +``` +Request: GET /dashboard/settings +Host: myfirstapp.synor.network + +1. Try exact path: /dashboard/settings → Not found +2. Try with .html: /dashboard/settings.html → Not found +3. Check if SPA (has index.html): Yes +4. Serve index.html with 200 (not redirect) +5. Client-side router handles /dashboard/settings +``` + +### 3. SSL/TLS Management + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SSL Certificate Strategy │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Synor Domains (*.synor.network) │ +│ ───────────────────────────────── │ +│ • Wildcard certificate: *.synor.network │ +│ • Single cert covers all subdomains │ +│ • Auto-renewed via Let's Encrypt │ +│ │ +│ Custom Domains │ +│ ───────────────────────────────── │ +│ • On-demand certificate generation │ +│ • Let's Encrypt HTTP-01 or DNS-01 challenge │ +│ • Stored in distributed cache │ +│ • Auto-renewal 30 days before expiry │ +│ │ +│ Certificate Storage │ +│ ───────────────────────────────── │ +│ • Encrypted at rest │ +│ • Replicated across gateway nodes │ +│ • Hot-reload without restart │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4. Custom Domain Verification + +Users must prove domain ownership before linking to their Synor name. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Domain Verification Flow │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: Request Verification │ +│ ───────────────────────────── │ +│ $ synor domain add myfirstapp.com │ +│ │ +│ Response: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ To verify ownership of myfirstapp.com, add ONE of: │ │ +│ │ │ │ +│ │ Option A: CNAME Record │ │ +│ │ myfirstapp.com CNAME myfirstapp.synor.network │ │ +│ │ │ │ +│ │ Option B: TXT Record │ │ +│ │ _synor.myfirstapp.com TXT "synor-verify=abc123xyz789" │ │ +│ │ │ │ +│ │ Then run: synor domain verify myfirstapp.com │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Step 2: Add DNS Record (User action) │ +│ ───────────────────────────────────── │ +│ User adds record at their DNS provider (Cloudflare, Route53, etc.) │ +│ │ +│ Step 3: Verify │ +│ ───────────── │ +│ $ synor domain verify myfirstapp.com │ +│ │ +│ Gateway checks: │ +│ 1. DNS lookup for CNAME or TXT record │ +│ 2. Verify record matches expected value │ +│ 3. Submit verification proof to L1 │ +│ 4. Issue SSL certificate │ +│ │ +│ ✓ Domain verified! myfirstapp.com → myfirstapp.synor.network │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## URL Patterns + +| Pattern | Example | Description | +|---------|---------|-------------| +| CID Path | `gateway.synor.cc/synor1abc...` | Direct CID access | +| Subdomain | `myfirstapp.synor.network` | Registered name | +| Custom Domain | `myfirstapp.com` | User's own domain | +| Path in App | `myfirstapp.synor.network/dashboard` | SPA routing | + +## CLI Commands + +```bash +# Upload and get CID +synor upload ./dist +# Output: Uploaded! CID: synor1abc789xyz... + +# Register a name +synor register myapp +# Output: Name "myapp" registered to synor1abc789xyz... + +# Update deployment (new version) +synor upload ./dist --name myapp +# Output: Updated! myapp now points to synor1def012... + +# Add custom domain +synor domain add myapp.com +# Output: Add CNAME record, then run: synor domain verify myapp.com + +# Verify domain +synor domain verify myapp.com +# Output: ✓ Domain verified! + +# Check status +synor status myapp +# Output: +# Name: myapp +# CID: synor1abc789xyz... +# URL: https://myapp.synor.network +# Custom Domains: +# - myapp.com (verified) +# - www.myapp.com (pending) +``` + +## Pricing Model + +| Service | Cost | Notes | +|---------|------|-------| +| Name Registration | 10 SYNOR | One-time | +| Annual Renewal | 5 SYNOR | Per year | +| Storage | Per deal pricing | See storage tiers | +| Custom Domain | Free | Unlimited per name | +| SSL Certificate | Free | Auto-managed | +| Bandwidth | Free* | Fair use policy | + +*Heavy usage may require staking or premium tier. + +## DNS Configuration + +### Synor Infrastructure + +``` +# Wildcard for all subdomains +*.synor.network A +*.synor.network AAAA + +# Gateway load balancer +gateway.synor.cc A +g.synor.cc CNAME gateway.synor.cc +``` + +### User Custom Domain + +``` +# Option 1: CNAME (recommended) +myapp.com CNAME myapp.synor.network + +# Option 2: A record (if CNAME not supported at apex) +myapp.com A + +# For www subdomain +www.myapp.com CNAME myapp.synor.network +``` + +## Deployment Types + +### Static Sites + +```yaml +# Supported frameworks +- Next.js (static export) +- React (CRA, Vite) +- Vue.js +- Svelte/SvelteKit +- Astro +- Hugo, Jekyll, 11ty +- Plain HTML/CSS/JS +``` + +### Configuration File (synor.json) + +```json +{ + "name": "myapp", + "build": { + "command": "npm run build", + "output": "dist" + }, + "routes": { + "/*": "/index.html" + }, + "headers": { + "/*": { + "Cache-Control": "public, max-age=31536000, immutable" + }, + "/index.html": { + "Cache-Control": "no-cache" + } + }, + "redirects": [ + { "from": "/old-page", "to": "/new-page", "status": 301 } + ] +} +``` + +## Security Considerations + +### Name Squatting Prevention + +- Minimum registration fee discourages mass registration +- Trademark dispute resolution process +- Reserved names for common terms + +### Phishing Protection + +- Confusable name detection (l vs 1, O vs 0) +- Warning for names similar to popular sites +- Report mechanism for malicious content + +### Content Moderation + +- Gateway operators can block CIDs (not remove from storage) +- Multiple gateways ensure censorship resistance +- Hash-based blocking list shared among operators + +## Implementation Phases + +### Phase 1: Name Registry (2 weeks) +- [ ] Name registry smart contract +- [ ] CLI commands: register, update, transfer +- [ ] Basic gateway subdomain routing + +### Phase 2: Enhanced Gateway (2 weeks) +- [ ] Host-based routing +- [ ] SPA support (fallback to index.html) +- [ ] Custom headers and redirects +- [ ] Caching layer + +### Phase 3: Custom Domains (2 weeks) +- [ ] Domain verification (CNAME/TXT) +- [ ] On-demand SSL certificates +- [ ] Certificate storage and renewal + +### Phase 4: Developer Experience (1 week) +- [ ] synor.json configuration +- [ ] CI/CD integration examples +- [ ] GitHub Actions deployment + +## Comparison + +| Feature | Synor Hosting | Vercel | Netlify | IPFS+ENS | +|---------|--------------|--------|---------|----------| +| Decentralized | ✓ | ✗ | ✗ | ✓ | +| Custom Domains | ✓ | ✓ | ✓ | ✓ | +| Auto SSL | ✓ | ✓ | ✓ | ✗ | +| Serverless Functions | Future | ✓ | ✓ | ✗ | +| Censorship Resistant | ✓ | ✗ | ✗ | ✓ | +| Pay Once, Host Forever | ✓ | ✗ | ✗ | ✓ | +| Native Blockchain | ✓ | ✗ | ✗ | ✗ | + +## Future Enhancements + +1. **Synor Functions**: Serverless compute layer +2. **Database Integration**: Decentralized database for dynamic apps +3. **Analytics**: Privacy-preserving usage analytics +4. **Team Collaboration**: Multi-user access to names +5. **Staging Environments**: Preview deployments before going live