diff --git a/bin/el/src/main.rs b/bin/el/src/main.rs index 240d969..4d3d4a8 100644 --- a/bin/el/src/main.rs +++ b/bin/el/src/main.rs @@ -2161,6 +2161,14 @@ fn dispatch_builtin( // http_serve(port) — general-purpose HTTP server. // Passes every request to the Engram `handle_request(method, path, body)` function. // The legacy /axon/message route is preserved for Neuron compatibility. + + // ── Waitlist rate limiter (max 3 per IP per 10 min) ────────────────── + use std::sync::{OnceLock, Mutex}; + static WAITLIST_RATE_MAP: OnceLock>>> = + OnceLock::new(); + let waitlist_rate_map = WAITLIST_RATE_MAP + .get_or_init(|| Mutex::new(std::collections::HashMap::new())); + let port_val = stack.pop().unwrap_or(Value::Nil); let port = match port_val { Value::Int(n) => n as u16, @@ -2353,10 +2361,188 @@ fn dispatch_builtin( continue; } - // POST /api/waitlist — acknowledge (email already captured above) + // POST /api/waitlist — honeypot + rate limit + HMAC confirm email if method == "POST" && path == "/api/waitlist" { + // ── IP rate limit: max 3 per IP per 10 minutes ── + let ip = request.remote_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let now_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let rate_ok = { + let mut map = waitlist_rate_map.lock() + .unwrap_or_else(|e| e.into_inner()); + let entry = map.entry(ip).or_insert_with(Vec::new); + entry.retain(|&ts| now_ts.saturating_sub(ts) < 600); + if entry.len() < 3 { entry.push(now_ts); true } else { false } + }; + if !rate_ok { + let _ = request.respond( + tiny_http::Response::from_string(r#"{"ok":false,"error":"too many requests"}"#) + .with_status_code(429u16) + .with_header(content_json) + .with_header(cors_origin) + ); + continue; + } + + let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default(); + let waitlist_secret = std::env::var("WAITLIST_SECRET") + .unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string()); + + let result: Result<(), String> = (|| { + let v: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| e.to_string())?; + let email = v["email"].as_str().unwrap_or("").to_string(); + let name = v["name"].as_str().unwrap_or("there").to_string(); + let conv_id = v["conv_id"].as_str().unwrap_or("").to_string(); + let base_url = v["base_url"].as_str().unwrap_or("").to_string(); + if email.is_empty() { return Err("no email".to_string()); } + + // ── Honeypot: bots fill this, humans don't ── + if !v["hp"].as_str().unwrap_or("").is_empty() { + // Silently accept but don't process — don't tell bots they failed + return Ok(()); + } + + // ── Generate HMAC-signed confirmation token ── + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let raw = format!("{}|{}|{}", email, name, ts); + let payload = URL_SAFE_NO_PAD.encode(raw.as_bytes()); + + let mut mac = Hmac::::new_from_slice(waitlist_secret.as_bytes()) + .map_err(|e| e.to_string())?; + mac.update(payload.as_bytes()); + let sig = hex::encode(mac.finalize().into_bytes()); + let token = format!("{}.{}", payload, sig); + + // ── Build confirm link ── + let confirm_link = if conv_id.is_empty() { + format!("{}?confirm={}", base_url, token) + } else { + format!("{}?confirm={}&cid={}", base_url, token, conv_id) + }; + + if resend_key.is_empty() { + // Dev mode — no email sent, just log + eprintln!("[waitlist] confirm link (dev): {}", confirm_link); + return Ok(()); + } + + // ── Send confirmation email via Resend ── + let html_body = format!( + r#"
+

Hey {} —

+

+ One click to confirm your spot on the Neuron waitlist. +

+

+ + Confirm my spot → + +

+

+ This link expires in 24 hours. If you didn't sign up for Neuron, you can ignore this. +

+
"#, + name, confirm_link + ); + + let payload_json = serde_json::json!({ + "from": "Neuron ", + "to": [email], + "subject": format!("Confirm your spot, {}", name), + "html": html_body + }); + + reqwest::blocking::Client::new() + .post("https://api.resend.com/emails") + .header("Authorization", format!("Bearer {}", resend_key)) + .header("Content-Type", "application/json") + .body(payload_json.to_string()) + .send() + .map_err(|e| e.to_string())?; + Ok(()) + })(); + + let (status, resp_body) = match result { + Ok(()) => (200u16, r#"{"ok":true}"#.to_string()), + Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)), + }; let _ = request.respond( - tiny_http::Response::from_string(r#"{"ok":true}"#) + tiny_http::Response::from_string(resp_body) + .with_status_code(status) + .with_header(content_json) + .with_header(cors_origin) + ); + continue; + } + + // POST /api/waitlist/confirm — verify HMAC token, confirm waitlist spot + if method == "POST" && path == "/api/waitlist/confirm" { + let waitlist_secret = std::env::var("WAITLIST_SECRET") + .unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string()); + + let result: Result<(), String> = (|| { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + + let v: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| e.to_string())?; + let token = v["token"].as_str().unwrap_or(""); + if token.is_empty() { return Err("missing token".to_string()); } + + let parts: Vec<&str> = token.splitn(2, '.').collect(); + if parts.len() != 2 { return Err("invalid token format".to_string()); } + let (payload, sig) = (parts[0], parts[1]); + + // Verify HMAC + let mut mac = Hmac::::new_from_slice(waitlist_secret.as_bytes()) + .map_err(|e| e.to_string())?; + mac.update(payload.as_bytes()); + let expected = hex::encode(mac.finalize().into_bytes()); + if sig != expected { return Err("invalid signature".to_string()); } + + // Decode payload and check expiry (24h) + let raw_bytes = URL_SAFE_NO_PAD.decode(payload) + .map_err(|e| e.to_string())?; + let raw = String::from_utf8(raw_bytes) + .map_err(|e| e.to_string())?; + let fields: Vec<&str> = raw.splitn(3, '|').collect(); + if fields.len() < 3 { return Err("invalid token payload".to_string()); } + + let ts: u64 = fields[2].parse().map_err(|_| "invalid timestamp".to_string())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if now.saturating_sub(ts) > 86400 { + return Err("link expired".to_string()); + } + + // Log confirmed email + eprintln!("[waitlist] confirmed: {} ({})", fields[0], fields[1]); + Ok(()) + })(); + + let (status, resp_body) = match result { + Ok(()) => (200u16, r#"{"ok":true}"#.to_string()), + Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)), + }; + let _ = request.respond( + tiny_http::Response::from_string(resp_body) + .with_status_code(status) .with_header(content_json) .with_header(cors_origin) ); @@ -3828,10 +4014,185 @@ fn dispatch_builtin( BuiltinResult::Handled } + // ── Terminal control builtins ───────────────────────────────────────── + + "term_clear" => { + print!("\x1b[2J\x1b[H"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "term_size" => { + let (cols, rows) = term_size(); + stack.push(Value::List(vec![Value::Int(cols as i64), Value::Int(rows as i64)])); + BuiltinResult::Handled + } + "cursor_to" => { + let col = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n, + _ => 1, + }; + let row = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n, + _ => 1, + }; + print!("\x1b[{};{}H", row, col); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "cursor_up" => { + let n = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n, + _ => 1, + }; + print!("\x1b[{}A", n); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "cursor_down" => { + let n = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n, + _ => 1, + }; + print!("\x1b[{}B", n); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "cursor_col" => { + let col = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n, + _ => 1, + }; + print!("\x1b[{}G", col); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "term_save_cursor" => { + print!("\x1b[s"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "term_restore_cursor" => { + print!("\x1b[u"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "term_clear_line" => { + print!("\x1b[2K\r"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "print_inline" => { + let v = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + print!("{}", v); + let _ = std::io::Write::flush(&mut std::io::stdout()); + stack.push(Value::Nil); + BuiltinResult::Handled + } + + // ── SSE streaming builtin ───────────────────────────────────────────── + + "http_sse_post" => { + // http_sse_post(url, token, body) -> String + // POSTs and reads an SSE stream, printing each token inline. + // Returns the full assembled response as a String. + let body_str = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let token_str = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let url_str = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + + let result = reqwest::blocking::Client::new() + .post(&url_str) + .header("Authorization", format!("Bearer {}", token_str)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .body(body_str) + .send(); + + match result { + Err(e) => { + stack.push(Value::Str(format!("error: {}", e))); + } + Ok(resp) => { + use std::io::BufRead; + let mut full_text = String::new(); + let reader = std::io::BufReader::new(resp); + for line in reader.lines() { + let Ok(line) = line else { break }; + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { break; } + if let Ok(v) = serde_json::from_str::(data) { + let delta = v.get("delta") + .and_then(|d| d.as_str()) + .or_else(|| v.get("content").and_then(|c| c.as_str())) + .or_else(|| v.get("text").and_then(|t| t.as_str())) + .unwrap_or(""); + if !delta.is_empty() { + print!("{}", delta); + let _ = std::io::Write::flush(&mut std::io::stdout()); + full_text.push_str(delta); + } + } else if !data.is_empty() { + print!("{}", data); + let _ = std::io::Write::flush(&mut std::io::stdout()); + full_text.push_str(data); + } + } + } + stack.push(Value::Str(full_text)); + } + } + BuiltinResult::Handled + } + _ => BuiltinResult::NotBuiltin, } } +/// Return terminal dimensions as (cols, rows). +/// Uses TIOCGWINSZ ioctl on Unix; falls back to (80, 24). +fn term_size() -> (u16, u16) { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + #[repr(C)] + struct WinSize { rows: u16, cols: u16, _xpix: u16, _ypix: u16 } + let ws = WinSize { rows: 0, cols: 0, _xpix: 0, _ypix: 0 }; + unsafe { + extern "C" { + fn ioctl(fd: std::ffi::c_int, request: std::ffi::c_ulong, ...) -> std::ffi::c_int; + } + // TIOCGWINSZ: macOS = 0x40087468, Linux = 0x5413 + #[cfg(target_os = "macos")] + ioctl(std::io::stdout().as_raw_fd(), 0x40087468_u64, &ws); + #[cfg(not(target_os = "macos"))] + ioctl(std::io::stdout().as_raw_fd(), 0x5413_u64, &ws); + if ws.cols > 0 && ws.rows > 0 { + return (ws.cols, ws.rows); + } + } + } + (80, 24) +} + /// Format a Unix timestamp (seconds + millis) as an ISO 8601 string. /// This avoids pulling in chrono while still producing a readable timestamp. fn format_epoch_as_iso(secs: u64, millis: u32) -> String { diff --git a/crates/el-types/src/types.rs b/crates/el-types/src/types.rs index be19342..80692de 100644 --- a/crates/el-types/src/types.rs +++ b/crates/el-types/src/types.rs @@ -250,6 +250,18 @@ impl TypeEnv { env.functions.insert(name.to_string(), str_fn(vec![s.clone()], s.clone())); } + // Terminal control builtins + for name in &["term_clear", "term_save_cursor", "term_restore_cursor", "term_clear_line"] { + env.functions.insert(name.to_string(), str_fn(vec![], Type::Void)); + } + env.functions.insert("print_inline".into(), str_fn(vec![u.clone()], Type::Void)); + env.functions.insert("term_size".into(), str_fn(vec![], Type::Unknown)); + env.functions.insert("cursor_to".into(), str_fn(vec![i.clone(), i.clone()], Type::Void)); + env.functions.insert("cursor_up".into(), str_fn(vec![i.clone()], Type::Void)); + env.functions.insert("cursor_down".into(), str_fn(vec![i.clone()], Type::Void)); + env.functions.insert("cursor_col".into(), str_fn(vec![i.clone()], Type::Void)); + env.functions.insert("http_sse_post".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone())); + // Math for name in &["math_abs","math_floor","math_ceil","math_round","math_sqrt"] { env.functions.insert(name.to_string(), str_fn(vec![u.clone()], u.clone()));