mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(landing): full-viewport GPU node-engine hero + waitlist + dashboard auth removal
The /launch landing page (bleeding-edge waitlist hero): - Full-viewport WebGL node engine (src/lib/hero/nodeEngine.ts): 40k GPU particles on a two-FBO GPUComputationRenderer running an 18s looping cinematic — particles stream in from the screen edges, slam together and EXPLODE at center, reform into a brain / graph constellation / neural lattice, dissolve back out, loop. Glowing additive particles + UnrealBloom, fills the whole screen edge to edge. - Key GPGPU fix: custom DataTexture shape targets read black in GPUComputationRenderer (three.js #15882), so shape targets are computed PROCEDURALLY in-shader from the per-particle seed. Position integrates raw per-frame velocity (Codrops pattern), texel-center reference attribute. - AmbientField (god-ray glow + parallax starfield), phantomBrain (deterministic seed-from-identity memory graph), LandingHero/neuralFlow (WebGPU Cinema-storm reuse + WebGL fallback, kept as alternates). Manifesto copy, seed input, email capture. No em-dashes. SSR off + prerender for the WebGL route. Waitlist backend (Supabase, owned data): - supabase/migrations/0001_waitlist.sql (RLS-locked table, dedup), Edge Function waitlist-join (CORS, validation, honeypot, dedup, Resend confirmation), setup doc. Dashboard auth removed (reverts the launch-polish auth wall that 401'd the dashboard against itself): mod.rs/state.rs/websocket.rs back to no-token, api.ts/websocket.ts/ +layout.svelte stripped of the token client. Pending-review UX + Memory PR gating kept. Research/spec docs under docs/launch/. Frontend typechecks 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
335a42f341
commit
cfe8d03d36
26 changed files with 3002 additions and 266 deletions
|
|
@ -12,11 +12,6 @@ pub mod static_files;
|
|||
pub mod websocket;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -30,8 +25,6 @@ use crate::cognitive::CognitiveEngine;
|
|||
use state::AppState;
|
||||
use vestige_core::Storage;
|
||||
|
||||
const DASHBOARD_TOKEN_HEADER: &str = "x-vestige-dashboard-token";
|
||||
|
||||
/// Build the axum router with all dashboard routes
|
||||
pub fn build_router(
|
||||
storage: Arc<Storage>,
|
||||
|
|
@ -54,12 +47,30 @@ pub fn build_router_with_event_tx(
|
|||
}
|
||||
|
||||
fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
||||
let origin_strings = dashboard_allowed_origins(port);
|
||||
let origins: Vec<HeaderValue> = origin_strings
|
||||
.iter()
|
||||
.map(|origin| origin.parse::<HeaderValue>().expect("valid origin"))
|
||||
.collect();
|
||||
let state = state.with_dashboard_allowed_origins(origin_strings);
|
||||
#[allow(unused_mut)]
|
||||
let mut origins = vec![
|
||||
format!("http://127.0.0.1:{}", port)
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
format!("http://localhost:{}", port)
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
];
|
||||
|
||||
// SvelteKit dev server — only in debug builds
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
origins.push(
|
||||
"http://localhost:5173"
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
);
|
||||
origins.push(
|
||||
"http://127.0.0.1:5173"
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
);
|
||||
}
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
|
|
@ -72,7 +83,6 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
.allow_headers([
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::HeaderName::from_static(DASHBOARD_TOKEN_HEADER),
|
||||
]);
|
||||
|
||||
// Security: restrict WebSocket connections to localhost only (prevents cross-site WS hijacking)
|
||||
|
|
@ -202,10 +212,6 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.concurrency_limit(50)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_dashboard_auth,
|
||||
))
|
||||
.layer(cors)
|
||||
.layer(csp)
|
||||
.layer(x_frame_options)
|
||||
|
|
@ -218,92 +224,6 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
(router, state)
|
||||
}
|
||||
|
||||
fn dashboard_allowed_origins(port: u16) -> Vec<String> {
|
||||
#[allow(unused_mut)]
|
||||
let mut origins = vec![
|
||||
format!("http://127.0.0.1:{}", port),
|
||||
format!("http://localhost:{}", port),
|
||||
];
|
||||
|
||||
// SvelteKit dev server — only in debug builds.
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
origins.push("http://localhost:5173".to_string());
|
||||
origins.push("http://127.0.0.1:5173".to_string());
|
||||
}
|
||||
|
||||
origins
|
||||
}
|
||||
|
||||
async fn require_dashboard_auth(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
if !path.starts_with("/api/") || request.method() == Method::OPTIONS {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let headers = request.headers();
|
||||
if let Err((status, message)) = validate_dashboard_origin(headers, &state) {
|
||||
return (status, message).into_response();
|
||||
}
|
||||
if let Err((status, message)) = validate_dashboard_token(headers, &state) {
|
||||
return (status, message).into_response();
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn validate_dashboard_origin(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<(), (StatusCode, &'static str)> {
|
||||
if let Some(fetch_site) = headers
|
||||
.get("sec-fetch-site")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
&& fetch_site == "cross-site"
|
||||
{
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Cross-site dashboard request rejected",
|
||||
));
|
||||
}
|
||||
|
||||
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if state.is_allowed_dashboard_origin(origin) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((StatusCode::FORBIDDEN, "Dashboard origin not allowed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_dashboard_token(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<(), (StatusCode, &'static str)> {
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get(DASHBOARD_TOKEN_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
})
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing dashboard auth token"))?;
|
||||
|
||||
if state.is_valid_dashboard_token(token) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((StatusCode::FORBIDDEN, "Invalid dashboard auth token"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the dashboard HTTP server (blocking — use in CLI mode)
|
||||
pub async fn start_dashboard(
|
||||
storage: Arc<Storage>,
|
||||
|
|
@ -317,11 +237,7 @@ pub async fn start_dashboard(
|
|||
info!("Dashboard starting at http://127.0.0.1:{}", port);
|
||||
|
||||
if open_browser {
|
||||
let url = format!(
|
||||
"http://127.0.0.1:{}/dashboard#vestige_token={}",
|
||||
port,
|
||||
_state.dashboard_token_fragment_value()
|
||||
);
|
||||
let url = format!("http://127.0.0.1:{}", port);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let _ = open::that(&url);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{Mutex, broadcast};
|
||||
use tracing::warn;
|
||||
use vestige_core::Storage;
|
||||
|
||||
use super::events::VestigeEvent;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// Broadcast channel capacity — how many events can buffer before old ones drop.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
|
|
@ -20,8 +18,6 @@ pub struct AppState {
|
|||
pub cognitive: Option<Arc<Mutex<CognitiveEngine>>>,
|
||||
pub event_tx: broadcast::Sender<VestigeEvent>,
|
||||
pub start_time: Instant,
|
||||
dashboard_token: Arc<str>,
|
||||
dashboard_allowed_origins: Arc<Vec<String>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -33,8 +29,6 @@ impl AppState {
|
|||
cognitive,
|
||||
event_tx,
|
||||
start_time: Instant::now(),
|
||||
dashboard_token: load_dashboard_token().into(),
|
||||
dashboard_allowed_origins: Arc::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,68 +48,12 @@ impl AppState {
|
|||
cognitive,
|
||||
event_tx,
|
||||
start_time: Instant::now(),
|
||||
dashboard_token: load_dashboard_token().into(),
|
||||
dashboard_allowed_origins: Arc::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the exact origins allowed to drive the dashboard API and WS.
|
||||
pub fn with_dashboard_allowed_origins(mut self, origins: Vec<String>) -> Self {
|
||||
self.dashboard_allowed_origins = Arc::new(origins);
|
||||
self
|
||||
}
|
||||
|
||||
/// Return true when a dashboard token matches the shared Vestige auth token.
|
||||
pub fn is_valid_dashboard_token(&self, token: &str) -> bool {
|
||||
let expected = self.dashboard_token.as_bytes();
|
||||
let candidate = token.as_bytes();
|
||||
candidate.len() == expected.len() && candidate.ct_eq(expected).unwrap_u8() == 1
|
||||
}
|
||||
|
||||
/// Return true when an Origin header exactly matches the configured dashboard origins.
|
||||
pub fn is_allowed_dashboard_origin(&self, origin: &str) -> bool {
|
||||
self.dashboard_allowed_origins
|
||||
.iter()
|
||||
.any(|allowed| allowed == origin)
|
||||
}
|
||||
|
||||
/// Percent-encode the dashboard token for use inside a URL fragment.
|
||||
pub fn dashboard_token_fragment_value(&self) -> String {
|
||||
percent_encode_fragment_value(self.dashboard_token.as_ref())
|
||||
}
|
||||
|
||||
/// Emit an event to all connected clients.
|
||||
pub fn emit(&self, event: VestigeEvent) {
|
||||
// Ignore send errors (no receivers connected)
|
||||
let _ = self.event_tx.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_dashboard_token() -> String {
|
||||
match crate::protocol::auth::get_or_create_auth_token() {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Could not load persisted auth token for dashboard; using process-local token: {}",
|
||||
err
|
||||
);
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_encode_fragment_value(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
encoded.push(byte as char);
|
||||
}
|
||||
_ => {
|
||||
encoded.push('%');
|
||||
encoded.push_str(&format!("{:02X}", byte));
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,46 +3,35 @@
|
|||
//! Clients connect to `/ws` and receive all VestigeEvents as JSON.
|
||||
//! Also sends heartbeats every 5 seconds with system stats.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::events::VestigeEvent;
|
||||
use super::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WsAuthQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler — GET /ws
|
||||
/// Validates token + Origin header to prevent cross-site WebSocket hijacking.
|
||||
/// Validates Origin header to prevent cross-site WebSocket hijacking.
|
||||
pub async fn ws_handler(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WsAuthQuery>,
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let Some(token) = query.token.as_deref() else {
|
||||
warn!("Rejected WebSocket connection without dashboard token");
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
};
|
||||
if !state.is_valid_dashboard_token(token) {
|
||||
warn!("Rejected WebSocket connection with invalid dashboard token");
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
// Validate Origin header (browsers always send it for WebSocket upgrades).
|
||||
// Non-browser clients (curl, wscat) won't have Origin; they still need the token.
|
||||
// Non-browser clients (curl, wscat) won't have Origin — allowed since localhost-only.
|
||||
match headers.get("origin").and_then(|v| v.to_str().ok()) {
|
||||
Some(origin) => {
|
||||
if !state.is_allowed_dashboard_origin(origin) {
|
||||
let allowed =
|
||||
origin.starts_with("http://127.0.0.1:") || origin.starts_with("http://localhost:");
|
||||
#[cfg(debug_assertions)]
|
||||
let allowed =
|
||||
allowed || origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173";
|
||||
if !allowed {
|
||||
warn!("Rejected WebSocket connection from origin: {}", origin);
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue