diff --git a/.env.example b/.env.example index e6ee106..3bf5dd1 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,23 @@ # OpenCade environment — copy to .env and adjust for your machine. # Docker Compose reads this automatically when present. -DATABASE_URL=postgres://opencade:opencade@db:5432/opencade +POSTGRES_PASSWORD=replace-with-a-random-database-password +DATABASE_URL=postgres://opencade:replace-with-a-random-database-password@db:5432/opencade # Generate with: openssl rand -hex 32 SESSION_SECRET=replace-with-at-least-32-random-bytes # Generate independently with: openssl rand -hex 32 RELAY_AUTH_SECRET=replace-with-a-different-32-byte-random-secret +# Generate independently; protects metrics and aggregate decision endpoints. +OPERATOR_TOKEN=replace-with-a-third-32-byte-random-secret ALLOWED_ORIGINS=http://localhost:1420,tauri://localhost,http://tauri.localhost,https://tauri.localhost OPENCADE_ENV=production +PUBLIC_RELAY_URL=wss://relay.example.com/relay +STUN_HOST=stun.example.com # Optional overrides: # RUST_LOG=info # PORT=8080 # POSTGRES_USER=opencade -# POSTGRES_PASSWORD=opencade # POSTGRES_DB=opencade # RFC 8489 server as a numeric IP:port. Both variables should identify the same service. # OPENCADE_STUN_SERVER=203.0.113.10:3478 -# RELAY_URL=wss://relay.example.com/relay diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d3f90f..24c0c2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,8 +117,41 @@ jobs: - name: Docker Compose configuration run: docker compose config env: + POSTGRES_PASSWORD: ci-postgres-password SESSION_SECRET: ci-compose-secret-at-least-32-bytes RELAY_AUTH_SECRET: ci-relay-secret-at-least-32-bytes-long + OPERATOR_TOKEN: ci-operator-token-at-least-32-bytes-long + PUBLIC_RELAY_URL: wss://relay.example.test/relay + STUN_HOST: stun.example.test + + security: + name: Dependency and container security + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.98" + - name: Audit Rust dependencies + run: | + cargo install cargo-audit --locked + cargo audit --deny warnings + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Audit production JavaScript dependencies + run: pnpm audit --prod --audit-level high + - name: Build production containers + run: | + docker build -f apps/server/Dockerfile -t opencade-server:ci . + docker build -f services/relay/Dockerfile -t opencade-relay:ci . desktop: name: Desktop Rust (Windows / MSRV) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7337e44 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.98" + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: cargo build -p opencade-networking --bins --release --locked + - run: cargo build --manifest-path apps/client/src-tauri/Cargo.toml --release --locked + - name: Require and import Windows signing certificate + shell: pwsh + env: + WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + if (-not $env:WINDOWS_CERTIFICATE_BASE64 -or -not $env:WINDOWS_CERTIFICATE_PASSWORD) { + throw "Windows signing secrets are required for a release" + } + $certificatePath = Join-Path $env:RUNNER_TEMP "opencade-signing.pfx" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64)) + $certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) + $env:OPENCADE_CERT_THUMBPRINT = $certificate.Thumbprint + "OPENCADE_CERT_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Sign and package alpha kit + shell: pwsh + run: | + $signTool = (Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1).FullName + if (-not $signTool) { throw "signtool.exe was not found" } + $executables = @( + "apps\client\src-tauri\target\release\opencade-client.exe", + "target\release\opencade-match-probe.exe", + "target\release\opencade-match-verify.exe", + "target\release\opencade-alpha-summary.exe" + ) + foreach ($executable in $executables) { + & $signTool sign /sha1 $env:OPENCADE_CERT_THUMBPRINT /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $executable + if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed: $executable" } + & $signTool verify /pa /all $executable + if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed: $executable" } + } + .\scripts\alpha\OpenCade-Alpha.ps1 -Mode Package -KitRoot release-kit + Compress-Archive -Path release-kit\* -DestinationPath "opencade-$env:GITHUB_REF_NAME-windows-x64.zip" + (Get-FileHash "opencade-$env:GITHUB_REF_NAME-windows-x64.zip" -Algorithm SHA256).Hash.ToLower() + " opencade-$env:GITHUB_REF_NAME-windows-x64.zip" | Out-File "SHA256SUMS.txt" -Encoding ascii + - uses: actions/attest-build-provenance@v2 + with: + subject-path: opencade-*-windows-x64.zip + - name: Publish immutable GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/$GITHUB_REPOSITORY/dependency-graph/sbom" > sbom.spdx.json + gh release create "$GITHUB_REF_NAME" opencade-*-windows-x64.zip SHA256SUMS.txt sbom.spdx.json --verify-tag --generate-notes diff --git a/Cargo.lock b/Cargo.lock index b6970b9..8ddcf7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,6 +1127,7 @@ dependencies = [ "axum", "dashmap", "futures", + "opencade-networking", "opencade-protocol", "opencade-shared", "serde", @@ -1148,6 +1149,7 @@ dependencies = [ "dashmap", "futures-util", "hex", + "opencade-networking", "opencade-protocol", "opencade-shared", "rand_core 0.6.4", @@ -1163,6 +1165,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -1175,6 +1178,7 @@ dependencies = [ "serde", "sha2 0.11.0", "thiserror", + "uuid", ] [[package]] diff --git a/README.md b/README.md index 33e54cf..6ba33ef 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ curl http://localhost:8080/health docker compose logs -f opencade-server ``` -This starts `opencade-server`, PostgreSQL, and the authenticated readiness-probe relay. The server applies committed SQLx migrations before it begins serving. +This starts PostgreSQL, runs committed SQLx migrations as a one-shot job, then starts `opencade-server` and the authenticated readiness-probe relay. See [the production runbook](docs/operations/RUNBOOK.md) for backup, restore, deployment, and rollback procedures. ```bash # stop diff --git a/apps/client/src-tauri/Cargo.lock b/apps/client/src-tauri/Cargo.lock index 7ab1985..ebf55ab 100644 --- a/apps/client/src-tauri/Cargo.lock +++ b/apps/client/src-tauri/Cargo.lock @@ -2813,6 +2813,7 @@ dependencies = [ "serde", "sha2 0.11.0", "thiserror 2.0.20", + "uuid", ] [[package]] diff --git a/apps/client/src-tauri/src/commands/match_probe.rs b/apps/client/src-tauri/src/commands/match_probe.rs index a65128d..39bfd3a 100644 --- a/apps/client/src-tauri/src/commands/match_probe.rs +++ b/apps/client/src-tauri/src/commands/match_probe.rs @@ -14,6 +14,9 @@ use tokio::sync::{Mutex, watch}; use uuid::Uuid; const MAX_ACTIVE_PROBES: usize = 8; +const MIN_PROBE_TIMEOUT_MS: u64 = 500; +const MAX_PROBE_TIMEOUT_MS: u64 = 15_000; +const MAX_PROBE_FRAMES: u64 = 600; #[derive(Default)] pub struct MatchProbeState { @@ -166,6 +169,7 @@ pub async fn run_reserved_match_probe( request: RunMatchProbeRequest, ) -> Result { validate_room_id(&request.room_id)?; + validate_probe_limits(request.frame_count, request.timeout_ms)?; let room_id = request.room_id.clone(); let run_id = Uuid::new_v4(); let (cancel_tx, cancel_rx) = watch::channel(false); @@ -251,14 +255,35 @@ pub async fn run_reserved_match_probe( #[tauri::command] pub async fn run_relay_match_probe_command( + state: tauri::State<'_, MatchProbeState>, request: RunRelayMatchProbeRequest, ) -> Result { validate_room_id(&request.room_id)?; + validate_probe_limits(request.frame_count, request.timeout_ms)?; if request.ticket.room_id != request.room_id || request.ticket.user_id != request.local_user_id { return Err("relay ticket does not match the local room participant".into()); } let session_key = combined_session_key(&request.local_nonce, &request.peer_nonce)?; + let room_id = request.room_id.clone(); + let run_id = Uuid::new_v4(); + let (cancel_tx, cancel_rx) = watch::channel(false); + { + let mut probes = state.probes.lock().await; + if probes.contains_key(&room_id) { + return Err("match probe is already active for this room".into()); + } + if probes.len() >= MAX_ACTIVE_PROBES { + return Err("too many active match probes; cancel an older room first".into()); + } + probes.insert( + room_id.clone(), + MatchProbeSlot::Running { + run_id, + cancel: cancel_tx, + }, + ); + } let descriptor = MatchDescriptor { room_id: request.room_id, game_id: request.game_id, @@ -277,12 +302,17 @@ pub async fn run_relay_match_probe_command( Duration::from_millis(request.timeout_ms), ) .map_err(|error| error.to_string())?; - let peer = RelayPeer::connect(&request.relay_url, &request.ticket) - .await - .map_err(|error| error.to_string())?; - let report = run_relay_match_probe(&peer, &config) - .await - .map_err(|error| error.to_string())?; + let probe = async { + let peer = RelayPeer::connect(&request.relay_url, &request.ticket) + .await + .map_err(|error| error.to_string())?; + run_relay_match_probe(&peer, &config) + .await + .map_err(|error| error.to_string()) + }; + let result = cancel_or_complete(probe, cancel_rx).await; + remove_running_probe(&state, &room_id, run_id).await; + let report = result?; tracing::info!( room_id = %report.room_id, frames = report.frames_received, @@ -292,6 +322,32 @@ pub async fn run_relay_match_probe_command( Ok(report) } +fn validate_probe_limits(frame_count: u64, timeout_ms: u64) -> Result<(), String> { + if !(1..=MAX_PROBE_FRAMES).contains(&frame_count) { + return Err(format!( + "frame_count must be between 1 and {MAX_PROBE_FRAMES}" + )); + } + if !(MIN_PROBE_TIMEOUT_MS..=MAX_PROBE_TIMEOUT_MS).contains(&timeout_ms) { + return Err(format!( + "timeout_ms must be between {MIN_PROBE_TIMEOUT_MS} and {MAX_PROBE_TIMEOUT_MS}" + )); + } + Ok(()) +} + +impl MatchProbeState { + pub fn shutdown_all(&self) { + if let Ok(mut probes) = self.probes.try_lock() { + for (_, slot) in probes.drain() { + if let MatchProbeSlot::Running { cancel, .. } = slot { + let _ = cancel.send(true); + } + } + } + } +} + #[tauri::command] pub async fn cancel_match_probe( state: tauri::State<'_, MatchProbeState>, diff --git a/apps/client/src-tauri/src/commands/mod.rs b/apps/client/src-tauri/src/commands/mod.rs index 57cd914..92e4065 100644 --- a/apps/client/src-tauri/src/commands/mod.rs +++ b/apps/client/src-tauri/src/commands/mod.rs @@ -4,3 +4,4 @@ pub mod match_probe; pub mod process; pub mod runtime; pub mod session; +pub mod tunnel; diff --git a/apps/client/src-tauri/src/commands/process.rs b/apps/client/src-tauri/src/commands/process.rs index c86c45c..7d7b02c 100644 --- a/apps/client/src-tauri/src/commands/process.rs +++ b/apps/client/src-tauri/src/commands/process.rs @@ -53,8 +53,6 @@ fn validate_game_id(game_id: &str) -> Result<(), String> { #[derive(Debug, Deserialize)] pub struct RetroarchMatchRequest { - api_url: String, - session_token: String, launch_grant: String, } @@ -88,6 +86,42 @@ pub struct RetroarchMatchLaunch { fingerprint: CompatibilityFingerprint, } +#[derive(Debug, Serialize)] +pub struct RetroarchPreflight { + adapter: &'static str, + emulator_version: Option, + executable_sha256: String, + core_sha256: String, + content_sha256: String, + native_port_available: bool, +} + +#[tauri::command] +pub async fn retroarch_preflight( + app: tauri::AppHandle, + game_id: String, +) -> Result { + validate_game_id(&game_id)?; + let root = retroarch_root(&app)?; + let adapter = RetroarchAdapter::new(&root); + let rom = root.join("ROMs").join(format!("{game_id}.zip")); + let fingerprint = tokio::task::spawn_blocking(move || adapter.fingerprint(&rom)) + .await + .map_err(|_| "compatibility preflight worker failed".to_string())? + .map_err(|error| error.to_string())?; + let native_port_available = + ensure_native_port_available("0.0.0.0:55435".parse().expect("static socket address")) + .is_ok(); + Ok(RetroarchPreflight { + adapter: "retroarch_fbneo", + emulator_version: fingerprint.retroarch_version, + executable_sha256: fingerprint.executable_sha256, + core_sha256: fingerprint.core_sha256, + content_sha256: fingerprint.content_sha256, + native_port_available, + }) +} + #[derive(Debug, Clone, Serialize)] struct EmulatorExitEvent { pid: u32, @@ -126,9 +160,11 @@ pub fn launch_game( pub async fn launch_retroarch_match( app: tauri::AppHandle, state: tauri::State<'_, ProcessState>, + runtime: tauri::State<'_, crate::commands::runtime::RuntimeConfig>, request: RetroarchMatchRequest, ) -> Result { - let authorized = consume_launch_grant(&request).await?; + let session_token = crate::commands::session::require_session_token()?; + let authorized = consume_launch_grant(&runtime.api_url, &session_token, &request).await?; validate_game_id(&authorized.game_id)?; let root = retroarch_root(&app)?; let adapter = RetroarchAdapter::new(&root); @@ -163,8 +199,8 @@ pub async fn launch_retroarch_match( .map_err(|error| error.to_string())?; let room_id = authorized.room_id; let callback = NativeExitCallback { - api_url: request.api_url, - session_token: request.session_token, + api_url: runtime.api_url.clone(), + session_token, room_id: room_id.clone(), }; let pid = register_child(&app, &state, child, Some(callback))?; @@ -183,8 +219,10 @@ pub fn stop_game(state: tauri::State<'_, ProcessState>, pid: u32) -> Result<(), .lock() .map_err(|_| "process registry unavailable".to_string())? .get(&pid) - .cloned() - .ok_or_else(|| "emulator process not found".to_string())?; + .cloned(); + let Some(child) = child else { + return Ok(()); + }; child .lock() .map_err(|_| "emulator process unavailable".to_string())? @@ -193,27 +231,17 @@ pub fn stop_game(state: tauri::State<'_, ProcessState>, pid: u32) -> Result<(), Ok(()) } -async fn consume_launch_grant(request: &RetroarchMatchRequest) -> Result { - let base = reqwest::Url::parse(&request.api_url).map_err(|_| "OpenCade API URL is invalid")?; - if !matches!(base.scheme(), "http" | "https") - || !base.username().is_empty() - || base.password().is_some() - || base.query().is_some() - || base.fragment().is_some() - { - return Err("OpenCade API URL is not allowed".into()); - } - if base.scheme() == "http" - && !matches!(base.host_str(), Some("localhost" | "127.0.0.1" | "::1")) - { - return Err("remote OpenCade API URLs must use HTTPS".into()); - } - if request.session_token.is_empty() || request.launch_grant.is_empty() { +async fn consume_launch_grant( + api_url: &str, + session_token: &str, + request: &RetroarchMatchRequest, +) -> Result { + if session_token.is_empty() || request.launch_grant.is_empty() { return Err("authenticated launch grant is required".into()); } let endpoint = format!( "{}/api/v1/match-launch-grants/consume", - request.api_url.trim_end_matches('/') + api_url.trim_end_matches('/') ); let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) @@ -221,7 +249,7 @@ async fn consume_launch_grant(request: &RetroarchMatchRequest) -> Result registry, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("process registry unavailable".into()); + } + }; let child = Arc::new(Mutex::new(child)); - state - .children - .lock() - .map_err(|_| "process registry unavailable".to_string())? - .insert(pid, Arc::clone(&child)); + if registry.contains_key(&pid) { + if let Ok(mut child) = child.lock() { + let _ = child.kill(); + let _ = child.wait(); + } + return Err("process id is already supervised".into()); + } + registry.insert(pid, Arc::clone(&child)); + drop(registry); let app = app.clone(); std::thread::spawn(move || { @@ -321,6 +361,21 @@ fn register_child( Ok(pid) } +impl ProcessState { + pub fn shutdown_all(&self) { + let children = match self.children.lock() { + Ok(mut registry) => registry.drain().map(|(_, child)| child).collect::>(), + Err(_) => return, + }; + for child in children { + if let Ok(mut child) = child.lock() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + fn report_native_exit(callback: &NativeExitCallback, exit_code: Option) { let endpoint = format!( "{}/api/v1/rooms/{}/finish", diff --git a/apps/client/src-tauri/src/commands/runtime.rs b/apps/client/src-tauri/src/commands/runtime.rs index 2be02f6..a5e0e65 100644 --- a/apps/client/src-tauri/src/commands/runtime.rs +++ b/apps/client/src-tauri/src/commands/runtime.rs @@ -1,29 +1,35 @@ use serde::Serialize; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct RuntimeConfig { - api_url: String, + pub(crate) api_url: String, stun_server: Option, } -#[tauri::command] -pub fn runtime_config() -> Result { - let api_url = - std::env::var("OPENCADE_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); - validate_api_url(&api_url)?; - let stun_server = std::env::var("OPENCADE_STUN_SERVER") - .ok() - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()); - if let Some(server) = &stun_server { - server - .parse::() - .map_err(|_| "OPENCADE_STUN_SERVER must be a numeric IP:port".to_string())?; +impl RuntimeConfig { + pub fn from_env() -> Result { + let api_url = std::env::var("OPENCADE_API_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + validate_api_url(&api_url)?; + let stun_server = std::env::var("OPENCADE_STUN_SERVER") + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + if let Some(server) = &stun_server { + server + .parse::() + .map_err(|_| "OPENCADE_STUN_SERVER must be a numeric IP:port".to_string())?; + } + Ok(RuntimeConfig { + api_url: api_url.trim_end_matches('/').to_owned(), + stun_server, + }) } - Ok(RuntimeConfig { - api_url: api_url.trim_end_matches('/').to_owned(), - stun_server, - }) +} + +#[tauri::command] +pub fn runtime_config(state: tauri::State<'_, RuntimeConfig>) -> RuntimeConfig { + state.inner().clone() } fn validate_api_url(value: &str) -> Result<(), String> { diff --git a/apps/client/src-tauri/src/commands/session.rs b/apps/client/src-tauri/src/commands/session.rs index 9298c2f..c27f232 100644 --- a/apps/client/src-tauri/src/commands/session.rs +++ b/apps/client/src-tauri/src/commands/session.rs @@ -33,6 +33,10 @@ pub fn load_session_token() -> Result, String> { } } +pub(crate) fn require_session_token() -> Result { + load_session_token()?.ok_or_else(|| "an authenticated desktop session is required".into()) +} + #[tauri::command] pub fn clear_session_token() -> Result<(), String> { match entry()?.delete_credential() { diff --git a/apps/client/src-tauri/src/commands/tunnel.rs b/apps/client/src-tauri/src/commands/tunnel.rs new file mode 100644 index 0000000..417b868 --- /dev/null +++ b/apps/client/src-tauri/src/commands/tunnel.rs @@ -0,0 +1,191 @@ +use opencade_networking::run_native_tcp_tunnel; +use opencade_shared::{RelayCapability, RelayTicket}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::{net::TcpListener, task::JoinHandle}; + +#[derive(Default)] +pub struct NativeTunnelState { + tasks: Arc>>, +} + +struct TunnelTask { + run_id: uuid::Uuid, + handle: JoinHandle<()>, +} + +#[derive(Debug, Deserialize)] +pub struct StartNativeTunnelRequest { + relay_url: String, + ticket: RelayTicket, + mode: TunnelMode, + local_endpoint: SocketAddr, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum TunnelMode { + Listen, + Connect, +} + +#[derive(Debug, Serialize)] +pub struct NativeTunnelStarted { + room_id: String, + local_endpoint: SocketAddr, +} + +#[tauri::command] +pub async fn start_native_tcp_tunnel( + state: tauri::State<'_, NativeTunnelState>, + request: StartNativeTunnelRequest, +) -> Result { + if request.ticket.capability != RelayCapability::NativeTcpTunnel { + return Err("native tunnel requires a capability-scoped ticket".into()); + } + if !request.local_endpoint.ip().is_loopback() { + return Err("native tunnel endpoints must be loopback-only".into()); + } + let room_id = request.ticket.room_id.clone(); + let run_id = uuid::Uuid::new_v4(); + let relay_url = request.relay_url; + let ticket = request.ticket; + let tasks = Arc::clone(&state.tasks); + let cleanup_room_id = room_id.clone(); + let (local_endpoint, task, start_tx) = match request.mode { + TunnelMode::Listen => { + let (start_tx, start_rx) = tokio::sync::oneshot::channel(); + let listener = TcpListener::bind(request.local_endpoint) + .await + .map_err(|_| "native tunnel listener is unavailable".to_string())?; + let address = listener + .local_addr() + .map_err(|_| "native tunnel address is unavailable".to_string())?; + let task = tokio::spawn(async move { + if start_rx.await.is_err() { + return; + } + let Ok(Ok((stream, _))) = + tokio::time::timeout(ticket_deadline(&ticket), listener.accept()).await + else { + tracing::warn!(room_id = %cleanup_room_id, "native TCP listener deadline elapsed"); + remove_completed_task(&tasks, &cleanup_room_id, run_id); + return; + }; + if let Err(error) = run_native_tcp_tunnel(stream, &relay_url, &ticket).await { + tracing::warn!(%error, "native TCP tunnel stopped"); + } + remove_completed_task(&tasks, &cleanup_room_id, run_id); + }); + (address, task, start_tx) + } + TunnelMode::Connect => { + let (start_tx, start_rx) = tokio::sync::oneshot::channel(); + let address = request.local_endpoint; + let task = tokio::spawn(async move { + if start_rx.await.is_err() { + return; + } + let deadline = tokio::time::Instant::now() + + ticket_deadline(&ticket).min(Duration::from_secs(10)); + let stream = loop { + if tokio::time::Instant::now() >= deadline { + tracing::warn!(room_id = %cleanup_room_id, "native TCP connect deadline elapsed"); + remove_completed_task(&tasks, &cleanup_room_id, run_id); + return; + } + match tokio::net::TcpStream::connect(address).await { + Ok(stream) => break stream, + Err(_) => tokio::time::sleep(Duration::from_millis(200)).await, + } + }; + if let Err(error) = run_native_tcp_tunnel(stream, &relay_url, &ticket).await { + tracing::warn!(%error, "native TCP tunnel stopped"); + } + remove_completed_task(&tasks, &cleanup_room_id, run_id); + }); + (address, task, start_tx) + } + }; + let mut tasks = state + .tasks + .lock() + .map_err(|_| "native tunnel registry is unavailable".to_string())?; + if let Some(previous) = tasks.insert( + room_id.clone(), + TunnelTask { + run_id, + handle: task, + }, + ) { + previous.handle.abort(); + } + let _ = start_tx.send(()); + Ok(NativeTunnelStarted { + room_id, + local_endpoint, + }) +} + +fn ticket_deadline(ticket: &RelayTicket) -> Duration { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or(ticket.expires_at); + Duration::from_secs(u64::try_from(ticket.expires_at.saturating_sub(now)).unwrap_or_default()) +} + +#[tauri::command] +pub fn stop_native_tcp_tunnel( + state: tauri::State<'_, NativeTunnelState>, + room_id: String, +) -> Result<(), String> { + if let Some(task) = state + .tasks + .lock() + .map_err(|_| "native tunnel registry is unavailable".to_string())? + .remove(&room_id) + { + task.handle.abort(); + } + Ok(()) +} + +fn remove_completed_task( + tasks: &Mutex>, + room_id: &str, + run_id: uuid::Uuid, +) { + if let Ok(mut tasks) = tasks.lock() + && tasks.get(room_id).is_some_and(|task| task.run_id == run_id) + { + tasks.remove(room_id); + } +} + +impl NativeTunnelState { + pub fn shutdown_all(&self) { + if let Ok(mut tasks) = self.tasks.lock() { + for (_, task) in tasks.drain() { + task.handle.abort(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tunnel_modes_are_explicit() { + assert!(matches!(TunnelMode::Listen, TunnelMode::Listen)); + assert!(matches!(TunnelMode::Connect, TunnelMode::Connect)); + } +} diff --git a/apps/client/src-tauri/src/main.rs b/apps/client/src-tauri/src/main.rs index f3ae482..033b226 100644 --- a/apps/client/src-tauri/src/main.rs +++ b/apps/client/src-tauri/src/main.rs @@ -2,15 +2,22 @@ mod commands; +use tauri::Manager; + fn main() { tracing_subscriber::fmt::init(); - tauri::Builder::default() + let runtime = commands::runtime::RuntimeConfig::from_env() + .expect("invalid OpenCade runtime configuration"); + let app = tauri::Builder::default() + .manage(runtime) .manage(commands::process::ProcessState::default()) .manage(commands::match_probe::MatchProbeState::default()) + .manage(commands::tunnel::NativeTunnelState::default()) .invoke_handler(tauri::generate_handler![ commands::fs::scan_game, commands::process::launch_game, commands::process::launch_retroarch_match, + commands::process::retroarch_preflight, commands::process::stop_game, commands::diag::network_test, commands::match_probe::reserve_match_probe, @@ -21,7 +28,25 @@ fn main() { commands::session::store_session_token, commands::session::load_session_token, commands::session::clear_session_token, + commands::tunnel::start_native_tcp_tunnel, + commands::tunnel::stop_native_tcp_tunnel, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + app.run(|handle, event| { + if matches!( + event, + tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } + ) { + handle + .state::() + .shutdown_all(); + handle + .state::() + .shutdown_all(); + handle + .state::() + .shutdown_all(); + } + }); } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 5112a3b..abd6850 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,15 +1,18 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { Envelope, MatchEndpointPayload, MatchProbeCompletedPayload, + RoomPayload, + RoomSnapshotPayload, } from "@opencade/protocol"; import DiagnosticsButton from "./components/DiagnosticsButton"; import { ApiError, api, configureApiBase } from "./lib/api"; import { parseMatchCompletion, parseMatchEndpoint } from "./lib/match"; import { cancelMatchProbe, loadRuntimeConfig } from "./lib/native"; import { useSessionStore } from "./lib/store"; +import { newestSnapshot } from "./lib/snapshot"; import { OpenCadeSocket, type ConnectionState } from "./lib/ws"; import Auth from "./routes/Auth"; import Games from "./routes/Games"; @@ -20,7 +23,8 @@ type View = { name: "games" } | { name: "lobby"; gameId: string } | { name: "match"; roomId: string }; export default function App() { - const { token, user, hydrated, hydrate, setSession, clearSession } = useSessionStore(); + const { token, user, hydrated, activeRoomId, hydrate, setSession, clearSession, setActiveRoom } = + useSessionStore(); const queryClient = useQueryClient(); const [view, setView] = useState({ name: "games" }); const [connection, setConnection] = useState("idle"); @@ -29,11 +33,19 @@ export default function App() { const [peerCompletion, setPeerCompletion] = useState(null); const [apiUrl, setApiUrl] = useState(null); const [runtimeError, setRuntimeError] = useState(null); + const activeRoom = useRef(null); + activeRoom.current = view.name === "match" ? view.roomId : null; useEffect(() => { void hydrate(); }, [hydrate]); + useEffect(() => { + if (hydrated && activeRoomId && view.name === "games") { + setView({ name: "match", roomId: activeRoomId }); + } + }, [activeRoomId, hydrated, view.name]); + useEffect(() => { let cancelled = false; void loadRuntimeConfig() @@ -70,12 +82,19 @@ export default function App() { const socket = new OpenCadeSocket(apiUrl, token, setConnection); setActiveSocket(socket); const unsubscribe = socket.subscribe((message: Envelope) => { + if (message.type === "connection.hello" && activeRoom.current) { + void queryClient.invalidateQueries({ queryKey: ["room", activeRoom.current] }); + void queryClient.invalidateQueries({ queryKey: ["room-snapshot", activeRoom.current] }); + } if (message.type.startsWith("challenge.")) { void queryClient.invalidateQueries({ queryKey: ["challenges"] }); } if (message.type === "challenge.accepted") { const roomId = roomIdFromPayload(message.payload); - if (roomId) setView({ name: "match", roomId }); + if (roomId) { + setActiveRoom(roomId); + setView({ name: "match", roomId }); + } } if (message.type === "match.endpoint") { const endpoint = parseMatchEndpoint(message.payload); @@ -87,6 +106,17 @@ export default function App() { const completion = parseMatchCompletion(message.payload); if (completion) setPeerCompletion(completion); } + if (message.type === "room.state" && isRoomPayload(message.payload)) { + queryClient.setQueryData(["room", message.payload.id], message.payload); + } + if (message.type === "room.snapshot" && isRoomSnapshot(message.payload)) { + const snapshot = message.payload; + queryClient.setQueryData( + ["room-snapshot", snapshot.room.id], + (current) => newestSnapshot(current, snapshot) + ); + queryClient.setQueryData(["room", snapshot.room.id], snapshot.room); + } }); socket.connect(); return () => { @@ -94,7 +124,7 @@ export default function App() { socket.close(); setActiveSocket((current) => (current === socket ? null : current)); }; - }, [apiUrl, token, queryClient]); + }, [apiUrl, token, queryClient, setActiveRoom]); if (runtimeError) { return ( @@ -121,7 +151,10 @@ export default function App() { } const logout = async () => { try { - if (view.name === "match") await cancelMatchProbe(view.roomId); + if (view.name === "match") { + await cancelMatchProbe(view.roomId); + await api.cancelRoom(token, view.roomId).catch(() => undefined); + } await api.logout(token); } finally { clearSession(); @@ -131,12 +164,20 @@ export default function App() { if (view.name === "match") void cancelMatchProbe(view.roomId); setPeerEndpoint(null); setPeerCompletion(null); + setActiveRoom(null); setView({ name: "games" }); }; return (
- @@ -144,7 +185,12 @@ export default function App() { {connection} {user.username} -
@@ -159,7 +205,10 @@ export default function App() { userId={user.id} gameId={view.gameId} onBack={() => setView({ name: "games" })} - onMatch={(roomId) => setView({ name: "match", roomId })} + onMatch={(roomId) => { + setActiveRoom(roomId); + setView({ name: "match", roomId }); + }} /> )} {view.name === "match" && ( @@ -182,6 +231,50 @@ export default function App() { ); } +function isRoomPayload(payload: unknown): payload is RoomPayload { + const states = new Set([ + "waiting", + "ready", + "challenging", + "connecting", + "playing", + "finished", + "cancelled", + ]); + return ( + typeof payload === "object" && + payload !== null && + typeof Reflect.get(payload, "id") === "string" && + typeof Reflect.get(payload, "game_id") === "string" && + typeof Reflect.get(payload, "host_id") === "string" && + (Reflect.get(payload, "guest_id") === null || + typeof Reflect.get(payload, "guest_id") === "string") && + states.has(String(Reflect.get(payload, "state"))) + ); +} + +function isRoomSnapshot(payload: unknown): payload is RoomSnapshotPayload { + const barrier = + typeof payload === "object" && payload !== null ? Reflect.get(payload, "barrier") : null; + return ( + typeof payload === "object" && + payload !== null && + isRoomPayload(Reflect.get(payload, "room")) && + Number.isSafeInteger(Reflect.get(payload, "revision")) && + Number.isSafeInteger(Reflect.get(payload, "preflight_count")) && + typeof Reflect.get(payload, "compatibility_matched") === "boolean" && + typeof barrier === "object" && + barrier !== null && + typeof Reflect.get(barrier, "room_id") === "string" && + Number.isSafeInteger(Reflect.get(barrier, "ready_count")) && + Number.isSafeInteger(Reflect.get(barrier, "required_count")) && + (Reflect.get(barrier, "launch_at") === null || + (typeof Reflect.get(barrier, "launch_at") === "string" && + !Number.isNaN(Date.parse(String(Reflect.get(barrier, "launch_at")))))) && + ["direct_lan", "tcp_tunnel"].includes(String(Reflect.get(payload, "route"))) + ); +} + function roomIdFromPayload(payload: unknown): string | undefined { if (typeof payload !== "object" || payload === null || !("room_id" in payload)) return undefined; const roomId = Reflect.get(payload, "room_id"); diff --git a/apps/client/src/components/MatchReadiness.tsx b/apps/client/src/components/MatchReadiness.tsx new file mode 100644 index 0000000..ced08b8 --- /dev/null +++ b/apps/client/src/components/MatchReadiness.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Game } from "../lib/api"; +import { isDesktopRuntime, retroarchPreflight, runNetworkTest } from "../lib/native"; +import { assessMatchReadiness, type ReadinessState } from "../lib/readiness"; +import { trackProductEvent } from "../lib/telemetry"; + +type Props = { + token: string; + game: Game; + onBack: () => void; + onContinue: () => void; +}; + +export default function MatchReadiness({ token, game, onBack, onContinue }: Props) { + const desktop = isDesktopRuntime(); + const [notice, setNotice] = useState(""); + const preflight = useQuery({ + queryKey: ["match-readiness", "preflight", game.id], + queryFn: () => retroarchPreflight(game.id), + enabled: desktop, + retry: false, + }); + const network = useQuery({ + queryKey: ["match-readiness", "network"], + queryFn: runNetworkTest, + enabled: desktop, + retry: false, + staleTime: 30_000, + }); + const assessment = assessMatchReadiness({ + desktop, + controlPlaneReady: true, + preflightStatus: queryStatus(desktop, preflight.status), + preflight: preflight.data, + networkStatus: queryStatus(desktop, network.status), + network: network.data, + }); + const continueToLobby = () => { + if (assessment.canContinue) { + void trackProductEvent(token, "readiness_completed", game.id).catch(() => undefined); + onContinue(); + return; + } + const blockedChecks = assessment.checks + .filter((check) => check.required && check.state === "blocked") + .map((check) => check.id); + if (blockedChecks.length > 0) { + void trackProductEvent(token, "readiness_blocked", game.id, blockedChecks).catch( + () => undefined + ); + } + setNotice("Resolve the blocked requirements before entering the lobby."); + }; + const retry = async () => { + if (!desktop) { + setNotice("Open the desktop client to run native readiness checks."); + return; + } + setNotice("Running the readiness checks again…"); + await Promise.all([preflight.refetch(), network.refetch()]); + setNotice("Readiness checks updated."); + }; + + return ( +
+ +
+
+

Match readiness · {game.name}

+

Prepare this machine

+

+ Complete the required checks before another player commits to the match. +

+
+ + {assessment.readyRequired}/{assessment.requiredTotal} required checks ready + +
+
    + {assessment.checks.map((check) => ( +
  1. + +
    +

    {check.title}

    +

    {check.detail}

    +
    + + {stateLabel(check.state)} + {!check.required && " · advisory"} + +
  2. + ))} +
+

+ {notice || + (assessment.canContinue + ? "This machine is ready to enter the lobby." + : "Blocked checks include a specific recovery action.")} +

+
+ + +
+
+ ); +} + +function queryStatus( + enabled: boolean, + status: "pending" | "error" | "success" +): "pending" | "error" | "success" { + return enabled ? status : "error"; +} + +function stateLabel(state: ReadinessState): string { + switch (state) { + case "ready": + return "Ready"; + case "warning": + return "Check advised"; + case "blocked": + return "Action required"; + case "pending": + return "Checking"; + } +} + +function stateSymbol(state: ReadinessState): string { + switch (state) { + case "ready": + return "✓"; + case "warning": + return "!"; + case "blocked": + return "×"; + case "pending": + return "…"; + } +} diff --git a/apps/client/src/components/TelemetryConsent.tsx b/apps/client/src/components/TelemetryConsent.tsx new file mode 100644 index 0000000..d3925fd --- /dev/null +++ b/apps/client/src/components/TelemetryConsent.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { getTelemetryConsent, setTelemetryConsent } from "../lib/telemetry"; + +export default function TelemetryConsent() { + const [consent, setConsent] = useState(() => getTelemetryConsent()); + const choose = (next: boolean) => { + if (setTelemetryConsent(next)) setConsent(next); + }; + + return ( +
+
+

Privacy control

+

Anonymous product telemetry

+

+ Share game selection and readiness outcomes to help improve the match setup. OpenFight + never sends usernames, ROM paths, network endpoints, or error text through telemetry. +

+
+
+ + +
+ + {consent === null + ? "No choice saved · sharing is off" + : consent + ? "Sharing on" + : "Sharing off"} + +
+ ); +} diff --git a/apps/client/src/lib/api.test.ts b/apps/client/src/lib/api.test.ts index 42624a8..5a15a2f 100644 --- a/apps/client/src/lib/api.test.ts +++ b/apps/client/src/lib/api.test.ts @@ -11,7 +11,13 @@ describe("runtime API configuration", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: async () => ({ type: "games.list", payload: { games: [] } }), + json: async () => ({ + type: "games.list", + version: "1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + payload: { games: [] }, + }), }); vi.stubGlobal("fetch", fetchMock); configureApiBase("https://alpha.example.com/"); @@ -24,6 +30,18 @@ describe("runtime API configuration", () => { ); }); + it("rejects a successful response with an incomplete wire envelope", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: "games.list", payload: { games: [] } }), + }) + ); + await expect(api.games("session-token")).rejects.toMatchObject({ code: "invalid_response" }); + }); + it("rejects non-http origins and embedded credentials", () => { expect(() => configureApiBase("file:///tmp/server")).toThrow(/HTTP or HTTPS/); expect(() => configureApiBase("https://user:pass@example.com")).toThrow(/credentials/); diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts index a00935a..839de68 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -1,4 +1,14 @@ -import type { Envelope, RoomPayload } from "@opencade/protocol"; +import { validateEnvelope } from "@opencade/protocol"; +import type { + AlphaFailureReport, + Envelope, + MatchPreflightPayload, + MatchReport, + ProductEventPayload, + RoomInvitePayload, + RoomPayload, + RoomSnapshotPayload, +} from "@opencade/protocol"; export type User = { id: string; username: string; email?: string | null }; export type AuthPayload = { user: User; token: string; expires_at: string }; @@ -27,11 +37,12 @@ export type RelayTicket = { room_id: string; user_id: string; expires_at: number; + nonce: string; signature: string; + capability: "probe" | "native_tcp_tunnel"; }; }; export type MatchLaunchGrant = { grant: string; expires_at: string }; - type ErrorPayload = { code?: string; message?: string }; export class ApiError extends Error { @@ -97,7 +108,18 @@ async function request(path: string, token?: string | null, init?: RequestIni } function isEnvelope(value: unknown): value is Envelope { - return typeof value === "object" && value !== null && "payload" in value && "type" in value; + if ( + typeof value !== "object" || + value === null || + typeof Reflect.get(value, "type") !== "string" || + typeof Reflect.get(value, "version") !== "string" || + typeof Reflect.get(value, "request_id") !== "string" || + typeof Reflect.get(value, "timestamp") !== "string" || + Reflect.get(value, "payload") === undefined + ) { + return false; + } + return validateEnvelope(value as Envelope).ok; } function errorPayload(value: unknown): ErrorPayload { @@ -151,12 +173,15 @@ export const api = { token, post({ exit_code: exitCode ?? null }) ), + cancelRoom: (token: string, roomId: string) => + request(`/api/v1/rooms/${roomId}/cancel`, token, post()), createLaunchGrant: ( token: string, roomId: string, localEndpoint: string, peerEndpoint: string, - inputDelayFrames = 2 + inputDelayFrames = 2, + campaignMode = true ) => request( `/api/v1/rooms/${roomId}/launch-grant`, @@ -165,8 +190,33 @@ export const api = { local_endpoint: localEndpoint, peer_endpoint: peerEndpoint, input_delay_frames: inputDelayFrames, + campaign_mode: campaignMode, }) ), relayTicket: (token: string, roomId: string) => request(`/api/v1/rooms/${roomId}/relay-ticket`, token, post()), + nativeTunnelTicket: (token: string, roomId: string) => + request(`/api/v1/rooms/${roomId}/native-tunnel-ticket`, token, post()), + createInvite: (token: string, roomId: string) => + request(`/api/v1/rooms/${roomId}/invite`, token, post()), + joinInvite: (token: string, code: string) => + request("/api/v1/invites/join", token, post({ code })), + submitPreflight: (token: string, roomId: string, payload: MatchPreflightPayload) => + request(`/api/v1/rooms/${roomId}/preflight`, token, post(payload)), + roomSnapshot: (token: string, roomId: string) => + request(`/api/v1/rooms/${roomId}/snapshot`, token), + readyToLaunch: (token: string, roomId: string) => + request(`/api/v1/rooms/${roomId}/ready`, token, post()), + submitEvidence: (token: string, evidence: MatchReport | AlphaFailureReport) => + request<{ digest: string; duplicate: boolean }>( + "/api/v1/alpha/evidence", + token, + post({ evidence }) + ), + recordProductEvent: (token: string, event: ProductEventPayload) => + request<{ accepted: boolean; duplicate: boolean }>( + "/api/v1/telemetry/events", + token, + post(event) + ), }; diff --git a/apps/client/src/lib/match.test.ts b/apps/client/src/lib/match.test.ts index 8402929..fc68d54 100644 --- a/apps/client/src/lib/match.test.ts +++ b/apps/client/src/lib/match.test.ts @@ -5,6 +5,7 @@ import { nativeLanEndpoint, parseMatchCompletion, parseMatchEndpoint, + selectNativeRoute, } from "./match"; const room = (guestId: string | null = "guest"): RoomPayload => ({ @@ -106,3 +107,41 @@ describe("nativeLanEndpoint", () => { expect(nativeLanEndpoint("[::1]:42000")).toBe("[::1]:55435"); }); }); + +describe("selectNativeRoute", () => { + it("selects the room host deterministically from either perspective", () => { + expect( + selectNativeRoute( + room(), + "host", + "192.168.1.10:40000", + "192.168.1.20:40001", + "direct_udp", + "host" + ).host + ).toBe("192.168.1.10:55435"); + expect( + selectNativeRoute( + room(), + "guest", + "192.168.1.20:40001", + "192.168.1.10:40000", + "direct_udp", + "host" + ).host + ).toBe("192.168.1.10:55435"); + }); + + it("never promotes readiness-only routes to playable routes", () => { + expect(() => + selectNativeRoute( + room(), + "host", + "192.168.1.10:40000", + "203.0.113.20:50000", + "relay", + "reflexive" + ) + ).toThrow("same-LAN"); + }); +}); diff --git a/apps/client/src/lib/match.ts b/apps/client/src/lib/match.ts index faa9bd6..4ecc77a 100644 --- a/apps/client/src/lib/match.ts +++ b/apps/client/src/lib/match.ts @@ -72,3 +72,25 @@ export function nativeLanEndpoint(endpoint: string, port = 55_435): string { : parsed.hostname; return `${host}:${port}`; } + +export function selectNativeRoute( + room: RoomPayload, + localUserId: string, + localEndpoint: string, + peerEndpoint: string, + transport: "direct_udp" | "relay", + candidate: "host" | "reflexive" +): { local: string; peer: string; host: string } { + const participants = matchParticipants(room, localUserId); + if (!participants) throw new Error("Native route requires two room members"); + if (transport !== "direct_udp" || candidate !== "host") { + throw new Error("Native route requires a verified same-LAN host candidate"); + } + const local = nativeLanEndpoint(localEndpoint); + const peer = nativeLanEndpoint(peerEndpoint); + return { + local, + peer, + host: participants.role === "host" ? local : peer, + }; +} diff --git a/apps/client/src/lib/matchCoordinator.ts b/apps/client/src/lib/matchCoordinator.ts index c370683..eea6d71 100644 --- a/apps/client/src/lib/matchCoordinator.ts +++ b/apps/client/src/lib/matchCoordinator.ts @@ -9,11 +9,18 @@ export type MatchPhase = | "finished" | "failed"; -export type MatchCoordinatorState = { - phase: MatchPhase; - transport?: "direct_udp" | "relay"; - error?: string; -}; +type Transport = "direct_udp" | "relay"; + +export type MatchCoordinatorState = + | { phase: "probing"; transport?: never; error?: never } + | { + phase: "awaiting_peer" | "ready" | "launching" | "awaiting_peer_launch" | "playing"; + transport: "direct_udp"; + error?: never; + } + | { phase: "relay_probe_only"; transport: "relay"; error?: never } + | { phase: "finished"; transport?: Transport; error?: never } + | { phase: "failed"; transport?: Transport; error: string }; export type MatchCoordinatorEvent = | { @@ -37,33 +44,43 @@ export function transitionMatchCoordinator( event: MatchCoordinatorEvent ): MatchCoordinatorState { if (event.type === "reset") return initialMatchCoordinatorState; - if (event.type === "failed") return { ...state, phase: "failed", error: event.error }; - if (event.type === "room_finished") return { ...state, phase: "finished" }; + if (event.type === "failed") { + return { phase: "failed", transport: state.transport, error: event.error }; + } + if (event.type === "room_finished") return { phase: "finished", transport: state.transport }; switch (state.phase) { case "probing": if (event.type === "probe_verified") { - return { - phase: - event.transport === "direct_udp" && event.candidate === "host" - ? "awaiting_peer" - : "relay_probe_only", - transport: event.transport, - }; + return event.transport === "direct_udp" && event.candidate === "host" + ? { phase: "awaiting_peer", transport: "direct_udp" } + : { phase: "relay_probe_only", transport: "relay" }; } return state; case "awaiting_peer": - return event.type === "peer_transcript_verified" ? { ...state, phase: "ready" } : state; + return event.type === "peer_transcript_verified" + ? { phase: "ready", transport: state.transport } + : state; case "ready": - return event.type === "launch_requested" ? { ...state, phase: "launching" } : state; + return event.type === "launch_requested" + ? { phase: "launching", transport: state.transport } + : state; case "launching": - return event.type === "native_spawned" ? { ...state, phase: "awaiting_peer_launch" } : state; + return event.type === "native_spawned" + ? { phase: "awaiting_peer_launch", transport: state.transport } + : state; case "awaiting_peer_launch": - if (event.type === "room_playing") return { ...state, phase: "playing" }; - if (event.type === "native_exited") return { ...state, phase: "finished" }; + if (event.type === "room_playing") { + return { phase: "playing", transport: state.transport }; + } + if (event.type === "native_exited") { + return { phase: "finished", transport: state.transport }; + } return state; case "playing": - return event.type === "native_exited" ? { ...state, phase: "finished" } : state; + return event.type === "native_exited" + ? { phase: "finished", transport: state.transport } + : state; case "relay_probe_only": case "finished": case "failed": diff --git a/apps/client/src/lib/native.ts b/apps/client/src/lib/native.ts index 1edb632..9f6a0e6 100644 --- a/apps/client/src/lib/native.ts +++ b/apps/client/src/lib/native.ts @@ -38,6 +38,14 @@ export type RetroarchMatchLaunch = { content_sha256: string; }; }; +export type RetroarchPreflight = { + adapter: "retroarch_fbneo"; + emulator_version?: string | null; + executable_sha256: string; + core_sha256: string; + content_sha256: string; + native_port_available: boolean; +}; export type EmulatorExitEvent = { pid: number; room_id?: string | null; @@ -45,6 +53,15 @@ export type EmulatorExitEvent = { success: boolean; }; export type RuntimeConfig = { api_url: string; stun_server?: string | null }; +export type NativeTunnelStarted = { room_id: string; local_endpoint: string }; +export type NativeTunnelTicket = { + room_id: string; + user_id: string; + expires_at: number; + nonce: string; + capability: "native_tcp_tunnel"; + signature: string; +}; let runtimeStunServer = import.meta.env.VITE_STUN_SERVER || undefined; @@ -82,14 +99,17 @@ export async function stopGame(pid: number): Promise { } export async function launchRetroarchMatch(request: { - api_url: string; - session_token: string; launch_grant: string; }): Promise { if (!isDesktopRuntime()) throw new Error("RetroArch netplay requires the desktop client"); return invoke("launch_retroarch_match", { request }); } +export async function retroarchPreflight(gameId: string): Promise { + if (!isDesktopRuntime()) throw new Error("RetroArch preflight requires the desktop client"); + return invoke("retroarch_preflight", { gameId }); +} + export async function onEmulatorExit( handler: (event: EmulatorExitEvent) => void ): Promise { @@ -158,6 +178,7 @@ export async function runRelayMatchProbe(request: { room_id: string; user_id: string; expires_at: number; + nonce: string; signature: string; }; room_id: string; @@ -178,3 +199,18 @@ export async function cancelMatchProbe(roomId: string): Promise { if (!isDesktopRuntime()) return; await invoke("cancel_match_probe", { roomId }); } + +export async function startNativeTcpTunnel(request: { + relay_url: string; + ticket: NativeTunnelTicket; + mode: "listen" | "connect"; + local_endpoint: string; +}): Promise { + if (!isDesktopRuntime()) throw new Error("Native TCP tunnel requires the desktop client"); + return invoke("start_native_tcp_tunnel", { request }); +} + +export async function stopNativeTcpTunnel(roomId: string): Promise { + if (!isDesktopRuntime()) return; + await invoke("stop_native_tcp_tunnel", { roomId }); +} diff --git a/apps/client/src/lib/readiness.test.ts b/apps/client/src/lib/readiness.test.ts new file mode 100644 index 0000000..4eeabf5 --- /dev/null +++ b/apps/client/src/lib/readiness.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { assessMatchReadiness, type ReadinessInput } from "./readiness"; + +const readyInput = (): ReadinessInput => ({ + desktop: true, + controlPlaneReady: true, + preflightStatus: "success", + preflight: { + adapter: "retroarch_fbneo", + emulator_version: "1.22.0", + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + native_port_available: true, + }, + networkStatus: "success", + network: { nat: "mapped", rtt_ms: 24, relay_reachable: true }, +}); + +describe("assessMatchReadiness", () => { + it("allows entry only when every required capability is ready", () => { + const assessment = assessMatchReadiness(readyInput()); + expect(assessment.canContinue).toBe(true); + expect(assessment.readyRequired).toBe(assessment.requiredTotal); + }); + + it("fails closed outside the desktop runtime", () => { + const assessment = assessMatchReadiness({ ...readyInput(), desktop: false }); + expect(assessment.canContinue).toBe(false); + expect(assessment.checks.find((check) => check.id === "desktop")?.state).toBe("blocked"); + }); + + it("blocks a busy native port", () => { + const input = readyInput(); + if (!input.preflight) throw new Error("fixture preflight is missing"); + input.preflight.native_port_available = false; + const assessment = assessMatchReadiness(input); + expect(assessment.canContinue).toBe(false); + expect(assessment.checks.find((check) => check.id === "native_port")?.state).toBe("blocked"); + }); + + it("keeps uncertain internet fallback advisory for same-LAN play", () => { + const assessment = assessMatchReadiness({ + ...readyInput(), + network: { nat: "blocked", rtt_ms: null, relay_reachable: false }, + }); + expect(assessment.canContinue).toBe(true); + expect(assessment.checks.find((check) => check.id === "network")?.state).toBe("warning"); + }); +}); diff --git a/apps/client/src/lib/readiness.ts b/apps/client/src/lib/readiness.ts new file mode 100644 index 0000000..ea71022 --- /dev/null +++ b/apps/client/src/lib/readiness.ts @@ -0,0 +1,159 @@ +import type { NetworkDiagnostics, RetroarchPreflight } from "./native"; + +export type ReadinessState = "pending" | "ready" | "warning" | "blocked"; + +export type ReadinessCheck = { + id: "desktop" | "control_plane" | "game_runtime" | "native_port" | "network"; + title: string; + detail: string; + state: ReadinessState; + required: boolean; +}; + +export type ReadinessInput = { + desktop: boolean; + controlPlaneReady: boolean; + preflightStatus: "pending" | "success" | "error"; + preflight?: RetroarchPreflight; + networkStatus: "pending" | "success" | "error"; + network?: NetworkDiagnostics; +}; + +export type ReadinessAssessment = { + checks: ReadinessCheck[]; + canContinue: boolean; + readyRequired: number; + requiredTotal: number; +}; + +export function assessMatchReadiness(input: ReadinessInput): ReadinessAssessment { + const checks: ReadinessCheck[] = [ + { + id: "desktop", + title: "Desktop runtime", + detail: input.desktop + ? "Native diagnostics and safe emulator launch are available." + : "Install and open the desktop client before joining a playable match.", + state: input.desktop ? "ready" : "blocked", + required: true, + }, + { + id: "control_plane", + title: "Control plane", + detail: input.controlPlaneReady + ? "The authenticated game service is reachable." + : "Reconnect to the OpenFight service, then run the checks again.", + state: input.controlPlaneReady ? "ready" : "blocked", + required: true, + }, + runtimeCheck(input), + portCheck(input), + networkCheck(input), + ]; + const required = checks.filter((check) => check.required); + const readyRequired = required.filter((check) => check.state === "ready").length; + return { + checks, + canContinue: readyRequired === required.length, + readyRequired, + requiredTotal: required.length, + }; +} + +function runtimeCheck(input: ReadinessInput): ReadinessCheck { + if (!input.desktop) { + return { + id: "game_runtime", + title: "Game runtime", + detail: "Runtime validation becomes available in the desktop client.", + state: "blocked", + required: true, + }; + } + if (input.preflightStatus === "pending") { + return { + id: "game_runtime", + title: "Game runtime", + detail: "Checking the emulator, core, and local game content…", + state: "pending", + required: true, + }; + } + if (input.preflightStatus === "error" || !input.preflight) { + return { + id: "game_runtime", + title: "Game runtime", + detail: "Check the configured emulator, core, and game content, then retry.", + state: "blocked", + required: true, + }; + } + return { + id: "game_runtime", + title: "Game runtime", + detail: "The emulator, core, and local game content produced valid fingerprints.", + state: "ready", + required: true, + }; +} + +function portCheck(input: ReadinessInput): ReadinessCheck { + if (!input.desktop || input.preflightStatus === "error") { + return { + id: "native_port", + title: "Match port", + detail: "Port validation requires a valid desktop game runtime.", + state: "blocked", + required: true, + }; + } + if (input.preflightStatus === "pending" || !input.preflight) { + return { + id: "native_port", + title: "Match port", + detail: "Waiting for the local runtime check…", + state: "pending", + required: true, + }; + } + return { + id: "native_port", + title: "Match port", + detail: input.preflight.native_port_available + ? "The native netplay port is available." + : "Close the process using the netplay port, then retry.", + state: input.preflight.native_port_available ? "ready" : "blocked", + required: true, + }; +} + +function networkCheck(input: ReadinessInput): ReadinessCheck { + if (!input.desktop || input.networkStatus === "error") { + return { + id: "network", + title: "Network fallback", + detail: "Network diagnostics are unavailable. Same-LAN play may still work.", + state: "warning", + required: false, + }; + } + if (input.networkStatus === "pending" || !input.network) { + return { + id: "network", + title: "Network fallback", + detail: "Checking NAT reachability and relay availability…", + state: "pending", + required: false, + }; + } + const reachable = input.network.nat !== "blocked" || input.network.relay_reachable; + return { + id: "network", + title: "Network fallback", + detail: reachable + ? "At least one diagnostic network path is available." + : "No internet path was verified. You can continue with a same-LAN opponent.", + state: reachable ? "ready" : "warning", + required: false, + }; +} diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index e3bcce3..7b44d54 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -43,7 +43,17 @@ export function downloadMatchReport( probe: MatchProbeReport, playable?: RetroarchMatchLaunch ): void { - const compatibility: MatchReportCompatibility | undefined = playable + const compatibility = compatibilityFromLaunch(playable); + downloadJson( + `opencade-match-${room.id.slice(0, 8)}.json`, + buildMatchReport(room, probe, new Date(), compatibility) + ); +} + +export function compatibilityFromLaunch( + playable?: RetroarchMatchLaunch +): MatchReportCompatibility | undefined { + return playable ? { adapter: playable.adapter, emulator_version: playable.fingerprint.retroarch_version ?? null, @@ -52,10 +62,6 @@ export function downloadMatchReport( content_sha256: playable.fingerprint.content_sha256, } : undefined; - downloadJson( - `opencade-match-${room.id.slice(0, 8)}.json`, - buildMatchReport(room, probe, new Date(), compatibility) - ); } export type AlphaFailureEvidence = { diff --git a/apps/client/src/lib/snapshot.test.ts b/apps/client/src/lib/snapshot.test.ts new file mode 100644 index 0000000..79ca963 --- /dev/null +++ b/apps/client/src/lib/snapshot.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { RoomSnapshotPayload } from "@opencade/protocol"; +import { newestSnapshot } from "./snapshot"; + +const snapshot = (revision: number): RoomSnapshotPayload => ({ + room: { + id: "room-1", + game_id: "sfiii3", + host_id: "host", + guest_id: "guest", + state: "connecting", + }, + revision, + preflight_count: 2, + compatibility_matched: true, + barrier: { + room_id: "room-1", + ready_count: 2, + required_count: 2, + launch_at: "2026-08-25T12:00:00Z", + }, + route: "direct_lan", +}); + +describe("newestSnapshot", () => { + it("rejects stale reconnect events and accepts idempotent duplicates", () => { + expect(newestSnapshot(snapshot(8), snapshot(7)).revision).toBe(8); + expect(newestSnapshot(snapshot(8), snapshot(8)).revision).toBe(8); + expect(newestSnapshot(snapshot(8), snapshot(9)).revision).toBe(9); + }); +}); diff --git a/apps/client/src/lib/snapshot.ts b/apps/client/src/lib/snapshot.ts new file mode 100644 index 0000000..1a880f0 --- /dev/null +++ b/apps/client/src/lib/snapshot.ts @@ -0,0 +1,8 @@ +import type { RoomSnapshotPayload } from "@opencade/protocol"; + +export function newestSnapshot( + current: RoomSnapshotPayload | undefined, + incoming: RoomSnapshotPayload +): RoomSnapshotPayload { + return !current || incoming.revision >= current.revision ? incoming : current; +} diff --git a/apps/client/src/lib/store.ts b/apps/client/src/lib/store.ts index 1ffc9f8..228eeec 100644 --- a/apps/client/src/lib/store.ts +++ b/apps/client/src/lib/store.ts @@ -7,15 +7,20 @@ type SessionState = { token: string | null; user: SessionUser | null; hydrated: boolean; + activeRoomId: string | null; hydrate: () => Promise; setSession: (token: string, user: SessionUser) => void; clearSession: () => void; + setActiveRoom: (roomId: string | null) => void; }; +const ACTIVE_ROOM_KEY = "opencade.active_room"; + export const useSessionStore = create((set) => ({ token: null, user: null, hydrated: false, + activeRoomId: sessionStorage.getItem(ACTIVE_ROOM_KEY), hydrate: async () => { try { const token = await loadSessionToken(); @@ -29,7 +34,13 @@ export const useSessionStore = create((set) => ({ void storeSessionToken(token); }, clearSession: () => { - set({ token: null, user: null }); + sessionStorage.removeItem(ACTIVE_ROOM_KEY); + set({ token: null, user: null, activeRoomId: null }); void clearStoredSessionToken(); }, + setActiveRoom: (roomId) => { + if (roomId) sessionStorage.setItem(ACTIVE_ROOM_KEY, roomId); + else sessionStorage.removeItem(ACTIVE_ROOM_KEY); + set({ activeRoomId: roomId }); + }, })); diff --git a/apps/client/src/lib/telemetry.test.ts b/apps/client/src/lib/telemetry.test.ts new file mode 100644 index 0000000..f1bed38 --- /dev/null +++ b/apps/client/src/lib/telemetry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + TELEMETRY_CONSENT_KEY, + createProductEvent, + getTelemetryConsent, + setTelemetryConsent, +} from "./telemetry.js"; + +function memoryStorage(): Pick { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + }; +} + +describe("product telemetry", () => { + it("defaults to no consent and persists either explicit choice", () => { + const storage = memoryStorage(); + expect(getTelemetryConsent(storage)).toBeNull(); + expect(setTelemetryConsent(true, storage)).toBe(true); + expect(getTelemetryConsent(storage)).toBe(true); + expect(setTelemetryConsent(false, storage)).toBe(true); + expect(getTelemetryConsent(storage)).toBe(false); + expect(storage.getItem(TELEMETRY_CONSENT_KEY)).toBe("declined"); + }); + + it("builds only the closed anonymous event payload", () => { + const payload = createProductEvent( + "00000000-0000-4000-8000-000000000001", + "readiness_blocked", + "sfiii3", + ["game_runtime"], + "00000000-0000-4000-8000-000000000002" + ); + expect(payload).toEqual({ + event_id: "00000000-0000-4000-8000-000000000002", + anonymous_session_id: "00000000-0000-4000-8000-000000000001", + event: "readiness_blocked", + game_id: "sfiii3", + blocked_checks: ["game_runtime"], + }); + expect(payload).not.toHaveProperty("user_id"); + expect(payload).not.toHaveProperty("properties"); + }); +}); diff --git a/apps/client/src/lib/telemetry.ts b/apps/client/src/lib/telemetry.ts new file mode 100644 index 0000000..a30914c --- /dev/null +++ b/apps/client/src/lib/telemetry.ts @@ -0,0 +1,80 @@ +import type { ProductEventName, ProductEventPayload, ReadinessCheckId } from "@opencade/protocol"; +import { api } from "./api"; + +export const TELEMETRY_CONSENT_KEY = "opencade.product-telemetry-consent"; +const TELEMETRY_SESSION_KEY = "opencade.product-telemetry-session"; + +type StorageAccess = Pick; + +export function getTelemetryConsent(storage?: StorageAccess): boolean | null { + const target = storage ?? browserStorage("local"); + if (!target) return null; + try { + const value = target.getItem(TELEMETRY_CONSENT_KEY); + return value === "granted" ? true : value === "declined" ? false : null; + } catch { + return null; + } +} + +export function setTelemetryConsent(consent: boolean, storage?: StorageAccess): boolean { + const target = storage ?? browserStorage("local"); + if (!target) return false; + try { + target.setItem(TELEMETRY_CONSENT_KEY, consent ? "granted" : "declined"); + return true; + } catch { + return false; + } +} + +export function createProductEvent( + anonymousSessionId: string, + event: ProductEventName, + gameId: string, + blockedChecks: ReadinessCheckId[] = [], + eventId = crypto.randomUUID() +): ProductEventPayload { + return { + event_id: eventId, + anonymous_session_id: anonymousSessionId, + event, + game_id: gameId, + blocked_checks: blockedChecks, + }; +} + +export async function trackProductEvent( + token: string, + event: ProductEventName, + gameId: string, + blockedChecks: ReadinessCheckId[] = [] +): Promise { + if (getTelemetryConsent() !== true) return false; + const anonymousSessionId = telemetrySessionId(); + if (!anonymousSessionId) return false; + await api.recordProductEvent( + token, + createProductEvent(anonymousSessionId, event, gameId, blockedChecks) + ); + return true; +} + +function telemetrySessionId(): string | null { + const storage = browserStorage("session"); + if (!storage) return null; + try { + const existing = storage.getItem(TELEMETRY_SESSION_KEY); + if (existing) return existing; + const created = crypto.randomUUID(); + storage.setItem(TELEMETRY_SESSION_KEY, created); + return created; + } catch { + return null; + } +} + +function browserStorage(kind: "local" | "session"): StorageAccess | null { + if (typeof window === "undefined") return null; + return kind === "local" ? window.localStorage : window.sessionStorage; +} diff --git a/apps/client/src/lib/usePlayableMatch.ts b/apps/client/src/lib/usePlayableMatch.ts index 794da39..7e50ae5 100644 --- a/apps/client/src/lib/usePlayableMatch.ts +++ b/apps/client/src/lib/usePlayableMatch.ts @@ -1,16 +1,18 @@ -import { useEffect, useReducer } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useReducer, useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { MatchEndpointPayload, MatchProbeCompletedPayload, RoomPayload, } from "@opencade/protocol"; -import { api, getApiBase } from "./api"; -import { matchParticipants, nativeLanEndpoint } from "./match"; +import { api } from "./api"; +import { matchParticipants, selectNativeRoute } from "./match"; import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator"; +import { trackProductEvent } from "./telemetry"; import { launchRetroarchMatch, onEmulatorExit, + retroarchPreflight, stopGame, type MatchEndpointCandidate, type MatchProbeReport, @@ -38,11 +40,60 @@ export function usePlayableMatch({ peerCompletion, }: PlayableMatchOptions) { const queryClient = useQueryClient(); + const preflightStarted = useRef(false); + const readyStarted = useRef(false); const [coordinator, dispatch] = useReducer( transitionMatchCoordinator, initialMatchCoordinatorState ); const participants = room ? matchParticipants(room, userId) : undefined; + const snapshot = useQuery({ + queryKey: ["room-snapshot", roomId], + queryFn: () => api.roomSnapshot(token, roomId), + enabled: Boolean(room && participants), + staleTime: 30_000, + }); + const preflight = useMutation({ + mutationFn: async () => { + if (!room) throw new Error("Room is unavailable for compatibility preflight"); + const local = await retroarchPreflight(room.game_id); + return api.submitPreflight(token, roomId, { + room_id: roomId, + native_port_available: local.native_port_available, + compatibility: { + adapter: local.adapter, + emulator_version: local.emulator_version ?? null, + executable_sha256: local.executable_sha256, + core_sha256: local.core_sha256, + content_sha256: local.content_sha256, + }, + }); + }, + onSuccess: (value) => queryClient.setQueryData(["room-snapshot", roomId], value), + onError: (error) => + dispatch({ + type: "failed", + error: error instanceof Error ? error.message : "Compatibility preflight failed", + }), + }); + const ready = useMutation({ + mutationFn: () => api.readyToLaunch(token, roomId), + onSuccess: (value) => queryClient.setQueryData(["room-snapshot", roomId], value), + onError: (error) => + dispatch({ + type: "failed", + error: error instanceof Error ? error.message : "Launch barrier failed", + }), + }); + const launchAt = snapshot.data?.barrier.launch_at + ? new Date(snapshot.data.barrier.launch_at).getTime() + : undefined; + const canLaunch = Boolean( + snapshot.data?.compatibility_matched && + snapshot.data.barrier.ready_count === snapshot.data.barrier.required_count && + launchAt !== undefined && + launchAt <= Date.now() + ); const playableMatch = useMutation({ mutationFn: async () => { if (!room || !participants || !localEndpoint || !peerEndpoint) { @@ -51,16 +102,19 @@ export function usePlayableMatch({ if (probeReport?.transport !== "direct_udp") { throw new Error("Native gameplay requires a verified direct UDP path"); } - dispatch({ type: "launch_requested" }); - const grant = await api.createLaunchGrant( - token, - roomId, - nativeLanEndpoint(localEndpoint.endpoint), - nativeLanEndpoint(peerEndpoint.endpoint) + if (!canLaunch) throw new Error("Synchronized launch barrier is not ready"); + const route = selectNativeRoute( + room, + userId, + localEndpoint.endpoint, + peerEndpoint.endpoint, + probeReport.transport, + probeReport.candidate ); + dispatch({ type: "launch_requested" }); + await trackProductEvent(token, "launch_attempted", room.game_id).catch(() => false); + const grant = await api.createLaunchGrant(token, roomId, route.local, route.peer); const launch = await launchRetroarchMatch({ - api_url: getApiBase(), - session_token: token, launch_grant: grant.grant, }); try { @@ -70,6 +124,7 @@ export function usePlayableMatch({ throw error; } await queryClient.invalidateQueries({ queryKey: ["room", roomId] }); + await trackProductEvent(token, "launch_succeeded", room.game_id).catch(() => false); return launch; }, onSuccess: () => dispatch({ type: "native_spawned" }), @@ -101,6 +156,18 @@ export function usePlayableMatch({ dispatch({ type: "peer_transcript_verified" }); }, [peerCompletion, probeReport]); + useEffect(() => { + if (coordinator.phase !== "ready" || preflightStarted.current) return; + preflightStarted.current = true; + preflight.mutate(); + }, [coordinator.phase, preflight]); + + useEffect(() => { + if (!snapshot.data?.compatibility_matched || readyStarted.current) return; + readyStarted.current = true; + ready.mutate(); + }, [ready, snapshot.data?.compatibility_matched]); + useEffect(() => { if (room?.state === "playing") dispatch({ type: "room_playing" }); if (room?.state === "finished") dispatch({ type: "room_finished" }); @@ -127,6 +194,14 @@ export function usePlayableMatch({ coordinator, participants, playableMatch, - resetCoordinator: () => dispatch({ type: "reset" }), + snapshot: snapshot.data, + preflightPending: preflight.isPending, + launchBarrierPending: ready.isPending, + canLaunch, + resetCoordinator: () => { + preflightStarted.current = false; + readyStarted.current = false; + dispatch({ type: "reset" }); + }, }; } diff --git a/apps/client/src/lib/ws.ts b/apps/client/src/lib/ws.ts index 4d8bd15..50c544c 100644 --- a/apps/client/src/lib/ws.ts +++ b/apps/client/src/lib/ws.ts @@ -11,6 +11,7 @@ export class OpenCadeSocket { private socket: WebSocket | null = null; private attempt = 0; private stopped = false; + private reconnectTimer: number | null = null; private listeners = new Set(); private pending = new Map< string, @@ -24,12 +25,22 @@ export class OpenCadeSocket { ) {} connect(): void { + if ( + this.socket?.readyState === WebSocket.OPEN || + this.socket?.readyState === WebSocket.CONNECTING + ) { + return; + } this.stopped = false; + if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; this.open(this.attempt === 0 ? "connecting" : "reconnecting"); } close(): void { this.stopped = true; + if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; this.socket?.close(1000, "client closed"); this.socket = null; this.rejectPending(new Error("WebSocket closed")); @@ -57,14 +68,18 @@ export class OpenCadeSocket { } private open(state: ConnectionState): void { + if (this.stopped) return; this.onState(state); const url = new URL("/ws", this.baseUrl.replace(/^http/, "ws")); - this.socket = new WebSocket(url, ["opencade.v1", `opencade.auth.${this.token}`]); - this.socket.onopen = () => { + const socket = new WebSocket(url, ["opencade.v1", `opencade.auth.${this.token}`]); + this.socket = socket; + socket.onopen = () => { + if (this.socket !== socket) return; this.attempt = 0; this.onState("open"); }; - this.socket.onmessage = (event) => { + socket.onmessage = (event) => { + if (this.socket !== socket) return; if (typeof event.data !== "string") return; try { const envelope = parseEnvelope(event.data); @@ -79,12 +94,18 @@ export class OpenCadeSocket { // The server owns protocol validation; malformed frames are ignored client-side. } }; - this.socket.onclose = () => { + socket.onerror = () => socket.close(); + socket.onclose = () => { + if (this.socket !== socket) return; this.socket = null; + this.rejectPending(new Error("WebSocket connection was lost")); if (this.stopped) return; const delay = reconnectDelay(this.attempt++); this.onState("reconnecting"); - window.setTimeout(() => this.open("reconnecting"), delay); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + if (!this.stopped) this.open("reconnecting"); + }, delay); }; } diff --git a/apps/client/src/routes/Games.tsx b/apps/client/src/routes/Games.tsx index c138e6b..68c55ff 100644 --- a/apps/client/src/routes/Games.tsx +++ b/apps/client/src/routes/Games.tsx @@ -1,17 +1,43 @@ +import { useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { api, type Game } from "../lib/api"; import { scanGame } from "../lib/native"; +import MatchReadiness from "../components/MatchReadiness"; +import TelemetryConsent from "../components/TelemetryConsent"; +import { trackProductEvent } from "../lib/telemetry"; type Props = { token: string; onSelect: (gameId: string) => void }; export default function Games({ token, onSelect }: Props) { + const [selectedGame, setSelectedGame] = useState(null); const games = useQuery({ queryKey: ["games"], queryFn: () => api.games(token) }); if (games.isPending) return ; if (games.isError) return ; - return ; + if (selectedGame) { + return ( + setSelectedGame(null)} + onContinue={() => onSelect(selectedGame.id)} + /> + ); + } + return ( + <> + + { + setSelectedGame(game); + void trackProductEvent(token, "game_selected", game.id).catch(() => undefined); + }} + /> + + ); } -function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (gameId: string) => void }) { +function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (game: Game) => void }) { const availability = useQueries({ queries: games.map((game) => ({ queryKey: ["availability", game.id], @@ -30,7 +56,7 @@ function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (gameId: st
{games.map((game, index) => ( -
+
+
+ Private alpha invite + {inviteCode || "Create a one-use 15-minute code or join one."} +
+
+ setJoinCode(event.target.value.toUpperCase())} + /> + + +
+
+ {(lobby.isError || incoming.isError || responseError) && ( +
+ {responseError || lobby.error?.message || incoming.error?.message} + +
+ )} {pending.map((item) => (
@@ -70,10 +152,18 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) Room {item.room_id.slice(0, 8)}
- -
@@ -96,7 +186,7 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props)
))} - {!lobby.isPending && peers.length === 0 && ( + {!lobby.isPending && !lobby.isError && peers.length === 0 && (
No opponents yet Keep this lobby open while another player joins {gameId}. diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index 4e9a75c..b282640 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -1,9 +1,17 @@ +import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { MatchEndpointPayload, MatchProbeCompletedPayload } from "@opencade/protocol"; import { api } from "../lib/api"; -import { downloadAlphaFailureReport, downloadMatchReport } from "../lib/report"; +import { + buildAlphaFailureReport, + buildMatchReport, + compatibilityFromLaunch, + downloadAlphaFailureReport, + downloadMatchReport, +} from "../lib/report"; import { useLanMatchProbe } from "../lib/useLanMatchProbe"; import { usePlayableMatch } from "../lib/usePlayableMatch"; +import { stopGame } from "../lib/native"; import type { OpenCadeSocket } from "../lib/ws"; export default function Match({ @@ -28,7 +36,6 @@ export default function Match({ const room = useQuery({ queryKey: ["room", roomId], queryFn: () => api.room(token, roomId), - refetchInterval: 2_000, }); const { localEndpoint, probeReport, probeError, probeFailure, isResetting, retry } = useLanMatchProbe({ @@ -42,7 +49,16 @@ export default function Match({ onRetry: onProbeRetry, }); const state = room.data?.state ?? "connecting"; - const { coordinator, participants, playableMatch, resetCoordinator } = usePlayableMatch({ + const { + coordinator, + participants, + playableMatch, + snapshot, + preflightPending, + launchBarrierPending, + canLaunch, + resetCoordinator, + } = usePlayableMatch({ token, userId, roomId, @@ -52,6 +68,16 @@ export default function Match({ probeReport, peerCompletion, }); + const [now, setNow] = useState(Date.now()); + const [isLeaving, setIsLeaving] = useState(false); + const [leaveError, setLeaveError] = useState(""); + const uploadedSuccess = useRef(false); + const uploadedFailure = useRef(false); + useEffect(() => { + if (!snapshot?.barrier.launch_at) return; + const timer = window.setInterval(() => setNow(Date.now()), 500); + return () => window.clearInterval(timer); + }, [snapshot?.barrier.launch_at]); const steps = ["connecting", "playing", "finished"]; const active = Math.max(0, steps.indexOf(state)); const heading = @@ -79,6 +105,30 @@ export default function Match({ transport: probeReport?.transport, } : probeFailure; + useEffect(() => { + if (!completedRoom || !probeReport || !playableMatch.data || uploadedSuccess.current) return; + uploadedSuccess.current = true; + const report = buildMatchReport( + completedRoom, + probeReport, + new Date(), + compatibilityFromLaunch(playableMatch.data) + ); + void api.submitEvidence(token, report).catch(() => { + uploadedSuccess.current = false; + }); + }, [completedRoom, playableMatch.data, probeReport, token]); + useEffect(() => { + if (!failureRoom || !participants || !failureEvidence || uploadedFailure.current) return; + uploadedFailure.current = true; + const report = buildAlphaFailureReport(failureRoom, participants.role, failureEvidence); + void api.submitEvidence(token, report).catch(() => { + uploadedFailure.current = false; + }); + }, [failureEvidence, failureRoom, participants, token]); + const launchSeconds = snapshot?.barrier.launch_at + ? Math.max(0, Math.ceil((new Date(snapshot.barrier.launch_at).getTime() - now) / 1000)) + : undefined; return (

Room {roomId.slice(0, 8)}

@@ -107,6 +157,19 @@ export default function Match({ {probeReport.transcript_checksum}

)} + {coordinator.phase === "ready" && ( +

+ {preflightPending + ? "Checking RetroArch, core, content, and TCP port…" + : !snapshot?.compatibility_matched + ? `Waiting for matching peer preflight (${snapshot?.preflight_count ?? 0}/2)` + : launchBarrierPending || snapshot.barrier.ready_count < 2 + ? `Compatibility matched · launch ready ${snapshot.barrier.ready_count}/2` + : launchSeconds && launchSeconds > 0 + ? `Synchronized launch opens in ${launchSeconds}s` + : "Compatibility and synchronized launch barrier verified"} +

+ )} {coordinator.phase === "relay_probe_only" && (

The readiness probe passed, but this UDP route is not a usable RetroArch TCP route. Native @@ -133,6 +196,11 @@ export default function Match({ {playableMatch.error.message}

)} + {leaveError && ( +

+ {leaveError} +

+ )} {playableMatch.data && (

RetroArch netplay launched · PID {playableMatch.data.pid} · content{" "} @@ -156,6 +224,22 @@ export default function Match({ : "Retry LAN probe"} )} + {room.isError && ( + + )} + {(playableMatch.isError || coordinator.phase === "failed") && ( + + )} {completedRoom && probeReport && ( )} -

diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 0f5a5cb..25a1004 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -142,6 +142,45 @@ h2 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.telemetry-consent { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 24px; + padding: 24px; + margin-bottom: 32px; + background: #141518; + border-left: 4px solid #f4b942; +} +.telemetry-copy h2 { + margin: 0; + font-size: 24px; + font-weight: 800; + line-height: 1.25; + letter-spacing: -0.02em; +} +.telemetry-copy > p:last-child { + max-width: 65ch; + margin: 16px 0 0; + color: #b4b5ba; + font-size: 12px; + line-height: 1.5; +} +.telemetry-choice { + display: flex; + gap: 8px; +} +.telemetry-choice button { + min-height: 48px; +} +.telemetry-state { + grid-column: 2; + color: #b4b5ba; + font-size: 12px; + font-weight: 800; + line-height: 1.5; + text-align: right; +} .game-card { display: grid; grid-template-columns: 50px 1fr auto; @@ -384,6 +423,113 @@ input { .match-actions .secondary { min-height: 44px; } +.readiness { + max-width: 960px; +} +.readiness-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; + margin-bottom: 32px; +} +.readiness-intro { + max-width: 65ch; + margin: 16px 0 0; + color: #b4b5ba; + font-size: 16px; + line-height: 1.5; +} +.readiness-progress { + min-width: 176px; + color: #f4b942; + font-size: 12px; + line-height: 1.5; + text-align: right; +} +.readiness-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + margin: 0; + list-style: none; +} +.readiness-check { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: 24px; + min-height: 96px; + padding: 24px; + background: #141518; + border-left: 4px solid #606166; +} +.readiness-check.ready { + border-color: #8bd49c; +} +.readiness-check.warning, +.readiness-check.pending { + border-color: #f4b942; +} +.readiness-check.blocked { + border-color: #ff8e86; +} +.readiness-symbol { + display: grid; + width: 48px; + height: 48px; + place-items: center; + background: #24252a; + color: #f4f0e8; + font-size: 24px; + font-weight: 800; +} +.readiness-copy h3 { + margin: 0 0 8px; + font-size: 24px; + font-weight: 800; + line-height: 1.25; +} +.readiness-copy p { + max-width: 65ch; + margin: 0; + color: #b4b5ba; + font-size: 12px; + line-height: 1.5; +} +.readiness-state { + color: #b4b5ba; + font-size: 12px; + font-weight: 800; + line-height: 1.5; + text-align: right; +} +.readiness-check.ready .readiness-state { + color: #8bd49c; +} +.readiness-check.warning .readiness-state, +.readiness-check.pending .readiness-state { + color: #f4b942; +} +.readiness-check.blocked .readiness-state { + color: #ff8e86; +} +.readiness-notice { + min-height: 24px; + margin: 24px 0 16px; + color: #b4b5ba; + font-size: 16px; + line-height: 1.5; +} +.readiness-actions { + display: flex; + justify-content: flex-end; + gap: 16px; +} +.readiness-actions button { + min-height: 48px; +} @media (max-width: 760px) { .auth-layout { grid-template-columns: 1fr; @@ -403,6 +549,39 @@ input { align-items: flex-start; flex-direction: column; } + .telemetry-consent { + grid-template-columns: 1fr; + padding: 16px; + } + .telemetry-choice { + align-items: stretch; + flex-direction: column; + } + .telemetry-state { + grid-column: 1; + text-align: left; + } + .readiness-heading { + align-items: flex-start; + flex-direction: column; + gap: 16px; + } + .readiness-progress { + text-align: left; + } + .readiness-check { + grid-template-columns: 48px 1fr; + gap: 16px; + padding: 16px; + } + .readiness-state { + grid-column: 2; + text-align: left; + } + .readiness-actions { + align-items: stretch; + flex-direction: column-reverse; + } } @media (prefers-reduced-motion: reduce) { * { diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index e343c20..bf8ff17 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -21,14 +21,16 @@ rand_core = { version = "0.6.4", features = ["getrandom"] } chrono = { version = "0.4.45", features = ["serde"] } uuid = { version = "1.25.0", features = ["v4", "serde"] } tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } regex = "1.13.1" sha2 = "0.11.0" hex = "0.4.3" dashmap = "6.2.1" thiserror = "2.0.20" +url = "2.5.8" opencade-protocol = { path = "../../packages/protocol" } opencade-shared = { path = "../../packages/shared" } +opencade-networking = { path = "../../packages/networking" } [dev-dependencies] futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] } diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 370f422..5101910 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -16,10 +16,11 @@ COPY packages/game-definitions/Cargo.toml packages/game-definitions/Cargo.toml COPY adapters/fbneo/Cargo.toml adapters/fbneo/Cargo.toml COPY adapters/retroarch/Cargo.toml adapters/retroarch/Cargo.toml COPY services/relay/Cargo.toml services/relay/Cargo.toml +COPY crates/proof-of-match/Cargo.toml crates/proof-of-match/Cargo.toml # Create stub sources so `cargo build` can resolve the workspace graph. RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ - packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src adapters/retroarch/src services/relay/src \ + packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src adapters/retroarch/src services/relay/src crates/proof-of-match/src \ && echo "fn main() {}" > apps/server/src/main.rs \ && echo "" > packages/protocol/src/lib.rs \ && echo "" > packages/networking/src/lib.rs \ @@ -29,6 +30,7 @@ RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ && echo "" > adapters/fbneo/src/lib.rs \ && echo "" > adapters/retroarch/src/lib.rs \ && echo "fn main() {}" > services/relay/src/main.rs \ + && echo "" > crates/proof-of-match/src/lib.rs \ && cargo build --release --locked # Copy the real sources (invalidates cache only when source changes). @@ -36,7 +38,7 @@ COPY . . # Remove only the dummy workspace artifacts before compiling the real sources. Dependency # artifacts remain cached, while Cargo can no longer mistake a stub crate for the application. -RUN cargo clean --release -p opencade-server -p opencade-protocol -p opencade-shared \ +RUN cargo clean --release --workspace \ && cargo build --release --locked -p opencade-server \ && test -x target/release/opencade-server diff --git a/apps/server/migrations/006_community_alpha.sql b/apps/server/migrations/006_community_alpha.sql new file mode 100644 index 0000000..38c65c4 --- /dev/null +++ b/apps/server/migrations/006_community_alpha.sql @@ -0,0 +1,52 @@ +-- Community alpha orchestration, compatibility handshakes, and privacy-safe evidence. + +CREATE TABLE room_invites ( + code_hash TEXT PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + consumed_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_room_invites_room ON room_invites(room_id, expires_at); + +CREATE TABLE match_preflights ( + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + compatibility JSONB NOT NULL, + native_port_available BOOLEAN NOT NULL, + ready BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (room_id, user_id) +); + +CREATE TABLE room_launch_barriers ( + room_id UUID PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE, + launch_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE room_events ( + revision BIGSERIAL PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT room_events_type CHECK (event_type ~ '^[a-z0-9_.]{3,64}$') +); + +CREATE INDEX idx_room_events_room_revision ON room_events(room_id, revision DESC); + +CREATE TABLE alpha_evidence ( + digest TEXT PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + submitted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('host', 'guest')), + kind TEXT NOT NULL CHECK (kind IN ('match', 'attempt_failure')), + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (room_id, submitted_by, kind) +); + +CREATE INDEX idx_alpha_evidence_room ON alpha_evidence(room_id, created_at); diff --git a/apps/server/migrations/007_product_telemetry.sql b/apps/server/migrations/007_product_telemetry.sql new file mode 100644 index 0000000..174912b --- /dev/null +++ b/apps/server/migrations/007_product_telemetry.sql @@ -0,0 +1,17 @@ +-- Opt-in, anonymous product activation telemetry. Raw events expire after 90 days. +CREATE TABLE product_events ( + event_id UUID PRIMARY KEY, + anonymous_session_id UUID NOT NULL, + event_name TEXT NOT NULL CHECK ( + event_name IN ('game_selected', 'readiness_completed', 'readiness_blocked', 'lobby_entered') + ), + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + blocked_checks JSONB NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(blocked_checks) = 'array'), + received_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_product_events_received_at + ON product_events (received_at DESC); +CREATE INDEX idx_product_events_session_received_at + ON product_events (anonymous_session_id, game_id, received_at DESC); diff --git a/apps/server/migrations/008_match_lifecycle.sql b/apps/server/migrations/008_match_lifecycle.sql new file mode 100644 index 0000000..c83a8ee --- /dev/null +++ b/apps/server/migrations/008_match_lifecycle.sql @@ -0,0 +1,116 @@ +-- Authoritative match-attempt identity, deadlines, and lifecycle audit metadata. + +ALTER TABLE product_events DROP CONSTRAINT IF EXISTS product_events_event_name_check; +ALTER TABLE product_events ADD CONSTRAINT product_events_event_name_check CHECK ( + event_name IN ( + 'game_selected', 'readiness_completed', 'readiness_blocked', 'lobby_entered', + 'launch_attempted', 'launch_succeeded' + ) +); + +ALTER TABLE rooms + ADD COLUMN IF NOT EXISTS attempt_id UUID NOT NULL DEFAULT gen_random_uuid(), + ADD COLUMN IF NOT EXISTS state_deadline_at TIMESTAMPTZ; + +UPDATE rooms +SET state_deadline_at = CASE state + WHEN 'WAITING' THEN updated_at + interval '24 hours' + WHEN 'CHALLENGING' THEN updated_at + interval '5 minutes' + WHEN 'CONNECTING' THEN updated_at + interval '2 minutes' + ELSE NULL +END +WHERE state_deadline_at IS NULL; + +ALTER TABLE challenges + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL + DEFAULT (now() + interval '5 minutes'); + +ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_state_check; +ALTER TABLE challenges ADD CONSTRAINT challenges_state_check + CHECK (state IN ('PENDING','ACCEPTED','DECLINED','CANCELLED','EXPIRED')); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_challenges_active_pair + ON challenges (challenger_id, challenged_id) + WHERE state = 'PENDING'; +CREATE INDEX IF NOT EXISTS idx_challenges_expiry + ON challenges (expires_at) WHERE state = 'PENDING'; + +ALTER TABLE match_preflights ADD COLUMN IF NOT EXISTS attempt_id UUID; +UPDATE match_preflights AS preflight +SET attempt_id = rooms.attempt_id +FROM rooms +WHERE rooms.id = preflight.room_id AND preflight.attempt_id IS NULL; +ALTER TABLE match_preflights ALTER COLUMN attempt_id SET NOT NULL; + +ALTER TABLE room_launch_barriers ADD COLUMN IF NOT EXISTS attempt_id UUID; +UPDATE room_launch_barriers AS barrier +SET attempt_id = rooms.attempt_id +FROM rooms +WHERE rooms.id = barrier.room_id AND barrier.attempt_id IS NULL; +ALTER TABLE room_launch_barriers ALTER COLUMN attempt_id SET NOT NULL; + +ALTER TABLE room_events + ADD COLUMN IF NOT EXISTS schema_version SMALLINT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS attempt_id UUID, + ADD COLUMN IF NOT EXISTS actor_id UUID REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS causation_id UUID, + ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb; + +CREATE TABLE IF NOT EXISTS match_attempts ( + attempt_id UUID PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK (state IN ('ACTIVE','SUCCEEDED','FAILED','CANCELLED','EXPIRED')), + failure_code TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deadline_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + UNIQUE (room_id, attempt_id) +); + +INSERT INTO match_attempts (attempt_id, room_id, state, deadline_at, finished_at) +SELECT attempt_id, id, + CASE state + WHEN 'FINISHED' THEN 'SUCCEEDED' + WHEN 'CANCELLED' THEN 'CANCELLED' + ELSE 'ACTIVE' + END, + state_deadline_at, + CASE WHEN state IN ('FINISHED', 'CANCELLED') THEN updated_at ELSE NULL END +FROM rooms +ON CONFLICT (attempt_id) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_match_attempts_room_started + ON match_attempts (room_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_match_attempts_deadline + ON match_attempts (deadline_at) WHERE state = 'ACTIVE'; + +ALTER TABLE match_preflights + DROP CONSTRAINT IF EXISTS match_preflights_attempt_id_fkey, + ADD CONSTRAINT match_preflights_attempt_id_fkey + FOREIGN KEY (attempt_id) REFERENCES match_attempts(attempt_id) ON DELETE CASCADE; +ALTER TABLE room_launch_barriers + DROP CONSTRAINT IF EXISTS room_launch_barriers_attempt_id_fkey, + ADD CONSTRAINT room_launch_barriers_attempt_id_fkey + FOREIGN KEY (attempt_id) REFERENCES match_attempts(attempt_id) ON DELETE CASCADE; +ALTER TABLE room_events + DROP CONSTRAINT IF EXISTS room_events_attempt_id_fkey, + ADD CONSTRAINT room_events_attempt_id_fkey + FOREIGN KEY (attempt_id) REFERENCES match_attempts(attempt_id) ON DELETE RESTRICT; +CREATE INDEX IF NOT EXISTS idx_room_events_attempt_revision + ON room_events (attempt_id, revision) WHERE attempt_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_rooms_waiting_host_game + ON rooms (host_user_id, game_id) WHERE state = 'WAITING'; + +CREATE TABLE IF NOT EXISTS command_results ( + actor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + idempotency_key UUID NOT NULL, + command_type TEXT NOT NULL, + response_type TEXT NOT NULL, + response_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (actor_id, idempotency_key) +); + +CREATE INDEX IF NOT EXISTS idx_command_results_created_at + ON command_results (created_at); diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index 4a5c83c..297c277 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -14,6 +14,14 @@ pub enum ConfigError { IncompleteRelayConfig, #[error("RELAY_AUTH_SECRET must contain at least 32 characters")] WeakRelaySecret, + #[error("production requires PUBLIC_RELAY_URL and RELAY_AUTH_SECRET")] + MissingProductionRelay, + #[error("production PUBLIC_RELAY_URL must use wss and a publicly reachable host")] + UnsafeProductionRelay, + #[error("production STUN_HOST must be a publicly reachable host")] + UnsafeProductionStun, + #[error("OPERATOR_TOKEN must contain at least 32 characters in production")] + WeakOperatorToken, } #[derive(Clone, PartialEq, Eq)] @@ -28,6 +36,7 @@ pub struct Config { pub stun_port: u16, pub relay_url: Option, pub relay_secret: Option, + pub operator_token: String, } impl std::fmt::Debug for Config { @@ -47,6 +56,7 @@ impl std::fmt::Debug for Config { "relay_secret", &self.relay_secret.as_ref().map(|_| ""), ) + .field("operator_token", &"") .finish() } } @@ -75,6 +85,11 @@ impl Config { if production && session_secret.len() < 32 { return Err(ConfigError::WeakProductionSecret); } + let operator_token = + lookup("OPERATOR_TOKEN").unwrap_or_else(|| "dev-operator-token-change-me".to_string()); + if production && operator_token.len() < 32 { + return Err(ConfigError::WeakOperatorToken); + } let allowed_origins = lookup("ALLOWED_ORIGINS") .unwrap_or_else(|| { @@ -99,7 +114,9 @@ impl Config { .ok_or(ConfigError::InvalidStunPort)?, None => 3478, }; - let relay_url = lookup("RELAY_URL") + let public_relay_url = lookup("PUBLIC_RELAY_URL"); + let relay_url = public_relay_url + .or_else(|| lookup("RELAY_URL")) .map(|v| v.trim().to_owned()) .filter(|v| !v.is_empty()); let relay_secret = lookup("RELAY_AUTH_SECRET") @@ -114,6 +131,17 @@ impl Config { { return Err(ConfigError::WeakRelaySecret); } + if production { + let relay = relay_url + .as_deref() + .ok_or(ConfigError::MissingProductionRelay)?; + if !is_safe_public_relay(relay) { + return Err(ConfigError::UnsafeProductionRelay); + } + if !is_public_host(&stun_host) { + return Err(ConfigError::UnsafeProductionStun); + } + } Ok(Self { database_url, @@ -126,6 +154,7 @@ impl Config { stun_port, relay_url, relay_secret, + operator_token, }) } @@ -141,10 +170,47 @@ impl Config { stun_port: 3478, relay_url: None, relay_secret: None, + operator_token: "test-operator-token-with-32-characters".into(), } } } +fn is_safe_public_relay(value: &str) -> bool { + let Ok(parsed) = url::Url::parse(value) else { + return false; + }; + parsed.scheme() == "wss" + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none() + && parsed.host_str().is_some_and(is_public_host) +} + +fn is_public_host(host: &str) -> bool { + let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); + if let Ok(address) = host.parse::() { + return match address { + std::net::IpAddr::V4(address) => { + !(address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_unspecified() + || address.is_broadcast() + || address.is_documentation()) + } + std::net::IpAddr::V6(address) => { + !(address.is_loopback() + || address.is_unspecified() + || address.is_multicast() + || address.is_unique_local() + || address.is_unicast_link_local()) + } + }; + } + !host.is_empty() && host != "localhost" && !host.ends_with(".local") +} + #[cfg(test)] mod tests { use super::*; @@ -266,4 +332,44 @@ mod tests { Err(ConfigError::WeakRelaySecret) ); } + + #[test] + fn production_requires_public_network_endpoints_and_operator_token() { + let base = [ + ("OPENCADE_ENV", "production"), + ("SESSION_SECRET", "session-secret-at-least-32-characters"), + ("RELAY_AUTH_SECRET", "relay-secret-at-least-32-characters"), + ("PUBLIC_RELAY_URL", "wss://relay.example.com/relay"), + ("STUN_HOST", "stun.example.com"), + ]; + assert_eq!(load_config(&base), Err(ConfigError::WeakOperatorToken)); + + let mut valid = base.to_vec(); + valid.push(("OPERATOR_TOKEN", "operator-token-at-least-32-characters")); + assert!(load_config(&valid).is_ok()); + + let mut unsafe_relay = valid.clone(); + unsafe_relay.retain(|(key, _)| *key != "PUBLIC_RELAY_URL"); + unsafe_relay.push(("PUBLIC_RELAY_URL", "ws://127.0.0.1:8081/relay")); + assert_eq!( + load_config(&unsafe_relay), + Err(ConfigError::UnsafeProductionRelay) + ); + + let mut private_relay = valid.clone(); + private_relay.retain(|(key, _)| *key != "PUBLIC_RELAY_URL"); + private_relay.push(("PUBLIC_RELAY_URL", "wss://192.168.1.10/relay")); + assert_eq!( + load_config(&private_relay), + Err(ConfigError::UnsafeProductionRelay) + ); + + let mut credentialed_relay = valid; + credentialed_relay.retain(|(key, _)| *key != "PUBLIC_RELAY_URL"); + credentialed_relay.push(("PUBLIC_RELAY_URL", "wss://user@relay.example.com/relay")); + assert_eq!( + load_config(&credentialed_relay), + Err(ConfigError::UnsafeProductionRelay) + ); + } } diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs index f12f4b6..0ce1a9d 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -3,6 +3,7 @@ pub mod authn; pub mod config; pub mod error; pub mod health; +pub mod lifecycle; pub mod metrics; pub mod room_state; pub mod routes; @@ -11,7 +12,10 @@ pub mod ws; use axum::{ Router, + extract::{Request, State}, http::{HeaderValue, Method, header}, + middleware::{self, Next}, + response::Response, routing::{get, post}, }; use tower_http::{ @@ -55,6 +59,39 @@ pub fn build_app(state: AppState) -> Router { .route("/api/v1/lobbies/{game_id}", get(routes::lobbies::get_lobby)) .route("/api/v1/rooms", post(routes::rooms::create_room)) .route("/api/v1/rooms/{id}", get(routes::rooms::get_room)) + .route( + "/api/v1/rooms/{id}/invite", + post(routes::alpha::create_invite), + ) + .route("/api/v1/invites/join", post(routes::alpha::join_invite)) + .route( + "/api/v1/rooms/{id}/preflight", + post(routes::alpha::submit_preflight), + ) + .route( + "/api/v1/rooms/{id}/snapshot", + get(routes::alpha::room_snapshot), + ) + .route( + "/api/v1/rooms/{id}/ready", + post(routes::alpha::ready_to_launch), + ) + .route( + "/api/v1/alpha/evidence", + post(routes::alpha_evidence::submit_evidence), + ) + .route( + "/api/v1/alpha/campaign", + get(routes::alpha_evidence::campaign_summary), + ) + .route( + "/api/v1/telemetry/events", + post(routes::telemetry::record_event), + ) + .route( + "/api/v1/telemetry/activation", + get(routes::telemetry::activation_summary), + ) .route( "/api/v1/rooms/{id}/accept", post(routes::rooms::accept_room), @@ -72,6 +109,10 @@ pub fn build_app(state: AppState) -> Router { "/api/v1/rooms/{id}/relay-ticket", post(routes::rooms::relay_ticket), ) + .route( + "/api/v1/rooms/{id}/native-tunnel-ticket", + post(routes::rooms::native_tunnel_ticket), + ) .route( "/api/v1/rooms/{id}/launch-grant", post(routes::rooms::create_launch_grant), @@ -85,9 +126,40 @@ pub fn build_app(state: AppState) -> Router { post(routes::rooms::finish_room), ) .route("/ws", get(ws::ws_handler)) - .with_state(state) + .with_state(state.clone()) .layer(cors) .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn_with_state(state, track_http)) +} + +async fn track_http(State(state): State, request: Request, next: Next) -> Response { + use std::{sync::atomic::Ordering, time::Instant}; + let started = Instant::now(); + state + .metrics + .http_requests_total + .fetch_add(1, Ordering::Relaxed); + let response = next.run(request).await; + state.metrics.http_request_duration_micros.fetch_add( + started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64, + Ordering::Relaxed, + ); + match response.status().as_u16() { + 400..=499 => { + state + .metrics + .http_responses_4xx + .fetch_add(1, Ordering::Relaxed); + } + 500..=599 => { + state + .metrics + .http_responses_5xx + .fetch_add(1, Ordering::Relaxed); + } + _ => {} + } + response } fn cors_layer(config: &Config) -> CorsLayer { @@ -100,7 +172,11 @@ fn cors_layer(config: &Config) -> CorsLayer { CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_methods([Method::GET, Method::POST]) - .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .allow_headers([ + header::AUTHORIZATION, + header::CONTENT_TYPE, + header::HeaderName::from_static("x-operator-token"), + ]) } pub async fn shutdown_signal() { diff --git a/apps/server/src/lifecycle.rs b/apps/server/src/lifecycle.rs new file mode 100644 index 0000000..ebe0c76 --- /dev/null +++ b/apps/server/src/lifecycle.rs @@ -0,0 +1,78 @@ +use std::time::Duration; + +use serde_json::json; +use uuid::Uuid; + +use crate::{error::AppError, state::AppState}; + +/// Starts the authoritative deadline reconciler. Database row locks make the +/// operation safe when several server replicas run it concurrently. +pub fn spawn_reconciler(state: AppState) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(15)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + if let Err(error) = reconcile_expired(&state).await { + tracing::error!(%error, "match lifecycle reconciliation failed"); + } + } + }); +} + +pub async fn reconcile_expired(state: &AppState) -> Result { + let mut transaction = state.pool.begin().await?; + sqlx::query( + "UPDATE challenges SET state = 'EXPIRED' + WHERE state = 'PENDING' AND expires_at <= now()", + ) + .execute(&mut *transaction) + .await?; + + let room_ids = sqlx::query_scalar::<_, Uuid>( + "WITH expired AS MATERIALIZED ( + SELECT id, attempt_id FROM rooms + WHERE state IN ('WAITING', 'CHALLENGING', 'CONNECTING', 'PLAYING') + AND state_deadline_at <= now() + ORDER BY state_deadline_at + FOR UPDATE SKIP LOCKED + ), updated_rooms AS ( + UPDATE rooms AS room + SET state = 'CANCELLED', state_deadline_at = NULL + FROM expired WHERE room.id = expired.id + RETURNING room.id, room.attempt_id + ), updated_attempts AS ( + UPDATE match_attempts AS attempt + SET state = 'EXPIRED', failure_code = 'state_deadline_exceeded', + deadline_at = NULL, finished_at = now() + FROM expired + WHERE attempt.attempt_id = expired.attempt_id AND attempt.state = 'ACTIVE' + ), updated_challenges AS ( + UPDATE challenges AS challenge SET state = 'EXPIRED' + FROM expired + WHERE challenge.room_id = expired.id AND challenge.state = 'PENDING' + ) + INSERT INTO room_events (room_id, attempt_id, event_type, payload) + SELECT id, attempt_id, 'room.deadline.expired', $1 FROM updated_rooms + RETURNING room_id", + ) + .bind(json!({ "failure_code": "state_deadline_exceeded" })) + .fetch_all(&mut *transaction) + .await?; + transaction.commit().await?; + + let members = sqlx::query_as::<_, (Uuid, Uuid)>( + "SELECT room_id, user_id FROM room_members WHERE room_id = ANY($1)", + ) + .bind(&room_ids) + .fetch_all(&state.pool) + .await?; + for (room_id, member) in members { + state.notify_user( + member, + "room.deadline.expired", + json!({ "room_id": room_id, "state": "cancelled" }), + ); + } + Ok(room_ids.len() as u64) +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index e210af4..5707983 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,4 +1,4 @@ -use opencade_server::{AppState, Config, build_app, shutdown_signal}; +use opencade_server::{AppState, Config, build_app, lifecycle, shutdown_signal}; use sqlx::postgres::PgPoolOptions; use std::net::SocketAddr; use tracing::info; @@ -9,11 +9,14 @@ async fn main() -> Result<(), Box> { let config = Config::from_env()?; let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(config.rust_log.clone())); - tracing_subscriber::fmt() + let subscriber = tracing_subscriber::fmt() .with_env_filter(filter) - .with_target(false) - .compact() - .init(); + .with_target(false); + if config.production { + subscriber.json().init(); + } else { + subscriber.compact().init(); + } let pool = PgPoolOptions::new() .max_connections(10) @@ -26,13 +29,13 @@ async fn main() -> Result<(), Box> { return Ok(()); } - sqlx::migrate!("./migrations").run(&pool).await?; - let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); let listener = tokio::net::TcpListener::bind(addr).await?; info!(%addr, "opencade server listening"); - axum::serve(listener, build_app(AppState::new(pool, config))) + let state = AppState::new(pool, config); + lifecycle::spawn_reconciler(state.clone()); + axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown_signal()) .await?; diff --git a/apps/server/src/metrics.rs b/apps/server/src/metrics.rs index 1bd9bfc..ef6e372 100644 --- a/apps/server/src/metrics.rs +++ b/apps/server/src/metrics.rs @@ -1,48 +1,61 @@ -use axum::{extract::State, http::StatusCode, response::IntoResponse}; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; use std::sync::atomic::Ordering; -use crate::state::AppState; +use crate::{error::AppError, state::AppState}; -/// Prometheus exposition for M7 hardening. -/// -/// Exposes at least: -/// - `http_requests_total` -/// - `ws_connections` -/// - `rooms_created` -/// - `http_request_duration_seconds` -pub async fn metrics(State(state): State) -> impl IntoResponse { +/// Operator-authenticated Prometheus exposition for production monitoring. +pub async fn metrics( + State(state): State, + headers: HeaderMap, +) -> Result { + state.require_operator( + headers + .get("x-operator-token") + .and_then(|value| value.to_str().ok()), + )?; let rooms = state.metrics.rooms_created.load(Ordering::Relaxed); let http_total = state.metrics.http_requests_total.load(Ordering::Relaxed); + let responses_4xx = state.metrics.http_responses_4xx.load(Ordering::Relaxed); + let responses_5xx = state.metrics.http_responses_5xx.load(Ordering::Relaxed); + let duration_seconds = state + .metrics + .http_request_duration_micros + .load(Ordering::Relaxed) as f64 + / 1_000_000.0; let ws = state.ws_hub.len() as u64; - // Minimal histogram buckets — real durations not tracked yet, but shape is scrapeable. let body = format!( "# HELP http_requests_total Total HTTP requests\n\ # TYPE http_requests_total counter\n\ http_requests_total {http_total}\n\ +# HELP http_responses_total HTTP responses by status class\n\ +# TYPE http_responses_total counter\n\ +http_responses_total{{class=\"4xx\"}} {responses_4xx}\n\ +http_responses_total{{class=\"5xx\"}} {responses_5xx}\n\ # HELP ws_connections Current WebSocket connections\n\ # TYPE ws_connections gauge\n\ ws_connections {ws}\n\ # HELP rooms_created Total rooms created\n\ # TYPE rooms_created counter\n\ rooms_created {rooms}\n\ -# HELP http_request_duration_seconds HTTP request duration\n\ -# TYPE http_request_duration_seconds histogram\n\ -http_request_duration_seconds_bucket{{le=\"0.1\"}} 0\n\ -http_request_duration_seconds_bucket{{le=\"0.5\"}} 0\n\ -http_request_duration_seconds_bucket{{le=\"+Inf\"}} {http_total}\n\ -http_request_duration_seconds_sum 0\n\ +# HELP http_request_duration_seconds Cumulative HTTP request duration\n\ +# TYPE http_request_duration_seconds summary\n\ +http_request_duration_seconds_sum {duration_seconds}\n\ http_request_duration_seconds_count {http_total}\n" ); - ( + Ok(( StatusCode::OK, [( axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4", )], body, - ) + )) } #[cfg(test)] @@ -53,17 +66,16 @@ mod tests { use tower::ServiceExt; #[tokio::test] - async fn metrics_contains_required_names() { - // Use dummy pool — metrics does not hit DB, but AppState needs a pool. - // Create a lazy pool that won't connect. + async fn metrics_requires_operator_and_contains_real_counters() { let pool = sqlx::PgPool::connect_lazy("postgres://opencade:opencade@localhost:5432/opencade_test") - .unwrap(); + .expect("lazy pool"); let state = AppState::new(pool, Config::for_test()); let app = Router::new() .route("/metrics", get(metrics)) .with_state(state); - let resp = app + let unauthorized = app + .clone() .oneshot( Request::builder() .uri("/metrics") @@ -72,12 +84,24 @@ mod tests { ) .await .unwrap(); - assert_eq!(resp.status(), 200); - let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 16) + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + let response = app + .oneshot( + Request::builder() + .uri("/metrics") + .header("x-operator-token", "test-operator-token-with-32-characters") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024) .await .unwrap(); let text = String::from_utf8(bytes.to_vec()).unwrap(); assert!(text.contains("http_requests_total")); + assert!(text.contains("http_responses_total")); assert!(text.contains("ws_connections")); assert!(text.contains("rooms_created")); assert!(text.contains("http_request_duration_seconds")); diff --git a/apps/server/src/room_state.rs b/apps/server/src/room_state.rs index be8367a..9f3ff47 100644 --- a/apps/server/src/room_state.rs +++ b/apps/server/src/room_state.rs @@ -24,7 +24,11 @@ pub fn transition(state: RoomState, event: RoomEvent) -> Result Ok(RoomState::Playing), (RoomState::Playing, RoomEvent::Finish) => Ok(RoomState::Finished), ( - RoomState::Waiting | RoomState::Ready | RoomState::Challenging | RoomState::Connecting, + RoomState::Waiting + | RoomState::Ready + | RoomState::Challenging + | RoomState::Connecting + | RoomState::Playing, RoomEvent::Cancel | RoomEvent::Decline, ) => Ok(RoomState::Cancelled), _ => Err(InvalidTransition { state, event }), @@ -68,6 +72,14 @@ mod tests { assert_eq!(finished, RoomState::Finished); } + #[test] + fn an_active_player_can_abandon_a_match_safely() { + assert_eq!( + transition(RoomState::Playing, RoomEvent::Cancel), + Ok(RoomState::Cancelled) + ); + } + #[test] fn terminal_states_reject_all_transitions() { for state in [RoomState::Finished, RoomState::Cancelled] { diff --git a/apps/server/src/routes/alpha.rs b/apps/server/src/routes/alpha.rs new file mode 100644 index 0000000..62558fc --- /dev/null +++ b/apps/server/src/routes/alpha.rs @@ -0,0 +1,488 @@ +use axum::{Json, extract::Path, extract::State, http::StatusCode}; +use chrono::{DateTime, Duration, Utc}; +use opencade_protocol::{ + Envelope, LaunchBarrierPayload, MatchPreflightPayload, MatchReportCompatibility, + NativeRouteCapability, RoomInvitePayload, RoomSnapshotPayload, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + authn::{AuthUser, hash_token}, + error::AppError, + room_state::{RoomEvent, from_database, to_database, transition}, + state::AppState, +}; + +use super::rooms::{notify_room_state, room_payload}; + +const INVITE_TTL_MINUTES: i64 = 15; +const LAUNCH_DELAY_SECONDS: i64 = 5; + +#[derive(Debug, Deserialize)] +pub struct JoinInviteRequest { + code: String, +} + +pub async fn create_invite( + State(state): State, + user: AuthUser, + Path(room_id): Path, +) -> Result<(StatusCode, Json>), AppError> { + let host = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM rooms WHERE id = $1 AND host_user_id = $2 AND state = 'WAITING')", + ) + .bind(room_id) + .bind(user.id) + .fetch_one(&state.pool) + .await?; + if !host { + return Err(AppError::Forbidden( + "only the host of a waiting room can create an invite".into(), + )); + } + + let code = Uuid::new_v4().simple().to_string()[..10].to_ascii_uppercase(); + let expires_at = Utc::now() + Duration::minutes(INVITE_TTL_MINUTES); + sqlx::query( + "INSERT INTO room_invites (code_hash, room_id, created_by, expires_at) + VALUES ($1, $2, $3, $4)", + ) + .bind(hash_token(&code)) + .bind(room_id) + .bind(user.id) + .bind(expires_at) + .execute(&state.pool) + .await?; + sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, 'room.invite.created')") + .bind(room_id) + .execute(&state.pool) + .await?; + + let payload = RoomInvitePayload { + room_id: room_id.to_string(), + code, + expires_at, + }; + Ok(( + StatusCode::CREATED, + Json(Envelope::new("room.invite.created", json!(payload))), + )) +} + +pub async fn join_invite( + State(state): State, + user: AuthUser, + Json(request): Json, +) -> Result>, AppError> { + let code = request.code.trim().to_ascii_uppercase(); + if code.len() != 10 || !code.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(AppError::BadRequest("invite code is invalid".into())); + } + + let mut transaction = state.pool.begin().await?; + let row = sqlx::query( + "SELECT room_id FROM room_invites + WHERE code_hash = $1 AND expires_at > now() AND consumed_at IS NULL + FOR UPDATE", + ) + .bind(hash_token(&code)) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| AppError::NotFound("invite is invalid, expired, or already used".into()))?; + let room_id: Uuid = row + .try_get("room_id") + .map_err(|_| AppError::Internal("invalid invite record".into()))?; + let room = + sqlx::query("SELECT host_user_id, game_id, state FROM rooms WHERE id = $1 FOR UPDATE") + .bind(room_id) + .fetch_one(&mut *transaction) + .await?; + let host_id: Uuid = room + .try_get("host_user_id") + .map_err(|_| AppError::Internal("invalid room record".into()))?; + let game_id: String = room + .try_get("game_id") + .map_err(|_| AppError::Internal("invalid room record".into()))?; + if host_id == user.id { + return Err(AppError::Forbidden( + "room hosts cannot redeem their own invite".into(), + )); + } + let current = from_database( + room.try_get::("state") + .map_err(|_| AppError::Internal("invalid room record".into()))? + .as_str(), + ) + .map_err(AppError::Internal)?; + let next = transition(current, RoomEvent::Accept) + .map_err(|error| AppError::Conflict(error.to_string()))?; + let members = + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM room_members WHERE room_id = $1") + .bind(room_id) + .fetch_one(&mut *transaction) + .await?; + if members >= 2 { + return Err(AppError::Conflict("room is full".into())); + } + sqlx::query( + "UPDATE rooms SET state = 'CANCELLED' + WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING' AND id <> $3", + ) + .bind(user.id) + .bind(&game_id) + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES ($1, $2)") + .bind(room_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; + sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + .bind(room_id) + .bind(to_database(&next)) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE room_invites SET consumed_at = now(), consumed_by = $2 WHERE code_hash = $1", + ) + .bind(hash_token(&code)) + .bind(user.id) + .execute(&mut *transaction) + .await?; + record_event(&mut transaction, room_id, "room.invite.joined").await?; + transaction.commit().await?; + + notify_room_state(&state, room_id, user.id).await +} + +pub async fn submit_preflight( + State(state): State, + user: AuthUser, + Path(room_id): Path, + Json(payload): Json, +) -> Result>, AppError> { + if payload.room_id != room_id.to_string() { + return Err(AppError::BadRequest( + "preflight room does not match path".into(), + )); + } + validate_compatibility(&payload.compatibility)?; + let mut transaction = state.pool.begin().await?; + let attempt_id = lock_active_attempt(&mut transaction, room_id, user.id).await?; + sqlx::query("DELETE FROM room_launch_barriers WHERE room_id = $1") + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO match_preflights + (room_id, user_id, compatibility, native_port_available, ready, updated_at, attempt_id) + VALUES ($1, $2, $3, $4, FALSE, now(), $5) + ON CONFLICT (room_id, user_id) DO UPDATE SET + compatibility = EXCLUDED.compatibility, + native_port_available = EXCLUDED.native_port_available, + ready = FALSE, + attempt_id = EXCLUDED.attempt_id, + updated_at = now()", + ) + .bind(room_id) + .bind(user.id) + .bind(json!(payload.compatibility)) + .bind(payload.native_port_available) + .bind(attempt_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events + (room_id, event_type, attempt_id, actor_id, payload) + VALUES ($1, 'match.preflight', $2, $3, $4)", + ) + .bind(room_id) + .bind(attempt_id) + .bind(user.id) + .bind(json!({ "native_port_available": payload.native_port_available })) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + let snapshot = build_snapshot(&state, room_id, user.id).await?; + notify_members(&state, room_id, "room.snapshot", json!(snapshot)).await?; + Ok(Json(Envelope::new( + "match.preflight.accepted", + json!(snapshot), + ))) +} + +pub async fn room_snapshot( + State(state): State, + user: AuthUser, + Path(room_id): Path, +) -> Result>, AppError> { + let snapshot = build_snapshot(&state, room_id, user.id).await?; + Ok(Json(Envelope::new("room.snapshot", json!(snapshot)))) +} + +pub async fn ready_to_launch( + State(state): State, + user: AuthUser, + Path(room_id): Path, +) -> Result>, AppError> { + let mut transaction = state.pool.begin().await?; + let attempt_id = lock_active_attempt(&mut transaction, room_id, user.id).await?; + let (preflight_count, compatibility_matched) = + preflight_status(&mut transaction, room_id, attempt_id).await?; + if preflight_count != 2 || !compatibility_matched { + return Err(AppError::Conflict( + "both peers must pass matching compatibility preflight".into(), + )); + } + sqlx::query( + "UPDATE match_preflights SET ready = TRUE, updated_at = now() + WHERE room_id = $1 AND user_id = $2 AND attempt_id = $3 + AND native_port_available = TRUE", + ) + .bind(room_id) + .bind(user.id) + .bind(attempt_id) + .execute(&mut *transaction) + .await?; + let ready_count = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM match_preflights + WHERE room_id = $1 AND attempt_id = $2 AND ready = TRUE", + ) + .bind(room_id) + .bind(attempt_id) + .fetch_one(&mut *transaction) + .await?; + if ready_count == 2 { + sqlx::query( + "INSERT INTO room_launch_barriers (room_id, attempt_id, launch_at) + VALUES ($1, $2, now() + make_interval(secs => $3)) + ON CONFLICT (room_id) DO UPDATE SET + attempt_id = EXCLUDED.attempt_id, + launch_at = EXCLUDED.launch_at + WHERE room_launch_barriers.attempt_id <> EXCLUDED.attempt_id + OR room_launch_barriers.launch_at <= now()", + ) + .bind(room_id) + .bind(attempt_id) + .bind(LAUNCH_DELAY_SECONDS as f64) + .execute(&mut *transaction) + .await?; + } + sqlx::query( + "INSERT INTO room_events + (room_id, event_type, attempt_id, actor_id, payload) + VALUES ($1, 'match.launch.ready', $2, $3, $4)", + ) + .bind(room_id) + .bind(attempt_id) + .bind(user.id) + .bind(json!({ "ready_count": ready_count })) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + let snapshot = build_snapshot(&state, room_id, user.id).await?; + notify_members(&state, room_id, "room.snapshot", json!(snapshot)).await?; + Ok(Json(Envelope::new("match.launch.ready", json!(snapshot)))) +} + +async fn build_snapshot( + state: &AppState, + room_id: Uuid, + user_id: Uuid, +) -> Result { + let room = room_payload(state, room_id, user_id).await?; + let rows = sqlx::query( + "SELECT compatibility, native_port_available, ready + FROM match_preflights + WHERE room_id = $1 + AND attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1) + ORDER BY user_id", + ) + .bind(room_id) + .fetch_all(&state.pool) + .await?; + let compatibilities = rows + .iter() + .filter_map(|row| row.try_get::("compatibility").ok()) + .collect::>(); + let compatibility_matched = compatibilities.len() == 2 + && compatibilities[0] == compatibilities[1] + && rows.iter().all(|row| { + row.try_get::("native_port_available") + .unwrap_or(false) + }); + let ready_count = rows + .iter() + .filter(|row| row.try_get::("ready").unwrap_or(false)) + .count(); + let launch_at = sqlx::query_scalar::<_, DateTime>( + "SELECT launch_at FROM room_launch_barriers + WHERE room_id = $1 + AND attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1)", + ) + .bind(room_id) + .fetch_optional(&state.pool) + .await?; + let revision = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(max(revision), 0) FROM room_events WHERE room_id = $1", + ) + .bind(room_id) + .fetch_one(&state.pool) + .await?; + Ok(RoomSnapshotPayload { + room, + revision, + preflight_count: u8::try_from(rows.len()).unwrap_or(u8::MAX), + compatibility_matched, + barrier: LaunchBarrierPayload { + room_id: room_id.to_string(), + ready_count: u8::try_from(ready_count).unwrap_or(u8::MAX), + required_count: 2, + launch_at, + }, + route: NativeRouteCapability::DirectLan, + }) +} + +async fn lock_active_attempt( + transaction: &mut Transaction<'_, Postgres>, + room_id: Uuid, + user_id: Uuid, +) -> Result { + let row = sqlx::query( + "SELECT rooms.attempt_id, rooms.state, rooms.state_deadline_at + FROM rooms + JOIN room_members ON room_members.room_id = rooms.id + WHERE rooms.id = $1 AND room_members.user_id = $2 + FOR UPDATE OF rooms", + ) + .bind(room_id) + .bind(user_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or_else(|| AppError::Forbidden("not a room member".into()))?; + let room_state: String = row + .try_get("state") + .map_err(|_| AppError::Internal("invalid room record".into()))?; + if !matches!(room_state.as_str(), "CONNECTING" | "PLAYING") { + return Err(AppError::Conflict( + "room is not active for match setup".into(), + )); + } + let deadline: Option> = row + .try_get("state_deadline_at") + .map_err(|_| AppError::Internal("invalid room record".into()))?; + if deadline.is_some_and(|value| value <= Utc::now()) { + return Err(AppError::Conflict("room match setup has expired".into())); + } + row.try_get("attempt_id") + .map_err(|_| AppError::Internal("invalid room attempt".into())) +} + +async fn preflight_status( + transaction: &mut Transaction<'_, Postgres>, + room_id: Uuid, + attempt_id: Uuid, +) -> Result<(usize, bool), AppError> { + let rows = sqlx::query( + "SELECT compatibility, native_port_available + FROM match_preflights + WHERE room_id = $1 AND attempt_id = $2 + ORDER BY user_id", + ) + .bind(room_id) + .bind(attempt_id) + .fetch_all(&mut **transaction) + .await?; + let compatibilities = rows + .iter() + .filter_map(|row| row.try_get::("compatibility").ok()) + .collect::>(); + let matched = compatibilities.len() == 2 + && compatibilities[0] == compatibilities[1] + && rows.iter().all(|row| { + row.try_get::("native_port_available") + .unwrap_or(false) + }); + Ok((rows.len(), matched)) +} + +async fn notify_members( + state: &AppState, + room_id: Uuid, + event: &str, + payload: Value, +) -> Result<(), AppError> { + let members = + sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM room_members WHERE room_id = $1") + .bind(room_id) + .fetch_all(&state.pool) + .await?; + for member in members { + state.notify_user(member, event, payload.clone()); + } + Ok(()) +} + +async fn record_event( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + room_id: Uuid, + event: &str, +) -> Result<(), AppError> { + sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, $2)") + .bind(room_id) + .bind(event) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +fn validate_compatibility(value: &MatchReportCompatibility) -> Result<(), AppError> { + let valid_hash = |hash: &str| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }; + if value.adapter != "retroarch_fbneo" + || value + .emulator_version + .as_ref() + .is_some_and(|version| version.trim().is_empty() || version.len() > 64) + || !valid_hash(&value.executable_sha256) + || !valid_hash(&value.core_sha256) + || !valid_hash(&value.content_sha256) + { + return Err(AppError::BadRequest( + "compatibility preflight is invalid".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compatibility() -> MatchReportCompatibility { + MatchReportCompatibility { + adapter: "retroarch_fbneo".into(), + emulator_version: Some("1.22.0".into()), + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + } + } + + #[test] + fn compatibility_is_fail_closed() { + assert!(validate_compatibility(&compatibility()).is_ok()); + let mut invalid = compatibility(); + invalid.content_sha256 = "C:\\ROMs\\sfiii3.zip".into(); + assert!(validate_compatibility(&invalid).is_err()); + } +} diff --git a/apps/server/src/routes/alpha_evidence.rs b/apps/server/src/routes/alpha_evidence.rs new file mode 100644 index 0000000..73e7f72 --- /dev/null +++ b/apps/server/src/routes/alpha_evidence.rs @@ -0,0 +1,243 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use opencade_networking::summarize_campaign_evidence; +use opencade_protocol::{AlphaFailureReport, Envelope, MatchReport, MatchReportRole}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{authn::AuthUser, error::AppError, state::AppState}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvidenceRequest { + evidence: Value, +} + +pub async fn submit_evidence( + State(state): State, + user: AuthUser, + Json(request): Json, +) -> Result<(StatusCode, Json>), AppError> { + let raw_encoded = serde_json::to_vec(&request.evidence) + .map_err(|_| AppError::BadRequest("evidence is invalid".into()))?; + if raw_encoded.len() > 64 * 1024 { + return Err(AppError::BadRequest( + "evidence exceeds the 64 KiB limit".into(), + )); + } + let (room_id, role, kind, canonical) = decode_evidence(&request.evidence)?; + if contains_private_key(&canonical) { + return Err(AppError::BadRequest( + "evidence contains private fields".into(), + )); + } + let encoded = serde_json::to_vec(&canonical) + .map_err(|_| AppError::BadRequest("evidence is invalid".into()))?; + let expected_role = member_role(&state, room_id, user.id).await?; + if role != expected_role { + return Err(AppError::Forbidden( + "evidence role does not match room membership".into(), + )); + } + let digest = hex::encode(Sha256::digest(&encoded)); + let inserted = sqlx::query( + "INSERT INTO alpha_evidence (digest, room_id, submitted_by, role, kind, payload) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING", + ) + .bind(&digest) + .bind(room_id) + .bind(user.id) + .bind(role_label(role)) + .bind(kind) + .bind(canonical) + .execute(&state.pool) + .await?; + reject_conflicting_duplicate(&state, room_id, user.id, kind, &digest, &inserted).await?; + let duplicate = inserted.rows_affected() == 0; + Ok(( + if duplicate { + StatusCode::OK + } else { + StatusCode::CREATED + }, + Json(Envelope::new( + "alpha.evidence.accepted", + json!({ "digest": digest, "duplicate": duplicate }), + )), + )) +} + +pub async fn campaign_summary( + State(state): State, + _user: AuthUser, + headers: HeaderMap, +) -> Result>, AppError> { + state.require_operator( + headers + .get("x-operator-token") + .and_then(|value| value.to_str().ok()), + )?; + let values = sqlx::query_scalar::<_, Value>( + "WITH recent_rooms AS ( + SELECT room_id, MAX(created_at) AS latest + FROM alpha_evidence GROUP BY room_id + ORDER BY latest DESC LIMIT 500 + ) + SELECT evidence.payload FROM alpha_evidence AS evidence + JOIN recent_rooms USING (room_id) + ORDER BY evidence.created_at DESC", + ) + .fetch_all(&state.pool) + .await?; + let mut matches = Vec::new(); + let mut failures = Vec::new(); + for value in values { + if value.get("kind").is_none() { + if let Ok(report) = serde_json::from_value::(value) { + matches.push(report); + } + } else if let Ok(report) = serde_json::from_value::(value) { + failures.push(report); + } + } + let total_rooms = + sqlx::query_scalar::<_, i64>("SELECT COUNT(DISTINCT room_id) FROM alpha_evidence") + .fetch_one(&state.pool) + .await?; + let mut summary = serde_json::to_value(summarize_campaign_evidence(&matches, &failures))?; + if let Some(summary) = summary.as_object_mut() { + summary.insert("cohort_limit".into(), json!(500)); + summary.insert("total_evidence_rooms".into(), json!(total_rooms)); + summary.insert("cohort_truncated".into(), json!(total_rooms > 500)); + } + Ok(Json(Envelope::new("alpha.campaign.summary", summary))) +} + +fn decode_evidence( + value: &Value, +) -> Result<(Uuid, MatchReportRole, &'static str, Value), AppError> { + match value.get("kind") { + None => { + let report: MatchReport = serde_json::from_value(value.clone()) + .map_err(|_| AppError::BadRequest("match evidence is invalid".into()))?; + let room_id = parse_room_id(&report.room.id)?; + let role = report.probe.role; + let canonical = serde_json::to_value(report) + .map_err(|_| AppError::BadRequest("match evidence is invalid".into()))?; + Ok((room_id, role, "match", canonical)) + } + Some(Value::String(kind)) if kind == "attempt_failure" => { + let report: AlphaFailureReport = serde_json::from_value(value.clone()) + .map_err(|_| AppError::BadRequest("failure evidence is invalid".into()))?; + let room_id = parse_room_id(&report.room.id)?; + let role = report.role; + let canonical = serde_json::to_value(report) + .map_err(|_| AppError::BadRequest("failure evidence is invalid".into()))?; + Ok((room_id, role, "attempt_failure", canonical)) + } + _ => Err(AppError::BadRequest("evidence kind is invalid".into())), + } +} + +async fn reject_conflicting_duplicate( + state: &AppState, + room_id: Uuid, + user_id: Uuid, + kind: &str, + digest: &str, + inserted: &sqlx::postgres::PgQueryResult, +) -> Result<(), AppError> { + if inserted.rows_affected() == 1 { + return Ok(()); + } + let existing = sqlx::query_scalar::<_, String>( + "SELECT digest FROM alpha_evidence + WHERE room_id = $1 AND submitted_by = $2 AND kind = $3", + ) + .bind(room_id) + .bind(user_id) + .bind(kind) + .fetch_optional(&state.pool) + .await?; + if existing.as_deref().is_some_and(|value| value != digest) { + return Err(AppError::Conflict( + "different evidence already exists for this attempt and role".into(), + )); + } + Ok(()) +} + +async fn member_role( + state: &AppState, + room_id: Uuid, + user_id: Uuid, +) -> Result { + let host = sqlx::query_scalar::<_, Uuid>( + "SELECT rooms.host_user_id FROM rooms + JOIN room_members ON room_members.room_id = rooms.id + WHERE rooms.id = $1 AND room_members.user_id = $2", + ) + .bind(room_id) + .bind(user_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::Forbidden("not a room member".into()))?; + Ok(if host == user_id { + MatchReportRole::Host + } else { + MatchReportRole::Guest + }) +} + +fn contains_private_key(value: &Value) -> bool { + const FORBIDDEN: &[&str] = &[ + "token", + "session_token", + "local_user_id", + "peer_user_id", + "host_id", + "guest_id", + "endpoint", + "nonce", + "path", + "rom_path", + ]; + match value { + Value::Object(map) => map + .iter() + .any(|(key, value)| FORBIDDEN.contains(&key.as_str()) || contains_private_key(value)), + Value::Array(values) => values.iter().any(contains_private_key), + _ => false, + } +} + +fn parse_room_id(value: &str) -> Result { + Uuid::parse_str(value).map_err(|_| AppError::BadRequest("evidence room id is invalid".into())) +} + +fn role_label(role: MatchReportRole) -> &'static str { + match role { + MatchReportRole::Host => "host", + MatchReportRole::Guest => "guest", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn privacy_filter_walks_nested_evidence() { + assert!(!contains_private_key(&json!({"room": {"id": "safe"}}))); + assert!(contains_private_key( + &json!({"probe": {"endpoint": "127.0.0.1"}}) + )); + assert!(contains_private_key(&json!([{"session_token": "secret"}]))); + } +} diff --git a/apps/server/src/routes/auth.rs b/apps/server/src/routes/auth.rs index 324e479..2c2a768 100644 --- a/apps/server/src/routes/auth.rs +++ b/apps/server/src/routes/auth.rs @@ -102,11 +102,24 @@ pub async fn register( )); } - let salt = SaltString::generate(&mut OsRng); - let password_hash = Argon2::default() - .hash_password(request.password.as_bytes(), &salt) - .map_err(|_| AppError::Internal("password hashing failed".into()))? - .to_string(); + let permit = state + .password_semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| AppError::Internal("password worker unavailable".into()))?; + let password = request.password.clone(); + let password_hash = tokio::task::spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|_| ()) + }) + .await + .map_err(|_| AppError::Internal("password worker failed".into()))? + .map_err(|_| AppError::Internal("password hashing failed".into()))?; + drop(permit); let token = generate_session_token(); let token_hash = hash_token(&token); let expires_at = chrono::Utc::now() + chrono::Duration::days(30); @@ -185,11 +198,7 @@ pub async fn login( .map_err(|error| database_error(error, "find login user"))?; let Some(row) = row else { - let salt = SaltString::encode_b64(b"opencade-dummy-salt") - .map_err(|_| AppError::Internal("password verifier unavailable".into()))?; - Argon2::default() - .hash_password(request.password.as_bytes(), &salt) - .map_err(|_| AppError::Internal("password verifier unavailable".into()))?; + run_password_verification(&state, request.password, None).await?; return Err(AppError::Unauthorized("invalid credentials".into())); }; @@ -205,11 +214,9 @@ pub async fn login( let stored_hash: String = row .try_get("password_hash") .map_err(|_| AppError::Internal("invalid user record".into()))?; - let parsed_hash = PasswordHash::new(&stored_hash) - .map_err(|_| AppError::Internal("invalid password record".into()))?; - Argon2::default() - .verify_password(request.password.as_bytes(), &parsed_hash) - .map_err(|_| AppError::Unauthorized("invalid credentials".into()))?; + if !run_password_verification(&state, request.password, Some(stored_hash)).await? { + return Err(AppError::Unauthorized("invalid credentials".into())); + } state.auth_rate_limiter.clear(&rate_key); let token = generate_session_token(); @@ -233,6 +240,36 @@ pub async fn login( ))) } +async fn run_password_verification( + state: &AppState, + password: String, + stored_hash: Option, +) -> Result { + let permit = state + .password_semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| AppError::Internal("password worker unavailable".into()))?; + let verified = tokio::task::spawn_blocking(move || match stored_hash { + Some(stored_hash) => PasswordHash::new(&stored_hash).ok().is_some_and(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() + }), + None => match SaltString::encode_b64(b"opencade-dummy-salt") { + Ok(salt) => Argon2::default() + .hash_password(password.as_bytes(), &salt) + .is_ok(), + Err(_) => false, + }, + }) + .await + .map_err(|_| AppError::Internal("password worker failed".into()))?; + drop(permit); + Ok(verified) +} + pub async fn logout( State(state): State, user: AuthUser, diff --git a/apps/server/src/routes/challenges.rs b/apps/server/src/routes/challenges.rs index 7ba655a..6c2a192 100644 --- a/apps/server/src/routes/challenges.rs +++ b/apps/server/src/routes/challenges.rs @@ -1,5 +1,6 @@ use axum::{Json, extract::Path, extract::State, http::StatusCode}; -use opencade_protocol::Envelope; +use chrono::{DateTime, Utc}; +use opencade_protocol::{Envelope, RoomState}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::{Postgres, Row, Transaction}; @@ -27,6 +28,9 @@ struct ChallengeView { challenger_id: Uuid, challenged_id: Uuid, state: String, + expires_at: DateTime, + #[serde(skip)] + room_state: String, } async fn locked_challenge( @@ -35,7 +39,8 @@ async fn locked_challenge( ) -> Result { let row = sqlx::query( "SELECT challenges.id, challenges.room_id, rooms.game_id, - challenges.challenger_id, challenges.challenged_id, challenges.state + challenges.challenger_id, challenges.challenged_id, challenges.state, + challenges.expires_at, rooms.state AS room_state FROM challenges JOIN rooms ON rooms.id = challenges.room_id WHERE challenges.id = $1 @@ -53,6 +58,8 @@ async fn locked_challenge( challenger_id: row.try_get("challenger_id").map_err(invalid_record)?, challenged_id: row.try_get("challenged_id").map_err(invalid_record)?, state: row.try_get("state").map_err(invalid_record)?, + expires_at: row.try_get("expires_at").map_err(invalid_record)?, + room_state: row.try_get("room_state").map_err(invalid_record)?, }) } @@ -90,22 +97,56 @@ pub async fn create_challenge( } let mut transaction = state.pool.begin().await?; + let recent = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM challenges + WHERE challenger_id = $1 AND created_at > now() - interval '10 minutes'", + ) + .bind(user.id) + .fetch_one(&mut *transaction) + .await?; + if recent >= 10 { + return Err(AppError::RateLimited( + "challenge creation limit exceeded".into(), + )); + } + sqlx::query( + "UPDATE challenges SET state = 'EXPIRED' + WHERE state = 'PENDING' AND expires_at <= now()", + ) + .execute(&mut *transaction) + .await?; sqlx::query( - "UPDATE rooms SET state = 'CANCELLED' - WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING'", + "WITH cancelled AS ( + UPDATE rooms SET state = 'CANCELLED' + WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING' + RETURNING attempt_id + ) + UPDATE match_attempts SET state = 'CANCELLED', finished_at = now() + WHERE attempt_id IN (SELECT attempt_id FROM cancelled) AND state = 'ACTIVE'", ) .bind(user.id) .bind(&request.game_id) .execute(&mut *transaction) .await?; let room_id = Uuid::new_v4(); + let attempt_id = Uuid::new_v4(); sqlx::query( - "INSERT INTO rooms (id, game_id, host_user_id, state, max_players) - VALUES ($1, $2, $3, 'CHALLENGING', 2)", + "INSERT INTO rooms + (id, game_id, host_user_id, state, max_players, state_deadline_at, attempt_id) + VALUES ($1, $2, $3, 'CHALLENGING', 2, now() + interval '5 minutes', $4)", ) .bind(room_id) .bind(&request.game_id) .bind(user.id) + .bind(attempt_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO match_attempts (attempt_id, room_id, state, deadline_at) + VALUES ($1, $2, 'ACTIVE', now() + interval '5 minutes')", + ) + .bind(attempt_id) + .bind(room_id) .execute(&mut *transaction) .await?; sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES ($1, $2)") @@ -114,16 +155,31 @@ pub async fn create_challenge( .execute(&mut *transaction) .await?; let row = sqlx::query( - "INSERT INTO challenges (room_id, challenger_id, challenged_id, state) - VALUES ($1, $2, $3, 'PENDING') + "INSERT INTO challenges + (room_id, challenger_id, challenged_id, state, expires_at) + VALUES ($1, $2, $3, 'PENDING', now() + interval '5 minutes') + ON CONFLICT (challenger_id, challenged_id) WHERE state = 'PENDING' DO NOTHING RETURNING id", ) .bind(room_id) .bind(user.id) .bind(request.challenged_id) - .fetch_one(&mut *transaction) + .fetch_optional(&mut *transaction) .await?; + let row = row.ok_or_else(|| { + AppError::Conflict("an active challenge already exists for these users".into()) + })?; let challenge_id: Uuid = row.try_get("id").map_err(invalid_record)?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + VALUES ($1, $2, $3, 'challenge.created', jsonb_build_object('challenge_id', $4))", + ) + .bind(room_id) + .bind(attempt_id) + .bind(user.id) + .bind(challenge_id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; let payload = json!({ @@ -147,10 +203,12 @@ pub async fn list_incoming( ) -> Result>, AppError> { let rows = sqlx::query( "SELECT challenges.id, challenges.room_id, rooms.game_id, - challenges.challenger_id, challenges.challenged_id, challenges.state + challenges.challenger_id, challenges.challenged_id, challenges.state, + challenges.expires_at, rooms.state AS room_state FROM challenges JOIN rooms ON rooms.id = challenges.room_id WHERE challenges.challenged_id = $1 AND challenges.state = 'PENDING' + AND challenges.expires_at > now() ORDER BY challenges.created_at DESC LIMIT 50", ) @@ -170,6 +228,8 @@ pub async fn list_incoming( .try_get::("state") .map_err(invalid_record)? .to_ascii_lowercase(), + expires_at: row.try_get("expires_at").map_err(invalid_record)?, + room_state: row.try_get("room_state").map_err(invalid_record)?, }) }) .collect::, AppError>>()?; @@ -191,31 +251,71 @@ pub async fn accept_challenge( "only the challenged user can accept".into(), )); } + if challenge.state == "ACCEPTED" { + transaction.rollback().await?; + challenge.state = "accepted".into(); + let payload = serde_json::to_value(&challenge) + .map_err(|_| AppError::Internal("failed to serialize challenge".into()))?; + return Ok(Json(Envelope::new("challenge.accepted", payload))); + } require_pending(&challenge)?; + let current = + crate::room_state::from_database(&challenge.room_state).map_err(AppError::Internal)?; + if current != RoomState::Challenging { + return Err(AppError::Conflict( + "challenge room is no longer active".into(), + )); + } sqlx::query( - "UPDATE rooms SET state = 'CANCELLED' - WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING'", + "WITH cancelled AS ( + UPDATE rooms SET state = 'CANCELLED' + WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING' + RETURNING attempt_id + ) + UPDATE match_attempts SET state = 'CANCELLED', finished_at = now() + WHERE attempt_id IN (SELECT attempt_id FROM cancelled) AND state = 'ACTIVE'", ) .bind(user.id) .bind(&challenge.game_id) .execute(&mut *transaction) .await?; + sqlx::query( + "UPDATE match_attempts + SET deadline_at = now() + interval '2 minutes' + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1)", + ) + .bind(challenge.room_id) + .execute(&mut *transaction) + .await?; sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES ($1, $2)") .bind(challenge.room_id) .bind(user.id) .execute(&mut *transaction) .await?; - let next = transition(opencade_protocol::RoomState::Challenging, RoomEvent::Accept) + let next = transition(current, RoomEvent::Accept) .map_err(|error| AppError::Conflict(error.to_string()))?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") - .bind(challenge.room_id) - .bind(to_database(&next)) - .execute(&mut *transaction) - .await?; + sqlx::query( + "UPDATE rooms SET state = $2, state_deadline_at = now() + interval '2 minutes' + WHERE id = $1", + ) + .bind(challenge.room_id) + .bind(to_database(&next)) + .execute(&mut *transaction) + .await?; sqlx::query("UPDATE challenges SET state = 'ACCEPTED' WHERE id = $1") .bind(id) .execute(&mut *transaction) .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, 'challenge.accepted', jsonb_build_object('challenge_id', $3) + FROM rooms WHERE id = $1", + ) + .bind(challenge.room_id) + .bind(user.id) + .bind(id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; challenge.state = "accepted".into(); let payload = serde_json::to_value(&challenge) @@ -267,10 +367,35 @@ async fn resolve_challenge( .bind(challenge_state) .execute(&mut *transaction) .await?; - sqlx::query("UPDATE rooms SET state = 'CANCELLED' WHERE id = $1") + sqlx::query("UPDATE rooms SET state = 'CANCELLED', state_deadline_at = NULL WHERE id = $1") .bind(challenge.room_id) .execute(&mut *transaction) .await?; + sqlx::query( + "UPDATE match_attempts + SET state = 'CANCELLED', finished_at = COALESCE(finished_at, now()), + deadline_at = NULL + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1) + AND state = 'ACTIVE'", + ) + .bind(challenge.room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, $3, jsonb_build_object('challenge_id', $4) + FROM rooms WHERE id = $1", + ) + .bind(challenge.room_id) + .bind(user.id) + .bind(if cancel { + "challenge.cancelled" + } else { + "challenge.declined" + }) + .bind(id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; challenge.state = challenge_state.to_ascii_lowercase(); @@ -291,7 +416,7 @@ async fn resolve_challenge( } fn require_pending(challenge: &ChallengeView) -> Result<(), AppError> { - if challenge.state == "PENDING" { + if challenge.state == "PENDING" && challenge.expires_at > Utc::now() { Ok(()) } else { Err(AppError::Conflict("challenge is no longer pending".into())) @@ -311,6 +436,8 @@ mod tests { challenger_id: Uuid::nil(), challenged_id: Uuid::new_v4(), state: "DECLINED".into(), + expires_at: Utc::now() + chrono::Duration::minutes(5), + room_state: "CHALLENGING".into(), }; assert!(matches!( require_pending(&challenge), diff --git a/apps/server/src/routes/mod.rs b/apps/server/src/routes/mod.rs index 87fb054..942a0b9 100644 --- a/apps/server/src/routes/mod.rs +++ b/apps/server/src/routes/mod.rs @@ -1,6 +1,9 @@ +pub mod alpha; +pub mod alpha_evidence; pub mod auth; pub mod challenges; pub mod games; pub mod lobbies; pub mod rooms; pub mod servers; +pub mod telemetry; diff --git a/apps/server/src/routes/rooms.rs b/apps/server/src/routes/rooms.rs index ca6a8a0..c21bc1d 100644 --- a/apps/server/src/routes/rooms.rs +++ b/apps/server/src/routes/rooms.rs @@ -2,7 +2,7 @@ use std::sync::atomic::Ordering; use axum::{Json, extract::Path, extract::State, http::StatusCode}; use opencade_protocol::{Envelope, RoomPayload, RoomState}; -use opencade_shared::RelayTicket; +use opencade_shared::{RelayCapability, RelayTicket}; use serde::Deserialize; use serde_json::{Value, json}; use sqlx::{Postgres, Row, Transaction}; @@ -34,6 +34,8 @@ pub struct CreateLaunchGrantRequest { local_endpoint: String, peer_endpoint: String, input_delay_frames: u8, + #[serde(default)] + campaign_mode: bool, } #[derive(Debug, Deserialize)] @@ -45,7 +47,7 @@ fn default_max_players() -> i32 { 2 } -async fn room_payload( +pub(crate) async fn room_payload( state: &AppState, room_id: Uuid, requesting_user: Uuid, @@ -149,22 +151,59 @@ pub async fn create_room( } let room_id = Uuid::new_v4(); + let attempt_id = Uuid::new_v4(); let mut transaction = state.pool.begin().await?; - sqlx::query( - "INSERT INTO rooms (id, game_id, host_user_id, state, max_players) - VALUES ($1, $2, $3, 'WAITING', $4)", + let inserted = sqlx::query( + "INSERT INTO rooms + (id, game_id, host_user_id, state, max_players, attempt_id, state_deadline_at) + VALUES ($1, $2, $3, 'WAITING', $4, $5, now() + interval '24 hours') + ON CONFLICT (host_user_id, game_id) WHERE state = 'WAITING' DO NOTHING", ) .bind(room_id) .bind(&request.game_id) .bind(user.id) .bind(request.max_players) + .bind(attempt_id) .execute(&mut *transaction) .await?; + if inserted.rows_affected() == 0 { + transaction.rollback().await?; + let existing_id = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM rooms + WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING'", + ) + .bind(user.id) + .bind(&request.game_id) + .fetch_one(&state.pool) + .await?; + let payload = room_payload(&state, existing_id, user.id).await?; + return Ok(( + StatusCode::OK, + Json(Envelope::new("rooms.existing", json!(payload))), + )); + } sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES ($1, $2)") .bind(room_id) .bind(user.id) .execute(&mut *transaction) .await?; + sqlx::query( + "INSERT INTO match_attempts (attempt_id, room_id, state, deadline_at) + VALUES ($1, $2, 'ACTIVE', now() + interval '24 hours')", + ) + .bind(attempt_id) + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + VALUES ($1, $2, $3, 'room.created', jsonb_build_object('state', 'waiting'))", + ) + .bind(room_id) + .bind(attempt_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; state.metrics.rooms_created.fetch_add(1, Ordering::Relaxed); @@ -196,6 +235,23 @@ pub async fn accept_room( "room host cannot accept their own room".into(), )); } + let already_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = $1 AND user_id = $2)", + ) + .bind(id) + .bind(user.id) + .fetch_one(&mut *transaction) + .await?; + if already_member + && matches!( + current, + RoomState::Connecting | RoomState::Playing | RoomState::Finished + ) + { + transaction.rollback().await?; + let payload = room_payload(&state, id, user.id).await?; + return Ok(Json(Envelope::new("rooms.accepted", json!(payload)))); + } let members = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM room_members WHERE room_id = $1") .bind(id) @@ -214,15 +270,33 @@ pub async fn accept_room( .bind(user.id) .execute(&mut *transaction) .await?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") - .bind(id) - .bind(to_database(&next)) - .execute(&mut *transaction) - .await?; + sqlx::query( + "UPDATE rooms SET state = $2, state_deadline_at = now() + interval '2 minutes' + WHERE id = $1", + ) + .bind(id) + .bind(to_database(&next)) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE match_attempts + SET deadline_at = now() + interval '2 minutes' + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1)", + ) + .bind(id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, 'room.accepted', jsonb_build_object('state', 'connecting') + FROM rooms WHERE id = $1", + ) + .bind(id) + .bind(user.id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; - - let payload = room_payload(&state, id, user.id).await?; - Ok(Json(Envelope::new("rooms.accepted", json!(payload)))) + notify_room_state(&state, id, user.id).await } pub async fn decline_room( @@ -231,10 +305,7 @@ pub async fn decline_room( Path(id): Path, ) -> Result>, AppError> { change_room_state(&state, &user, id, RoomEvent::Decline, false).await?; - Ok(Json(Envelope::new( - "rooms.declined", - json!({ "room_id": id, "state": "cancelled" }), - ))) + notify_room_state(&state, id, user.id).await } pub async fn cancel_room( @@ -242,11 +313,8 @@ pub async fn cancel_room( user: AuthUser, Path(id): Path, ) -> Result>, AppError> { - change_room_state(&state, &user, id, RoomEvent::Cancel, true).await?; - Ok(Json(Envelope::new( - "rooms.cancelled", - json!({ "room_id": id, "state": "cancelled" }), - ))) + change_room_state(&state, &user, id, RoomEvent::Cancel, false).await?; + notify_room_state(&state, id, user.id).await } pub async fn start_room( @@ -271,6 +339,7 @@ pub async fn relay_ticket( user: AuthUser, Path(id): Path, ) -> Result>, AppError> { + require_ticket_budget(&state, user.id)?; let relay_url = state .config .relay_url @@ -313,6 +382,69 @@ pub async fn relay_ticket( ))) } +pub async fn native_tunnel_ticket( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> Result>, AppError> { + require_ticket_budget(&state, user.id)?; + let relay_url = state + .config + .relay_url + .as_deref() + .ok_or_else(|| AppError::Conflict("native tunnel is not configured".into()))?; + let relay_secret = state + .config + .relay_secret + .as_deref() + .ok_or_else(|| AppError::Internal("relay signing is not configured".into()))?; + let eligible = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM room_members + JOIN rooms ON rooms.id = room_members.room_id + WHERE room_members.room_id = $1 AND room_members.user_id = $2 + AND rooms.state IN ('CONNECTING', 'PLAYING') + AND (SELECT count(*) FROM match_preflights + WHERE room_id = $1 AND ready = TRUE) = 2 + )", + ) + .bind(id) + .bind(user.id) + .fetch_one(&state.pool) + .await?; + if !eligible { + return Err(AppError::Forbidden( + "native tunnel tickets require a ready two-peer room".into(), + )); + } + let expires_at = chrono::Utc::now().timestamp() + 120; + let ticket = RelayTicket::issue_scoped( + relay_secret.as_bytes(), + &id.to_string(), + &user.id.to_string(), + expires_at, + RelayCapability::NativeTcpTunnel, + ) + .map_err(|_| AppError::Internal("failed to issue native tunnel ticket".into()))?; + Ok(Json(Envelope::new( + "native_tcp_tunnel.ticket", + json!({ "relay_url": relay_url, "ticket": ticket }), + ))) +} + +fn require_ticket_budget(state: &AppState, user_id: Uuid) -> Result<(), AppError> { + if state + .auth_rate_limiter + .check_with_limit(&format!("relay-ticket:{user_id}"), 20) + { + Ok(()) + } else { + Err(AppError::RateLimited( + "relay ticket issuance limit exceeded".into(), + )) + } +} + pub async fn create_launch_grant( State(state): State, user: AuthUser, @@ -348,6 +480,24 @@ pub async fn create_launch_grant( "launch grants are issued only while a room is connecting".into(), )); } + if request.campaign_mode { + let ready = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM room_launch_barriers barrier + WHERE barrier.room_id = $1 AND barrier.launch_at <= now() + AND (SELECT count(*) FROM match_preflights + WHERE room_id = $1 AND ready = TRUE) = 2 + )", + ) + .bind(id) + .fetch_one(&mut *transaction) + .await?; + if !ready { + return Err(AppError::Conflict( + "community alpha launch barrier is not ready".into(), + )); + } + } let row = sqlx::query( "SELECT rooms.game_id, peer.user_id AS peer_user_id FROM rooms @@ -389,6 +539,14 @@ pub async fn create_launch_grant( .bind(expires_at) .execute(&mut *transaction) .await?; + sqlx::query( + "UPDATE match_attempts + SET deadline_at = now() + interval '2 minutes' + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1)", + ) + .bind(id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; Ok(Json(Envelope::new( @@ -518,11 +676,14 @@ async fn confirm_native_launch( if current == RoomState::Connecting && member_count == 2 && launched_count == member_count { let next = transition(current, RoomEvent::Start) .map_err(|error| AppError::Conflict(error.to_string()))?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") - .bind(room_id) - .bind(to_database(&next)) - .execute(&mut *transaction) - .await?; + sqlx::query( + "UPDATE rooms SET state = $2, state_deadline_at = now() + interval '4 hours' + WHERE id = $1", + ) + .bind(room_id) + .bind(to_database(&next)) + .execute(&mut *transaction) + .await?; sqlx::query( "INSERT INTO matches (room_id, game_id, started_at) SELECT id, game_id, now() FROM rooms WHERE id = $1 @@ -531,6 +692,24 @@ async fn confirm_native_launch( .bind(room_id) .execute(&mut *transaction) .await?; + sqlx::query( + "UPDATE match_attempts + SET deadline_at = now() + interval '4 hours' + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1) + AND state = 'ACTIVE'", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, 'room.playing', jsonb_build_object('state', 'playing') + FROM rooms WHERE id = $1", + ) + .bind(room_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; } transaction.commit().await?; @@ -573,11 +752,31 @@ async fn confirm_native_exit( RoomState::Connecting => { let next = transition(current, RoomEvent::Cancel) .map_err(|error| AppError::Conflict(error.to_string()))?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + sqlx::query("UPDATE rooms SET state = $2, state_deadline_at = NULL WHERE id = $1") .bind(room_id) .bind(to_database(&next)) .execute(&mut *transaction) .await?; + sqlx::query( + "UPDATE match_attempts + SET state = 'FAILED', failure_code = 'native_exit_before_start', + finished_at = COALESCE(finished_at, now()), deadline_at = NULL + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1) + AND state = 'ACTIVE'", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, 'room.failed', + jsonb_build_object('state', 'cancelled', 'failure_code', 'native_exit_before_start') + FROM rooms WHERE id = $1", + ) + .bind(room_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; } RoomState::Playing => { let member_count = sqlx::query_scalar::<_, i64>( @@ -596,7 +795,7 @@ async fn confirm_native_exit( if member_count == 2 && ended_count == member_count { let next = transition(current, RoomEvent::Finish) .map_err(|error| AppError::Conflict(error.to_string()))?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + sqlx::query("UPDATE rooms SET state = $2, state_deadline_at = NULL WHERE id = $1") .bind(room_id) .bind(to_database(&next)) .execute(&mut *transaction) @@ -607,6 +806,25 @@ async fn confirm_native_exit( .bind(room_id) .execute(&mut *transaction) .await?; + sqlx::query( + "UPDATE match_attempts + SET state = 'SUCCEEDED', finished_at = COALESCE(finished_at, now()), + deadline_at = NULL + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1) + AND state = 'ACTIVE'", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, 'room.finished', jsonb_build_object('state', 'finished') + FROM rooms WHERE id = $1", + ) + .bind(room_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; } } RoomState::Finished | RoomState::Cancelled => {} @@ -617,7 +835,7 @@ async fn confirm_native_exit( notify_room_state(state, room_id, user.id).await } -async fn notify_room_state( +pub(crate) async fn notify_room_state( state: &AppState, room_id: Uuid, requesting_user: Uuid, @@ -665,11 +883,39 @@ async fn change_room_state( } } let next = transition(current, event).map_err(|error| AppError::Conflict(error.to_string()))?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + sqlx::query("UPDATE rooms SET state = $2, state_deadline_at = NULL WHERE id = $1") .bind(room_id) .bind(to_database(&next)) .execute(&mut *transaction) .await?; + if next == RoomState::Cancelled { + sqlx::query( + "UPDATE challenges SET state = 'CANCELLED' + WHERE room_id = $1 AND state = 'PENDING'", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE match_attempts + SET state = 'CANCELLED', finished_at = COALESCE(finished_at, now()), + deadline_at = NULL + WHERE attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1) + AND state = 'ACTIVE'", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + } + sqlx::query( + "INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload) + SELECT id, attempt_id, $2, 'room.cancelled', jsonb_build_object('state', 'cancelled') + FROM rooms WHERE id = $1", + ) + .bind(room_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; transaction.commit().await?; Ok(()) } diff --git a/apps/server/src/routes/telemetry.rs b/apps/server/src/routes/telemetry.rs new file mode 100644 index 0000000..76dffe1 --- /dev/null +++ b/apps/server/src/routes/telemetry.rs @@ -0,0 +1,336 @@ +use std::collections::HashSet; + +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use opencade_protocol::{ + ActivationSummaryPayload, Envelope, ProductEventName, ProductEventPayload, ReadinessBlockCount, + ReadinessCheckId, +}; +use serde_json::{Value, json}; +use sqlx::Row; +use uuid::Uuid; + +use crate::{authn::AuthUser, error::AppError, state::AppState}; + +const ACTIVATION_WINDOW_DAYS: i32 = 30; +const RAW_RETENTION_DAYS: i32 = 90; +const MIN_AGGREGATE_COHORT: i64 = 3; +const MAX_EVENTS_PER_MINUTE: usize = 60; + +pub async fn record_event( + State(state): State, + user: AuthUser, + Json(payload): Json, +) -> Result<(StatusCode, Json>), AppError> { + if !state + .auth_rate_limiter + .check_with_limit(&format!("telemetry:{}", user.id), MAX_EVENTS_PER_MINUTE) + { + return Err(AppError::RateLimited( + "product telemetry rate limit exceeded".into(), + )); + } + let event_id = parse_uuid(&payload.event_id, "event id")?; + let anonymous_session_id = parse_uuid(&payload.anonymous_session_id, "anonymous session id")?; + validate_event(&payload)?; + + let known_game = + sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM games WHERE id = $1)") + .bind(&payload.game_id) + .fetch_one(&state.pool) + .await?; + if !known_game { + return Err(AppError::BadRequest("telemetry game id is unknown".into())); + } + + let blocked_checks = serde_json::to_value(&payload.blocked_checks)?; + let inserted = sqlx::query( + "INSERT INTO product_events + (event_id, anonymous_session_id, event_name, game_id, blocked_checks) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (event_id) DO NOTHING", + ) + .bind(event_id) + .bind(anonymous_session_id) + .bind(event_label(payload.event)) + .bind(&payload.game_id) + .bind(blocked_checks) + .execute(&state.pool) + .await?; + + sqlx::query("DELETE FROM product_events WHERE received_at < now() - make_interval(days => $1)") + .bind(RAW_RETENTION_DAYS) + .execute(&state.pool) + .await?; + + let duplicate = inserted.rows_affected() == 0; + Ok(( + if duplicate { + StatusCode::OK + } else { + StatusCode::CREATED + }, + Json(Envelope::new( + "telemetry.event.accepted", + json!({ "accepted": true, "duplicate": duplicate }), + )), + )) +} + +pub async fn activation_summary( + State(state): State, + _user: AuthUser, + headers: HeaderMap, +) -> Result>, AppError> { + state.require_operator( + headers + .get("x-operator-token") + .and_then(|value| value.to_str().ok()), + )?; + let row = sqlx::query( + "WITH windowed AS ( + SELECT anonymous_session_id, event_name, game_id, received_at + FROM product_events + WHERE received_at >= now() - make_interval(days => $1) + ), selected AS ( + SELECT anonymous_session_id, game_id, MIN(received_at) AS selected_at + FROM windowed WHERE event_name = 'game_selected' + GROUP BY anonymous_session_id, game_id + ) + SELECT + COUNT(DISTINCT selected.anonymous_session_id) AS selected_sessions, + COUNT(DISTINCT selected.anonymous_session_id) + FILTER (WHERE windowed.event_name = 'readiness_completed') AS ready_sessions, + COUNT(DISTINCT selected.anonymous_session_id) + FILTER (WHERE windowed.event_name = 'lobby_entered') AS lobby_sessions, + COUNT(DISTINCT selected.anonymous_session_id) + FILTER (WHERE windowed.event_name = 'launch_attempted') AS launch_attempted_sessions, + COUNT(DISTINCT selected.anonymous_session_id) + FILTER (WHERE windowed.event_name = 'launch_succeeded') AS launch_succeeded_sessions, + COUNT(*) FILTER (WHERE windowed.event_name = 'readiness_blocked') AS readiness_blocked_events + FROM selected + LEFT JOIN windowed + ON windowed.anonymous_session_id = selected.anonymous_session_id + AND windowed.game_id = selected.game_id + AND windowed.received_at >= selected.selected_at", + ) + .bind(ACTIVATION_WINDOW_DAYS) + .fetch_one(&state.pool) + .await?; + + let selected_sessions: i64 = row.try_get("selected_sessions")?; + let ready_sessions: i64 = row.try_get("ready_sessions")?; + let lobby_sessions: i64 = row.try_get("lobby_sessions")?; + let launch_attempted_sessions: i64 = row.try_get("launch_attempted_sessions")?; + let launch_succeeded_sessions: i64 = row.try_get("launch_succeeded_sessions")?; + let readiness_blocked_events: i64 = row.try_get("readiness_blocked_events")?; + if selected_sessions < MIN_AGGREGATE_COHORT { + return Ok(Json(Envelope::new( + "telemetry.activation.summary", + empty_summary(), + ))); + } + let blocked_rows = sqlx::query( + "WITH windowed AS ( + SELECT anonymous_session_id, event_name, game_id, blocked_checks, received_at + FROM product_events + WHERE received_at >= now() - make_interval(days => $1) + ), selected AS ( + SELECT anonymous_session_id, game_id, MIN(received_at) AS selected_at + FROM windowed WHERE event_name = 'game_selected' + GROUP BY anonymous_session_id, game_id + ) + SELECT blocked_check, COUNT(*) AS event_count + FROM selected + JOIN windowed + ON windowed.anonymous_session_id = selected.anonymous_session_id + AND windowed.game_id = selected.game_id + AND windowed.received_at >= selected.selected_at + CROSS JOIN LATERAL jsonb_array_elements_text(windowed.blocked_checks) AS blocked_check + WHERE windowed.event_name = 'readiness_blocked' + GROUP BY blocked_check + HAVING COUNT(*) >= $2 + ORDER BY event_count DESC, blocked_check ASC", + ) + .bind(ACTIVATION_WINDOW_DAYS) + .bind(MIN_AGGREGATE_COHORT) + .fetch_all(&state.pool) + .await?; + let blocked_by_check = blocked_rows + .into_iter() + .map(|row| { + let label: String = row.try_get("blocked_check")?; + Ok(ReadinessBlockCount { + check: parse_check_label(&label)?, + count: row.try_get("event_count")?, + }) + }) + .collect::, AppError>>()?; + + Ok(Json(Envelope::new( + "telemetry.activation.summary", + ActivationSummaryPayload { + window_days: ACTIVATION_WINDOW_DAYS as u16, + selected_sessions, + ready_sessions, + lobby_sessions, + launch_attempted_sessions, + launch_succeeded_sessions, + readiness_blocked_events, + selected_to_ready_rate: ratio(ready_sessions, selected_sessions), + selected_to_lobby_rate: ratio(lobby_sessions, selected_sessions), + selected_to_launch_rate: ratio(launch_succeeded_sessions, selected_sessions), + blocked_by_check, + }, + ))) +} + +fn validate_event(payload: &ProductEventPayload) -> Result<(), AppError> { + if payload.game_id.len() < 3 + || payload.game_id.len() > 20 + || !payload + .game_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(AppError::BadRequest("telemetry game id is invalid".into())); + } + let unique = payload + .blocked_checks + .iter() + .copied() + .collect::>(); + if unique.len() != payload.blocked_checks.len() { + return Err(AppError::BadRequest( + "telemetry blocked checks must be unique".into(), + )); + } + match payload.event { + ProductEventName::ReadinessBlocked => { + if unique.is_empty() || unique.contains(&ReadinessCheckId::Network) || unique.len() > 4 + { + return Err(AppError::BadRequest( + "readiness_blocked requires one or more blocking checks".into(), + )); + } + } + _ if !unique.is_empty() => { + return Err(AppError::BadRequest( + "blocked checks are only valid for readiness_blocked".into(), + )); + } + _ => {} + } + Ok(()) +} + +fn parse_uuid(value: &str, field: &str) -> Result { + Uuid::parse_str(value) + .map_err(|_| AppError::BadRequest(format!("telemetry {field} is invalid"))) +} + +fn event_label(event: ProductEventName) -> &'static str { + match event { + ProductEventName::GameSelected => "game_selected", + ProductEventName::ReadinessCompleted => "readiness_completed", + ProductEventName::ReadinessBlocked => "readiness_blocked", + ProductEventName::LobbyEntered => "lobby_entered", + ProductEventName::LaunchAttempted => "launch_attempted", + ProductEventName::LaunchSucceeded => "launch_succeeded", + } +} + +fn parse_check_label(label: &str) -> Result { + match label { + "desktop" => Ok(ReadinessCheckId::Desktop), + "control_plane" => Ok(ReadinessCheckId::ControlPlane), + "game_runtime" => Ok(ReadinessCheckId::GameRuntime), + "native_port" => Ok(ReadinessCheckId::NativePort), + "network" => Ok(ReadinessCheckId::Network), + _ => Err(AppError::Internal("invalid telemetry aggregate".into())), + } +} + +fn ratio(numerator: i64, denominator: i64) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 + } +} + +fn empty_summary() -> ActivationSummaryPayload { + ActivationSummaryPayload { + window_days: ACTIVATION_WINDOW_DAYS as u16, + selected_sessions: 0, + ready_sessions: 0, + lobby_sessions: 0, + launch_attempted_sessions: 0, + launch_succeeded_sessions: 0, + readiness_blocked_events: 0, + selected_to_ready_rate: 0.0, + selected_to_lobby_rate: 0.0, + selected_to_launch_rate: 0.0, + blocked_by_check: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event( + event: ProductEventName, + blocked_checks: Vec, + ) -> ProductEventPayload { + ProductEventPayload { + event_id: Uuid::new_v4().to_string(), + anonymous_session_id: Uuid::new_v4().to_string(), + event, + game_id: "sfiii3".into(), + blocked_checks, + } + } + + #[test] + fn closed_contract_rejects_invalid_blocker_combinations() { + assert!(validate_event(&event(ProductEventName::ReadinessBlocked, vec![])).is_err()); + assert!( + validate_event(&event( + ProductEventName::ReadinessBlocked, + vec![ReadinessCheckId::Network] + )) + .is_err() + ); + assert!( + validate_event(&event( + ProductEventName::ReadinessCompleted, + vec![ReadinessCheckId::Desktop] + )) + .is_err() + ); + assert!( + validate_event(&event( + ProductEventName::ReadinessBlocked, + vec![ReadinessCheckId::Desktop, ReadinessCheckId::Desktop] + )) + .is_err() + ); + assert!( + validate_event(&event( + ProductEventName::ReadinessBlocked, + vec![ReadinessCheckId::Desktop] + )) + .is_ok() + ); + } + + #[test] + fn empty_funnel_has_zero_rates() { + assert_eq!(ratio(0, 0), 0.0); + assert_eq!(ratio(3, 4), 0.75); + } +} diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs index 418ac88..a51d27c 100644 --- a/apps/server/src/state.rs +++ b/apps/server/src/state.rs @@ -19,6 +19,9 @@ use crate::config::Config; #[derive(Debug, Default)] pub struct Metrics { pub http_requests_total: AtomicU64, + pub http_request_duration_micros: AtomicU64, + pub http_responses_4xx: AtomicU64, + pub http_responses_5xx: AtomicU64, pub rooms_created: AtomicU64, } @@ -45,6 +48,8 @@ pub struct AppState { pub metrics: Arc, pub auth_rate_limiter: Arc, + /// Bounds CPU-heavy Argon2 work so authentication bursts cannot starve Tokio workers. + pub password_semaphore: Arc, } impl AppState { @@ -58,6 +63,7 @@ impl AppState { ws_hub: std::sync::Arc::new(dashmap::DashMap::new()), metrics: Arc::new(Metrics::default()), auth_rate_limiter: Arc::new(AuthRateLimiter::default()), + password_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), } } @@ -76,6 +82,21 @@ impl AppState { tracing::warn!(%error, %user_id, %message_type, "websocket notification queue unavailable"); } } + + pub fn require_operator(&self, supplied: Option<&str>) -> Result<(), crate::error::AppError> { + use sha2::{Digest, Sha256}; + let supplied = supplied + .ok_or_else(|| crate::error::AppError::Unauthorized("missing operator token".into()))?; + let expected = Sha256::digest(self.config.operator_token.as_bytes()); + let actual = Sha256::digest(supplied.as_bytes()); + if expected.as_slice() == actual.as_slice() { + Ok(()) + } else { + Err(crate::error::AppError::Unauthorized( + "invalid operator token".into(), + )) + } + } } impl std::fmt::Debug for AppState { diff --git a/apps/server/tests/api_flow.rs b/apps/server/tests/api_flow.rs index 3a7f6ed..6326baa 100644 --- a/apps/server/tests/api_flow.rs +++ b/apps/server/tests/api_flow.rs @@ -19,11 +19,43 @@ async fn request( uri: &str, token: Option<&str>, body: Option, +) -> (StatusCode, Envelope) { + request_with_operator_token(app, method, uri, token, body, None).await +} + +async fn operator_request( + app: &Router, + method: Method, + uri: &str, + token: Option<&str>, + body: Option, +) -> (StatusCode, Envelope) { + request_with_operator_token( + app, + method, + uri, + token, + body, + Some("test-operator-token-with-32-characters"), + ) + .await +} + +async fn request_with_operator_token( + app: &Router, + method: Method, + uri: &str, + token: Option<&str>, + body: Option, + operator_token: Option<&str>, ) -> (StatusCode, Envelope) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); } + if let Some(operator_token) = operator_token { + builder = builder.header("x-operator-token", operator_token); + } let request_body = match body { Some(value) => { builder = builder.header(header::CONTENT_TYPE, "application/json"); @@ -73,6 +105,21 @@ async fn current_user_id(app: &Router, token: &str) -> String { .to_string() } +fn telemetry_event( + event_id: uuid::Uuid, + session_id: uuid::Uuid, + event: &str, + blocked_checks: &[&str], +) -> Value { + json!({ + "event_id": event_id, + "anonymous_session_id": session_id, + "event": event, + "game_id": "sfiii3", + "blocked_checks": blocked_checks + }) +} + #[sqlx::test] async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { sqlx::migrate!("./migrations") @@ -170,6 +217,16 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { assert_eq!(accept_status, StatusCode::OK); assert_eq!(accepted.payload["state"], "accepted"); let room_id = accepted.payload["room_id"].as_str().expect("room id"); + let (repeat_accept_status, repeat_accept) = request( + &app, + Method::POST, + &format!("/api/v1/challenges/{challenge_id}/accept"), + Some(&guest_token), + None, + ) + .await; + assert_eq!(repeat_accept_status, StatusCode::OK); + assert_eq!(repeat_accept.payload["room_id"], room_id); let (room_status, room) = request( &app, @@ -473,6 +530,247 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { server.abort(); } +#[sqlx::test] +async fn expired_attempts_are_reconciled_authoritatively(pool: PgPool) { + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations should succeed"); + let state = AppState::new(pool.clone(), Config::for_test()); + let app = build_app(state.clone()); + let token = register(&app, "deadline_host").await; + let (status, room) = request( + &app, + Method::POST, + "/api/v1/rooms", + Some(&token), + Some(json!({ "game_id": "sfiii3" })), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let room_id = room.payload["id"].as_str().expect("room id"); + sqlx::query( + "UPDATE rooms + SET state = 'CONNECTING', state_deadline_at = now() - interval '1 second' + WHERE id = $1", + ) + .bind(uuid::Uuid::parse_str(room_id).expect("room uuid")) + .execute(&pool) + .await + .expect("expire room"); + + assert_eq!( + opencade_server::lifecycle::reconcile_expired(&state) + .await + .expect("reconcile"), + 1 + ); + let outcome = sqlx::query_as::<_, (String, String, Option)>( + "SELECT rooms.state, match_attempts.state, match_attempts.failure_code + FROM rooms JOIN match_attempts USING (attempt_id) WHERE rooms.id = $1", + ) + .bind(uuid::Uuid::parse_str(room_id).expect("room uuid")) + .fetch_one(&pool) + .await + .expect("attempt outcome"); + assert_eq!(outcome.0, "CANCELLED"); + assert_eq!(outcome.1, "EXPIRED"); + assert_eq!(outcome.2.as_deref(), Some("state_deadline_exceeded")); +} + +#[sqlx::test] +async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool) { + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations should succeed"); + let mut config = Config::for_test(); + config.relay_url = Some("wss://relay.example/relay".into()); + config.relay_secret = Some("integration-relay-secret-at-least-32-bytes".into()); + let app = build_app(AppState::new(pool, config)); + let host_token = register(&app, "campaign_host").await; + let guest_token = register(&app, "campaign_guest").await; + let observer_token = register(&app, "campaign_observer").await; + + let (_, room) = request( + &app, + Method::POST, + "/api/v1/rooms", + Some(&host_token), + Some(json!({ "game_id": "sfiii3" })), + ) + .await; + let room_id = room.payload["id"].as_str().expect("room id"); + let (invite_status, invite) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/invite"), + Some(&host_token), + None, + ) + .await; + assert_eq!(invite_status, StatusCode::CREATED); + let code = invite.payload["code"].as_str().expect("invite code"); + assert_eq!(code.len(), 10); + let (join_status, joined) = request( + &app, + Method::POST, + "/api/v1/invites/join", + Some(&guest_token), + Some(json!({ "code": code.to_ascii_lowercase() })), + ) + .await; + assert_eq!(join_status, StatusCode::OK); + assert_eq!(joined.payload["state"], "connecting"); + let (replay_status, _) = request( + &app, + Method::POST, + "/api/v1/invites/join", + Some(&observer_token), + Some(json!({ "code": code })), + ) + .await; + assert_eq!(replay_status, StatusCode::NOT_FOUND); + + let compatibility = json!({ + "adapter": "retroarch_fbneo", + "emulator_version": "1.22.0", + "executable_sha256": "a".repeat(64), + "core_sha256": "b".repeat(64), + "content_sha256": "c".repeat(64) + }); + for token in [&host_token, &guest_token] { + let (status, _) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/preflight"), + Some(token), + Some(json!({ + "room_id": room_id, + "compatibility": compatibility, + "native_port_available": true + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + } + for token in [&host_token, &guest_token] { + let (status, _) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/ready"), + Some(token), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + } + let (snapshot_status, snapshot) = request( + &app, + Method::GET, + &format!("/api/v1/rooms/{room_id}/snapshot"), + Some(&host_token), + None, + ) + .await; + assert_eq!(snapshot_status, StatusCode::OK); + assert_eq!(snapshot.payload["preflight_count"], 2); + assert_eq!(snapshot.payload["compatibility_matched"], true); + assert_eq!(snapshot.payload["barrier"]["ready_count"], 2); + assert!(snapshot.payload["barrier"]["launch_at"].is_string()); + let launch_at = snapshot.payload["barrier"]["launch_at"].clone(); + let (duplicate_ready_status, duplicate_ready) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/ready"), + Some(&host_token), + None, + ) + .await; + assert_eq!(duplicate_ready_status, StatusCode::OK); + assert_eq!(duplicate_ready.payload["barrier"]["launch_at"], launch_at); + + let (tunnel_status, tunnel) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/native-tunnel-ticket"), + Some(&host_token), + None, + ) + .await; + assert_eq!(tunnel_status, StatusCode::OK); + assert_eq!(tunnel.payload["ticket"]["capability"], "native_tcp_tunnel"); + + let failure = json!({ + "schema_version": 1, + "kind": "attempt_failure", + "exported_at": chrono::Utc::now(), + "room": { "id": room_id, "game_id": "sfiii3", "state": "connecting" }, + "role": "host", + "stage": "direct_udp", + "error_code": "direct_udp_timeout", + "transport": "direct_udp", + "client": { "platform": "macos", "user_agent": "integration-test" } + }); + let (evidence_status, evidence) = request( + &app, + Method::POST, + "/api/v1/alpha/evidence", + Some(&host_token), + Some(json!({ "evidence": failure.clone() })), + ) + .await; + assert_eq!(evidence_status, StatusCode::CREATED); + assert_eq!(evidence.payload["duplicate"], false); + let (duplicate_status, duplicate) = request( + &app, + Method::POST, + "/api/v1/alpha/evidence", + Some(&host_token), + Some(json!({ "evidence": failure.clone() })), + ) + .await; + assert_eq!(duplicate_status, StatusCode::OK); + assert_eq!(duplicate.payload["duplicate"], true); + let mut conflicting_failure = failure; + conflicting_failure["error_code"] = json!("different_failure"); + let (conflict_status, _) = request( + &app, + Method::POST, + "/api/v1/alpha/evidence", + Some(&host_token), + Some(json!({ "evidence": conflicting_failure })), + ) + .await; + assert_eq!(conflict_status, StatusCode::CONFLICT); + + let (privacy_status, _) = request( + &app, + Method::POST, + "/api/v1/alpha/evidence", + Some(&host_token), + Some(json!({ + "evidence": { + "kind": "attempt_failure", + "endpoint": "192.168.1.20:55435" + } + })), + ) + .await; + assert_eq!(privacy_status, StatusCode::BAD_REQUEST); + let (campaign_status, campaign) = operator_request( + &app, + Method::GET, + "/api/v1/alpha/campaign", + Some(&host_token), + None, + ) + .await; + assert_eq!(campaign_status, StatusCode::OK); + assert_eq!(campaign.payload["attempts"], 1); + assert_eq!(campaign.payload["failed"], 1); +} + #[sqlx::test] async fn logout_revokes_the_current_session(pool: PgPool) { sqlx::migrate!("./migrations") @@ -497,3 +795,198 @@ async fn logout_revokes_the_current_session(pool: PgPool) { assert_eq!(games_status, StatusCode::UNAUTHORIZED); assert_eq!(response.payload["code"], "unauthorized"); } + +#[sqlx::test] +async fn product_telemetry_is_private_idempotent_bounded_and_aggregated(pool: PgPool) { + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations should succeed"); + let app = build_app(AppState::new(pool.clone(), Config::for_test())); + let token = register(&app, "telemetry_player").await; + let session_id = uuid::Uuid::new_v4(); + let selected_id = uuid::Uuid::new_v4(); + + let (unauthorized, _) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + None, + Some(telemetry_event( + selected_id, + session_id, + "game_selected", + &[], + )), + ) + .await; + assert_eq!(unauthorized, StatusCode::UNAUTHORIZED); + + for (event_id, event) in [ + (selected_id, "game_selected"), + (uuid::Uuid::new_v4(), "readiness_completed"), + (uuid::Uuid::new_v4(), "lobby_entered"), + ] { + let (status, accepted) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + Some(&token), + Some(telemetry_event(event_id, session_id, event, &[])), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(accepted.payload["duplicate"], false); + } + for _ in 0..2 { + let additional_session = uuid::Uuid::new_v4(); + for event in ["game_selected", "readiness_completed", "lobby_entered"] { + let (status, _) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + Some(&token), + Some(telemetry_event( + uuid::Uuid::new_v4(), + additional_session, + event, + &[], + )), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + } + } + let (duplicate_status, duplicate) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + Some(&token), + Some(telemetry_event( + selected_id, + session_id, + "game_selected", + &[], + )), + ) + .await; + assert_eq!(duplicate_status, StatusCode::OK); + assert_eq!(duplicate.payload["duplicate"], true); + + for invalid in [ + json!({ + "event_id": "not-a-uuid", + "anonymous_session_id": session_id, + "event": "game_selected", + "game_id": "sfiii3", + "blocked_checks": [] + }), + telemetry_event( + uuid::Uuid::new_v4(), + session_id, + "readiness_completed", + &["desktop"], + ), + telemetry_event( + uuid::Uuid::new_v4(), + session_id, + "readiness_blocked", + &["network"], + ), + ] { + let (status, _) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + Some(&token), + Some(invalid), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + for _ in 0..3 { + let (status, _) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + Some(&token), + Some(telemetry_event( + uuid::Uuid::new_v4(), + session_id, + "readiness_blocked", + &["game_runtime"], + )), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + } + + let expired_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO product_events + (event_id, anonymous_session_id, event_name, game_id, received_at) + VALUES ($1, $2, 'game_selected', 'sfiii3', now() - interval '91 days')", + ) + .bind(expired_id) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .expect("insert expired telemetry fixture"); + let (status, _) = request( + &app, + Method::POST, + "/api/v1/telemetry/events", + Some(&token), + Some(telemetry_event( + uuid::Uuid::new_v4(), + session_id, + "game_selected", + &[], + )), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM product_events WHERE event_id = $1") + .bind(expired_id) + .fetch_one(&pool) + .await + .expect("count expired event"), + 0 + ); + + let (summary_status, summary) = operator_request( + &app, + Method::GET, + "/api/v1/telemetry/activation", + Some(&token), + None, + ) + .await; + assert_eq!(summary_status, StatusCode::OK); + assert_eq!(summary.payload["window_days"], 30); + assert_eq!(summary.payload["selected_sessions"], 3); + assert_eq!(summary.payload["ready_sessions"], 3); + assert_eq!(summary.payload["lobby_sessions"], 3); + assert_eq!(summary.payload["launch_attempted_sessions"], 0); + assert_eq!(summary.payload["launch_succeeded_sessions"], 0); + assert_eq!(summary.payload["readiness_blocked_events"], 3); + assert_eq!(summary.payload["selected_to_ready_rate"], 1.0); + assert_eq!(summary.payload["selected_to_lobby_rate"], 1.0); + assert_eq!(summary.payload["selected_to_launch_rate"], 0.0); + assert_eq!( + summary.payload["blocked_by_check"][0]["check"], + "game_runtime" + ); + assert_eq!(summary.payload["blocked_by_check"][0]["count"], 3); + + let stored_columns: Vec = sqlx::query_scalar( + "SELECT column_name FROM information_schema.columns + WHERE table_name = 'product_events' ORDER BY ordinal_position", + ) + .fetch_all(&pool) + .await + .expect("inspect telemetry schema"); + assert!(!stored_columns.iter().any(|column| column == "user_id")); +} diff --git a/docker-compose.yml b/docker-compose.yml index 15d0a3b..e152183 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,10 @@ services: restart: unless-stopped environment: POSTGRES_USER: opencade - POSTGRES_PASSWORD: opencade + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD to a random value} POSTGRES_DB: opencade ports: - - "5432:5432" + - "127.0.0.1:5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -19,24 +19,37 @@ services: retries: 10 start_period: 10s - opencade-server: + migrate: build: context: . dockerfile: ./apps/server/Dockerfile image: opencade-server:local - restart: unless-stopped + restart: "no" depends_on: db: condition: service_healthy - environment: - DATABASE_URL: postgres://opencade:opencade@db:5432/opencade + command: ["opencade-server", "--migrate"] + environment: &server-environment + DATABASE_URL: postgres://opencade:${POSTGRES_PASSWORD}@db:5432/opencade RUST_LOG: info SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET to at least 32 random bytes} + OPERATOR_TOKEN: ${OPERATOR_TOKEN:?set OPERATOR_TOKEN to at least 32 random bytes} OPENCADE_ENV: production PORT: 8080 - # Must be reachable by desktop clients; override for remote/LAN deployments. - RELAY_URL: ${RELAY_URL:-ws://localhost:8081/relay} + PUBLIC_RELAY_URL: ${PUBLIC_RELAY_URL:?set PUBLIC_RELAY_URL to the externally reachable wss relay URL} + STUN_HOST: ${STUN_HOST:?set STUN_HOST to the externally reachable STUN host} RELAY_AUTH_SECRET: ${RELAY_AUTH_SECRET:?set RELAY_AUTH_SECRET to at least 32 random bytes} + + opencade-server: + build: + context: . + dockerfile: ./apps/server/Dockerfile + image: opencade-server:local + restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully + environment: *server-environment ports: - "8080:8080" healthcheck: @@ -58,6 +71,12 @@ services: RELAY_AUTH_SECRET: ${RELAY_AUTH_SECRET:?set RELAY_AUTH_SECRET to at least 32 random bytes} ports: - "8081:8081" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8081/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s volumes: pgdata: driver: local diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d891533..e53cf7d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -302,9 +302,11 @@ Consequences: - MVP has **three active containers**: `db` (`postgres:16-alpine`), `opencade-server` on `8080`, and `opencade-relay` on `8081`; peers still prefer direct UDP. - No Redis, no separate signaling service, and no message queue in MVP. Presence remains in the - control plane; bounded readiness frames alone can use the relay fallback. -- The relay accepts only short-lived room-member tickets issued by the control plane, limits rooms - to two users and bounded frames, and does not relay RetroArch's native netplay socket. + control plane. Readiness frames use the probe capability; an isolated, bounded native TCP tunnel + capability exists but remains route-policy gated pending physical validation. +- The relay accepts only short-lived room-member tickets issued by the control plane and limits + rooms to two users and bounded frames. Capability-scoped tickets keep readiness probes isolated + from the experimental native TCP tunnel described in ADR 0005. --- @@ -445,14 +447,15 @@ WAITING|CHALLENGING ──(challenge accepted)──► CONNECTING CONNECTING ──(both authorized native processes launch)──► PLAYING PLAYING ──(both native processes exit)──────────────────► FINISHED CONNECTING ──(a launched process exits before peer)─────► CANCELLED -* ──(host cancels / timeout / all leave)──► CANCELLED +* ──(member cancels / deadline expires)──► CANCELLED ``` States stored in `rooms.state` (`WAITING | READY | CHALLENGING | CONNECTING | PLAYING | FINISHED | CANCELLED`). Alpha rooms have exactly two members. Transitions are server-authoritative and backed by -per-participant native launch/exit records; a successful UDP readiness probe never advances room -state. Native launch descriptors are derived from short-lived, hashed, one-use server grants. See -ADR 0004. +per-attempt identity, state deadlines (including a 24-hour waiting-room lease), per-participant native launch/exit records, and an append-only +room event journal. A database-backed reconciler expires stale challenges and active attempts safely +across replicas using row locks. A successful UDP readiness probe never advances room state. Native +launch descriptors are derived from short-lived, hashed, one-use server grants. See ADR 0004. Challenge flow: `challenge.create` → server creates `challenge` row (ephemeral, in `rooms` or separate `challenges` if needed) → pushes `challenge.create` to target → target `challenge.accept/decline` → on accept, server creates/moves to `room` and emits `room.state: READY`. @@ -565,12 +568,25 @@ CREATE TABLE rooms ( host_user_id UUID NOT NULL REFERENCES users(id), state TEXT NOT NULL CHECK (state IN ('WAITING','READY','PLAYING','FINISHED','CANCELLED')), max_players INT NOT NULL DEFAULT 2 CHECK (max_players BETWEEN 2 AND 4), + attempt_id UUID NOT NULL, + state_deadline_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ON rooms(game_id, state); CREATE INDEX ON rooms(host_user_id); +-- match_attempts — authoritative outcome and timeout boundary +CREATE TABLE match_attempts ( + attempt_id UUID PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK (state IN ('ACTIVE','SUCCEEDED','FAILED','CANCELLED','EXPIRED')), + failure_code TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deadline_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); + -- room_members — join table CREATE TABLE room_members ( room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, @@ -657,8 +673,9 @@ fixes the connection to the signed room/user, permits at most two distinct users 64-message queues, and rejects frames above 64 KiB. Text frames must be valid versioned envelopes whose payload room matches the signed room; bounded binary probe frames remain opaque. -This relay carries OpenCade readiness frames. It is not TURN and does not relay RetroArch's native -TCP netplay connection. +Probe tickets carry only OpenCade readiness frames. Native-tunnel tickets use a separate signed +capability and bounded TCP-over-WebSocket framing; the match coordinator does not select that route +until ADR 0005's physical validation gate passes. The relay is not TURN. ### 10.4 Latency — RTT / loss / jitter @@ -803,18 +820,16 @@ Cross-cutting utilities used by both Rust and TS: | **Process launch** | No shell, allow-listed binary path, per-arg sanitization, ROM path canonicalization inside allowed dirs (see §11.2). | | **Tauri permissions** | No `shell` plugin, scoped `fs` (allow `emulator/**`, `config/**`, logs; deny rest), CSP, `withGlobalTauri: false`. | | **CORS** | `Access-Control-Allow-Origin` limited to Tauri's custom protocol + `http://localhost:1420` in dev only. | -| **Rate limiting** | `tower::limit` on `/auth/*` (5/min/IP) and WS `signaling.*` (60/min/user) — in-memory in MVP, no Redis. | -| **Secrets** | `.env` never committed; `DATABASE_URL`, `JWT_SECRET` (if used for signing, not for sessions) loaded via `dotenvy`. CI uses GitHub secrets. | +| **Rate limiting** | Bounded in-memory limits protect auth, telemetry, WS messages, relay frames/bytes, and concurrent native probes. | +| **Secrets** | `.env` never committed. Session, relay, database, and operator credentials are independent; production config rejects weak or incomplete values. | --- ## 15. Observability -Placeholder in MVP, real wiring without re-architecture. - -- **Logging**: `tracing` + `tracing-subscriber` (JSON in prod, pretty in dev). Every request logs `request_id`, `user_id`, `route`, `latency_ms`. Tauri side uses `tauri-plugin-log`. -- **Metrics**: `prometheus` exporter at `GET /metrics` (behind admin auth) — counters `http_requests_total`, `ws_connections`, `rooms_created`, histogram `http_request_duration_seconds`. -- **Tracing**: `tracing-opentelemetry` stub — exporter disabled in MVP, enabled by setting `OTEL_EXPORTER_OTLP_ENDPOINT`. +- **Logging**: `tracing` + `tracing-subscriber` (JSON in production, compact in development). Tauri side uses `tauri-plugin-log`; secrets and ROM paths are excluded. +- **Metrics**: Prometheus text at `GET /metrics`, protected by `x-operator-token`, with request, status-class, cumulative latency, WebSocket, and room counters. +- **Decision surfaces**: campaign and activation aggregates require both user authentication and the operator credential; raw user telemetry is never exposed by these routes. - **Health**: `GET /health` → `{ status:"ok", version, db:"up"|"down" }` (no auth, for Compose healthcheck). - All observability code is gated behind `observability/` module so it can be no-op in tests. @@ -825,7 +840,7 @@ Placeholder in MVP, real wiring without re-architecture. ### 16.1 docker-compose.yml (root) Canonical compose is `docker-compose.yml` at repo root. Active services are PostgreSQL, -`opencade-server` on `8080`, and the authenticated WebSocket readiness relay on `8081`. The server +the one-shot `migrate` job, `opencade-server` on `8080`, and the authenticated WebSocket readiness relay on `8081`. The server and relay receive the same independently generated `RELAY_AUTH_SECRET`; it must differ from the session secret. @@ -876,10 +891,11 @@ configured RFC 8489 service. ### 16.2 Dockerfiles -- `apps/server/Dockerfile` — `rust:1.98-bookworm` builder → `debian:bookworm-slim` runtime, `sqlx migrate run` on start. +- `apps/server/Dockerfile` — `rust:1.98-bookworm` builder → `debian:bookworm-slim` runtime. The same immutable image runs migrations as an explicit one-shot Compose job before server startup. - `services/relay/Dockerfile` — same pattern, binary `opencade-relay`. -No `docker-compose.override.yml` in repo; devs create it locally if needed. +No `docker-compose.override.yml` is committed; developers create it locally if needed. Backup, +restore, rollback, and incident procedures live in `docs/operations/RUNBOOK.md`. --- diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d8379ea..5865550 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -38,6 +38,18 @@ Updated 2026-08-25. safe host/guest argument construction, and SHA-256 executable/core/content fingerprints. - Server-derived one-use native launch grants, exactly-two-player rooms, a deterministic client match coordinator, supervised/reaped child processes, and launch/exit-backed room transitions. +- One-use room invite codes, reconnect-safe revisioned room snapshots, fail-closed two-peer + compatibility preflights, and an idempotent synchronized launch barrier. +- A community-alpha dashboard backed by authenticated, privacy-filtered, conflict-safe evidence + uploads and a bounded server-side campaign summary. +- A reusable match-readiness gate that validates the desktop runtime, control plane, local game + runtime, native port, and advisory network fallback before a player enters a lobby. +- Explicitly opt-in product telemetry for the game-selection-to-lobby activation funnel, with a + closed Rust/TypeScript event contract, anonymous tab sessions, idempotent ingestion, 90-day raw + retention, 30-day aggregates, and small-cohort blocker suppression. +- A capability-scoped native TCP tunnel primitive with bounded binary frames and a real loopback + relay integration test. It is separately ticketed from readiness probes and remains disabled as + an automatic gameplay route pending physical two-host validation. - A truthful route gate: same-LAN host candidates may launch the RetroArch TCP alpha; UDP reflexive and relay results are readiness-only until a native transport bridge exists. - Strict playable-report verification that requires matching, well-formed native compatibility @@ -65,3 +77,5 @@ Updated 2026-08-25. - Production installer packaging/signing, friends/chat/rankings/replays, or a public MVP release. - Ten two-machine LAN matches and a 20-person community alpha; these require external testers and real Windows hosts. Use `docs/alpha/LAN_TEST.md` to collect that evidence. +- Automatic selection of the native TCP tunnel as a gameplay route. ADR 0005 requires physical + two-host proof before enabling that fallback in the match coordinator. diff --git a/docs/PRODUCT_TELEMETRY.md b/docs/PRODUCT_TELEMETRY.md new file mode 100644 index 0000000..aba8ec3 --- /dev/null +++ b/docs/PRODUCT_TELEMETRY.md @@ -0,0 +1,44 @@ +# Product telemetry + +OpenFight product telemetry measures whether players can move from choosing a game to entering its +lobby. It is disabled until a player explicitly opts in, and the choice can be changed from the +Games screen at any time. Telemetry delivery never blocks the match flow. + +## Event model + +The Rust protocol crate is the source of truth for the closed event contract. The server accepts +only these events: + +| Event | Meaning | Allowed blocker checks | +| --------------------- | ------------------------------------------------------- | --------------------------------------------------------- | +| `game_selected` | A catalog game was selected | None | +| `readiness_completed` | Every required readiness check passed | None | +| `readiness_blocked` | Lobby entry was attempted with a required check blocked | `desktop`, `control_plane`, `game_runtime`, `native_port` | +| `lobby_entered` | Lobby presence was created successfully | None | + +Each request contains an idempotency UUID, an anonymous tab-session UUID, the game ID, and the +typed blocker list. There is no arbitrary-properties field. + +## Privacy and retention + +- The server authenticates ingestion but does not store the user ID with an event. +- Usernames, ROM or executable paths, network endpoints, credentials, user-agent strings, free + text, and error messages are not accepted by the schema. +- The anonymous session identifier lives in `sessionStorage` and therefore does not become a + durable cross-session identity. +- Raw events older than 90 days are deleted during ingestion. +- Ingestion is capped at 60 events per authenticated account per minute. +- The complete summary is suppressed until it contains three selected sessions; blocker dimensions + with fewer than three events are also omitted. + +## Activation metric + +`GET /api/v1/telemetry/activation` returns a rolling 30-day summary. Its primary measures are: + +- selected-to-ready rate = distinct sessions with `readiness_completed` / distinct sessions with + `game_selected`; +- selected-to-lobby rate = distinct sessions with `lobby_entered` / distinct sessions with + `game_selected`; +- readiness blocker events grouped by typed check after small-cohort suppression. + +The endpoint requires authentication. Rates are `0` when there are no selected sessions. diff --git a/docs/adr/0005-community-alpha-orchestration.md b/docs/adr/0005-community-alpha-orchestration.md new file mode 100644 index 0000000..85647f9 --- /dev/null +++ b/docs/adr/0005-community-alpha-orchestration.md @@ -0,0 +1,31 @@ +# ADR 0005: Community-alpha orchestration and native tunnel boundary + +Status: accepted, 2026-08-25. + +## Context + +The experimental RetroArch path needed a repeatable two-person loop instead of manual timing and +download-only reports. Reconnects also needed a durable authority for compatibility and launch +state. A TCP-over-WebSocket path is technically possible, but an automated loopback test does not +prove RetroArch behavior across two physical hosts. + +## Decision + +- The server owns one-use invite redemption, compatibility preflights, monotonic room snapshots, + and a single persisted `launch_at` barrier. +- Both members must submit matching executable, core, and content fingerprints and confirm their + native port before the server issues a campaign launch grant. +- Clients resume active rooms from durable snapshots and ignore stale revisions. +- Evidence upload is authenticated, role-bound, size-bounded, privacy-filtered, idempotent for an + identical report, and rejects a conflicting report for the same room/user/kind. +- Relay tickets carry a cryptographically signed capability. Readiness probes and native TCP + tunnel traffic cannot share a relay bucket or ticket. +- The native TCP tunnel implementation is shipped as a gated capability, not selected by the + match coordinator until two-host evidence demonstrates safe RetroArch operation. + +## Consequences + +The alpha loop is deterministic and recoverable without claiming unearned network compatibility. +Enabling the native tunnel later is a route-policy change backed by evidence, not a new transport +implementation. Campaign summaries read only the newest 1,000 evidence records so the dashboard +cannot grow into an unbounded request. diff --git a/docs/operations/RUNBOOK.md b/docs/operations/RUNBOOK.md new file mode 100644 index 0000000..80da280 --- /dev/null +++ b/docs/operations/RUNBOOK.md @@ -0,0 +1,45 @@ +# Production operations runbook + +OpenCade separates schema migration from application startup. Production configuration fails closed unless the session, relay, and operator secrets are at least 32 characters and the advertised relay/STUN endpoints are public. + +## Deploy and rollback + +1. Back up the database with `DATABASE_URL=... BACKUP_PATH=... scripts/db/backup.sh`. +2. Pull immutable images for the intended release tag; never deploy `latest`. +3. Run `opencade-server --migrate` as a one-shot job. Migrations must complete before new server replicas start. +4. Start relay, then server. Check `/health`, `/ready`, and the authenticated `/metrics` endpoint. +5. Exercise register/login, create/accept/cancel room, and a relay-ticket connection. +6. Roll application images back to the prior tag when the schema remains backward compatible. For an incompatible schema, stop writes and restore into a new, empty database using the procedure below; do not mutate the failed database in place. + +Docker Compose implements steps 3–4 with the `migrate` service and `service_completed_successfully` dependency. + +## Backup and restore drill + +Backups are PostgreSQL custom archives, created with mode `0600`, validated with `pg_restore --list`, and accompanied by SHA-256 checksums. Store them encrypted outside the application host. + +```bash +DATABASE_URL=postgres://... \ +BACKUP_PATH=/secure/opencade-$(date +%Y%m%d).dump \ +scripts/db/backup.sh +``` + +Restore only into an empty database. The restore script fails closed when public tables already exist. + +```bash +DATABASE_URL=postgres://.../opencade_restore \ +BACKUP_PATH=/secure/opencade-20260826.dump \ +CONFIRM_RESTORE=restore-empty-database \ +scripts/db/restore.sh +``` + +Run this drill before the first public release and quarterly afterward. Record archive checksum, start/end time, restored row counts, and smoke-test result. + +## Monitoring and incidents + +Scrape `GET /metrics` with `x-operator-token: $OPERATOR_TOKEN`. Alert on 5xx responses, readiness failure, server/relay restart loops, active WebSocket collapse, and match-attempt deadline expirations. Campaign and activation aggregates require both a user bearer token and the operator token. + +Use `scripts/ops/decision-summary.sh` with `OPENCADE_API_URL`, `SESSION_TOKEN`, and `OPERATOR_TOKEN` to retrieve the bounded campaign cohort and privacy-thresholded activation funnel. Treat a low cohort as insufficient evidence, not as zero demand; launch conversion is reported separately from readiness and lobby conversion. + +The room event journal and `match_attempts` table are the incident timeline. Raw evidence is canonicalized and capped at 64 KiB; do not request users to share session tokens, ROM paths, or endpoint addresses. + +Rotate a compromised secret independently, revoke sessions when the session secret is affected, restart the corresponding services, and document the time window. Never log secret values. diff --git a/packages/networking/Cargo.toml b/packages/networking/Cargo.toml index 179c8c6..15d1bfc 100644 --- a/packages/networking/Cargo.toml +++ b/packages/networking/Cargo.toml @@ -15,7 +15,7 @@ opencade-shared = { path = "../shared" } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" thiserror = "2.0.20" -tokio = { version = "1.53.1", features = ["macros", "net", "rt", "sync", "time"] } +tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } uuid = { version = "1.25.0", features = ["v4"] } futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] } tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-native-roots"] } diff --git a/packages/networking/src/lib.rs b/packages/networking/src/lib.rs index 7d049e5..fc2d321 100644 --- a/packages/networking/src/lib.rs +++ b/packages/networking/src/lib.rs @@ -9,6 +9,7 @@ mod probe; mod relay; mod report; mod stun; +mod tcp_tunnel; mod traversal; pub use latency::LatencyMetrics; @@ -27,6 +28,7 @@ pub use report::{ verify_playable_match_reports, }; pub use stun::{NatMapping, StunObservation, discover_reflexive_address}; +pub use tcp_tunnel::{NATIVE_TUNNEL_FRAME_BYTES, run_native_tcp_tunnel}; pub use traversal::{HolePunchConfig, HolePunchReport, punch_hole}; pub const MAX_INPUT_BYTES: usize = 256; pub const INPUT_QUEUE_CAPACITY: usize = 120; diff --git a/packages/networking/src/relay.rs b/packages/networking/src/relay.rs index 933a25a..06232c0 100644 --- a/packages/networking/src/relay.rs +++ b/packages/networking/src/relay.rs @@ -21,6 +21,8 @@ impl RelayPeer { .append_pair("room_id", &ticket.room_id) .append_pair("user_id", &ticket.user_id) .append_pair("expires_at", &ticket.expires_at.to_string()) + .append_pair("capability", ticket.capability.as_str()) + .append_pair("nonce", &ticket.nonce.to_string()) .append_pair("signature", &ticket.signature); let (stream, _) = connect_async(url.as_str()) .await diff --git a/packages/networking/src/tcp_tunnel.rs b/packages/networking/src/tcp_tunnel.rs new file mode 100644 index 0000000..f4bf9c6 --- /dev/null +++ b/packages/networking/src/tcp_tunnel.rs @@ -0,0 +1,107 @@ +use futures_util::{SinkExt, StreamExt}; +use opencade_shared::RelayTicket; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, +}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use url::Url; + +use crate::TransportError; + +pub const NATIVE_TUNNEL_FRAME_BYTES: usize = 16 * 1024; + +/// Bridge one already-authorized local TCP stream over the signed two-peer WebSocket relay. +/// +/// This is intentionally distinct from the deterministic UDP readiness probe. Callers must obtain +/// an explicit native-tunnel ticket and provide a loopback-side stream owned by the emulator +/// adapter. Frames are bounded and WebSocket backpressure is awaited rather than buffered without +/// limit. +pub async fn run_native_tcp_tunnel( + local: TcpStream, + relay_url: &str, + ticket: &RelayTicket, +) -> Result<(), TransportError> { + let mut url = Url::parse(relay_url) + .map_err(|_| TransportError::Relay("native tunnel URL is invalid".into()))?; + if !matches!(url.scheme(), "ws" | "wss") { + return Err(TransportError::Relay( + "native tunnel URL must use ws or wss".into(), + )); + } + url.query_pairs_mut() + .append_pair("room_id", &ticket.room_id) + .append_pair("user_id", &ticket.user_id) + .append_pair("expires_at", &ticket.expires_at.to_string()) + .append_pair("capability", ticket.capability.as_str()) + .append_pair("nonce", &ticket.nonce.to_string()) + .append_pair("signature", &ticket.signature); + let (websocket, _) = connect_async(url.as_str()) + .await + .map_err(|_| TransportError::Relay("native tunnel connection failed".into()))?; + let (mut ws_write, mut ws_read) = websocket.split(); + let (mut tcp_read, mut tcp_write) = local.into_split(); + let tcp_to_relay = async { + let mut buffer = vec![0_u8; NATIVE_TUNNEL_FRAME_BYTES]; + loop { + let count = tcp_read + .read(&mut buffer) + .await + .map_err(|_| TransportError::Relay("native TCP read failed".into()))?; + if count == 0 { + ws_write + .send(Message::Close(None)) + .await + .map_err(|_| TransportError::Relay("native tunnel close failed".into()))?; + return Ok::<(), TransportError>(()); + } + ws_write + .send(Message::Binary(buffer[..count].to_vec().into())) + .await + .map_err(|_| TransportError::Relay("native tunnel send failed".into()))?; + } + }; + let relay_to_tcp = async { + loop { + match ws_read.next().await { + Some(Ok(Message::Binary(payload))) + if payload.len() <= NATIVE_TUNNEL_FRAME_BYTES => + { + tcp_write + .write_all(&payload) + .await + .map_err(|_| TransportError::Relay("native TCP write failed".into()))?; + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) | None => return Ok::<(), TransportError>(()), + Some(Ok(Message::Binary(_))) => { + return Err(TransportError::Relay( + "native tunnel frame exceeds 16 KiB".into(), + )); + } + Some(Ok(Message::Text(_))) | Some(Ok(Message::Frame(_))) => { + return Err(TransportError::Relay( + "native tunnel received an invalid frame".into(), + )); + } + Some(Err(_)) => { + return Err(TransportError::Relay("native tunnel receive failed".into())); + } + } + } + }; + tokio::select! { + result = tcp_to_relay => result, + result = relay_to_tcp => result, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tunnel_frames_are_strictly_bounded() { + assert_eq!(NATIVE_TUNNEL_FRAME_BYTES, 16 * 1024); + } +} diff --git a/packages/protocol/src/envelope.test.ts b/packages/protocol/src/envelope.test.ts index ce3e121..f315166 100644 --- a/packages/protocol/src/envelope.test.ts +++ b/packages/protocol/src/envelope.test.ts @@ -120,6 +120,17 @@ describe("envelope roundtrip", () => { it("rejects invalid shape", () => { expect(() => parseEnvelope('{"type":"x"}')).toThrow(); + expect(() => + parseEnvelope( + JSON.stringify({ + type: "x", + version: "2.0", + request_id: "request", + timestamp: new Date().toISOString(), + payload: {}, + }) + ) + ).toThrow(/unsupported version/); }); it("roundtrips room state ready", () => { const room: RoomPayload = { diff --git a/packages/protocol/src/envelope.ts b/packages/protocol/src/envelope.ts index 6d2c15c..95b873d 100644 --- a/packages/protocol/src/envelope.ts +++ b/packages/protocol/src/envelope.ts @@ -53,7 +53,19 @@ export function validateEnvelope( } export function parseEnvelope(raw: string): Envelope { - const parsed = JSON.parse(raw) as Envelope; + const decoded: unknown = JSON.parse(raw); + if ( + typeof decoded !== "object" || + decoded === null || + !("type" in decoded) || + !("version" in decoded) || + !("request_id" in decoded) || + !("timestamp" in decoded) || + !("payload" in decoded) + ) { + throw new Error("invalid envelope shape: missing required fields"); + } + const parsed = decoded as Envelope; if ( typeof parsed.type !== "string" || typeof parsed.version !== "string" || @@ -63,7 +75,9 @@ export function parseEnvelope(raw: string): Envelope { ) { throw new Error("invalid envelope shape: missing required fields"); } - return parsed; + const validation = validateEnvelope(parsed); + if (!validation.ok) throw new Error(validation.error); + return parsed as Envelope; } export function serializeEnvelope(envelope: Envelope): string { diff --git a/packages/protocol/src/generated/ActivationSummaryPayload.ts b/packages/protocol/src/generated/ActivationSummaryPayload.ts new file mode 100644 index 0000000..ef02f40 --- /dev/null +++ b/packages/protocol/src/generated/ActivationSummaryPayload.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReadinessBlockCount } from "./ReadinessBlockCount"; + +/** + * Rolling activation funnel. Small blocker cohorts are suppressed by the server. + */ +export type ActivationSummaryPayload = { + window_days: number; + selected_sessions: number; + ready_sessions: number; + lobby_sessions: number; + launch_attempted_sessions: number; + launch_succeeded_sessions: number; + readiness_blocked_events: number; + selected_to_ready_rate: number; + selected_to_lobby_rate: number; + selected_to_launch_rate: number; + blocked_by_check: Array; +}; diff --git a/packages/protocol/src/generated/LaunchBarrierPayload.ts b/packages/protocol/src/generated/LaunchBarrierPayload.ts new file mode 100644 index 0000000..e6bdb03 --- /dev/null +++ b/packages/protocol/src/generated/LaunchBarrierPayload.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Server-authoritative launch barrier shared by both room participants. + */ +export type LaunchBarrierPayload = { + room_id: string; + ready_count: number; + required_count: number; + launch_at: string | null; +}; diff --git a/packages/protocol/src/generated/MatchPreflightPayload.ts b/packages/protocol/src/generated/MatchPreflightPayload.ts new file mode 100644 index 0000000..c216a5a --- /dev/null +++ b/packages/protocol/src/generated/MatchPreflightPayload.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MatchReportCompatibility } from "./MatchReportCompatibility"; + +/** + * Privacy-safe compatibility handshake performed before either emulator is launched. + */ +export type MatchPreflightPayload = { + room_id: string; + compatibility: MatchReportCompatibility; + native_port_available: boolean; +}; diff --git a/packages/protocol/src/generated/NativeRouteCapability.ts b/packages/protocol/src/generated/NativeRouteCapability.ts new file mode 100644 index 0000000..fd29c01 --- /dev/null +++ b/packages/protocol/src/generated/NativeRouteCapability.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Native route capabilities are explicit: a readiness probe never implies emulator traffic. + */ +export type NativeRouteCapability = "direct_lan" | "tcp_tunnel"; diff --git a/packages/protocol/src/generated/ProductEventName.ts b/packages/protocol/src/generated/ProductEventName.ts new file mode 100644 index 0000000..174e20c --- /dev/null +++ b/packages/protocol/src/generated/ProductEventName.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Product-funnel events accepted by the privacy-minimized telemetry endpoint. + */ +export type ProductEventName = + | "game_selected" + | "readiness_completed" + | "readiness_blocked" + | "lobby_entered" + | "launch_attempted" + | "launch_succeeded"; diff --git a/packages/protocol/src/generated/ProductEventPayload.ts b/packages/protocol/src/generated/ProductEventPayload.ts new file mode 100644 index 0000000..f89c956 --- /dev/null +++ b/packages/protocol/src/generated/ProductEventPayload.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProductEventName } from "./ProductEventName"; +import type { ReadinessCheckId } from "./ReadinessCheckId"; + +/** + * Closed, data-minimized event contract. It intentionally has no arbitrary properties field. + */ +export type ProductEventPayload = { + event_id: string; + anonymous_session_id: string; + event: ProductEventName; + game_id: string; + blocked_checks: Array; +}; diff --git a/packages/protocol/src/generated/ReadinessBlockCount.ts b/packages/protocol/src/generated/ReadinessBlockCount.ts new file mode 100644 index 0000000..04d017c --- /dev/null +++ b/packages/protocol/src/generated/ReadinessBlockCount.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReadinessCheckId } from "./ReadinessCheckId"; + +export type ReadinessBlockCount = { check: ReadinessCheckId; count: number }; diff --git a/packages/protocol/src/generated/ReadinessCheckId.ts b/packages/protocol/src/generated/ReadinessCheckId.ts new file mode 100644 index 0000000..af94dc4 --- /dev/null +++ b/packages/protocol/src/generated/ReadinessCheckId.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stable readiness checks used for aggregate blocker analysis. + */ +export type ReadinessCheckId = + "desktop" | "control_plane" | "game_runtime" | "native_port" | "network"; diff --git a/packages/protocol/src/generated/RoomInvitePayload.ts b/packages/protocol/src/generated/RoomInvitePayload.ts new file mode 100644 index 0000000..94fa087 --- /dev/null +++ b/packages/protocol/src/generated/RoomInvitePayload.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Short-lived room invite. Only the creator receives the secret code. + */ +export type RoomInvitePayload = { room_id: string; code: string; expires_at: string }; diff --git a/packages/protocol/src/generated/RoomSnapshotPayload.ts b/packages/protocol/src/generated/RoomSnapshotPayload.ts new file mode 100644 index 0000000..ffa1cba --- /dev/null +++ b/packages/protocol/src/generated/RoomSnapshotPayload.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LaunchBarrierPayload } from "./LaunchBarrierPayload"; +import type { NativeRouteCapability } from "./NativeRouteCapability"; +import type { RoomPayload } from "./RoomPayload"; + +/** + * Durable room snapshot used after initial connection and every reconnect. + */ +export type RoomSnapshotPayload = { + room: RoomPayload; + revision: number; + preflight_count: number; + compatibility_matched: boolean; + barrier: LaunchBarrierPayload; + route: NativeRouteCapability; +}; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 77cf2c3..f2e399a 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -11,6 +11,11 @@ export type { MatchReport, MatchReportClient, MatchReportCompatibility, + MatchPreflightPayload, + LaunchBarrierPayload, + NativeRouteCapability, + RoomInvitePayload, + RoomSnapshotPayload, AlphaEvidenceKind, AlphaFailureReport, AlphaFailureStage, @@ -28,5 +33,10 @@ export type { HelloPayload, ErrorPayload, EnvelopeType, + ActivationSummaryPayload, + ProductEventName, + ProductEventPayload, + ReadinessBlockCount, + ReadinessCheckId, } from "./messages.js"; export { isKnownEnvelopeType } from "./messages.js"; diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 9d6140a..da23e7c 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -270,6 +270,56 @@ pub struct MatchReportCompatibility { pub content_sha256: String, } +/// Native route capabilities are explicit: a readiness probe never implies emulator traffic. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/NativeRouteCapability.ts")] +#[serde(rename_all = "snake_case")] +pub enum NativeRouteCapability { + DirectLan, + TcpTunnel, +} + +/// Privacy-safe compatibility handshake performed before either emulator is launched. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchPreflightPayload.ts")] +pub struct MatchPreflightPayload { + pub room_id: String, + pub compatibility: MatchReportCompatibility, + pub native_port_available: bool, +} + +/// Server-authoritative launch barrier shared by both room participants. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/LaunchBarrierPayload.ts")] +pub struct LaunchBarrierPayload { + pub room_id: String, + pub ready_count: u8, + pub required_count: u8, + pub launch_at: Option>, +} + +/// Durable room snapshot used after initial connection and every reconnect. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/RoomSnapshotPayload.ts")] +pub struct RoomSnapshotPayload { + pub room: RoomPayload, + #[ts(type = "number")] + pub revision: i64, + pub preflight_count: u8, + pub compatibility_matched: bool, + pub barrier: LaunchBarrierPayload, + pub route: NativeRouteCapability, +} + +/// Short-lived room invite. Only the creator receives the secret code. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/RoomInvitePayload.ts")] +pub struct RoomInvitePayload { + pub room_id: String, + pub code: String, + pub expires_at: DateTime, +} + /// Stable stages for privacy-minimized evidence from an abandoned alpha attempt. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] #[ts(export, export_to = "../src/generated/AlphaFailureStage.ts")] @@ -347,6 +397,74 @@ pub struct RoomPayload { pub state: RoomState, } +/// Product-funnel events accepted by the privacy-minimized telemetry endpoint. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/ProductEventName.ts")] +#[serde(rename_all = "snake_case")] +pub enum ProductEventName { + GameSelected, + ReadinessCompleted, + ReadinessBlocked, + LobbyEntered, + LaunchAttempted, + LaunchSucceeded, +} + +/// Stable readiness checks used for aggregate blocker analysis. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS)] +#[ts(export, export_to = "../src/generated/ReadinessCheckId.ts")] +#[serde(rename_all = "snake_case")] +pub enum ReadinessCheckId { + Desktop, + ControlPlane, + GameRuntime, + NativePort, + Network, +} + +/// Closed, data-minimized event contract. It intentionally has no arbitrary properties field. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/ProductEventPayload.ts")] +pub struct ProductEventPayload { + pub event_id: String, + pub anonymous_session_id: String, + pub event: ProductEventName, + pub game_id: String, + #[serde(default)] + pub blocked_checks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/ReadinessBlockCount.ts")] +pub struct ReadinessBlockCount { + pub check: ReadinessCheckId, + #[ts(type = "number")] + pub count: i64, +} + +/// Rolling activation funnel. Small blocker cohorts are suppressed by the server. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[ts(export, export_to = "../src/generated/ActivationSummaryPayload.ts")] +pub struct ActivationSummaryPayload { + pub window_days: u16, + #[ts(type = "number")] + pub selected_sessions: i64, + #[ts(type = "number")] + pub ready_sessions: i64, + #[ts(type = "number")] + pub lobby_sessions: i64, + #[ts(type = "number")] + pub launch_attempted_sessions: i64, + #[ts(type = "number")] + pub launch_succeeded_sessions: i64, + #[ts(type = "number")] + pub readiness_blocked_events: i64, + pub selected_to_ready_rate: f64, + pub selected_to_lobby_rate: f64, + pub selected_to_launch_rate: f64, + pub blocked_by_check: Vec, +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -624,4 +742,23 @@ mod tests { assert_eq!(reply.request_id, "request-123"); assert_eq!(reply.msg_type, "pong"); } + + #[test] + fn product_events_use_closed_snake_case_contracts() { + let payload = ProductEventPayload { + event_id: Uuid::new_v4().to_string(), + anonymous_session_id: Uuid::new_v4().to_string(), + event: ProductEventName::ReadinessBlocked, + game_id: "sfiii3".into(), + blocked_checks: vec![ReadinessCheckId::GameRuntime], + }; + let value = serde_json::to_value(&payload).expect("serialize product event"); + assert_eq!(value["event"], "readiness_blocked"); + assert_eq!(value["blocked_checks"][0], "game_runtime"); + assert!(value.get("properties").is_none()); + assert_eq!( + serde_json::from_value::(value).expect("deserialize event"), + payload + ); + } } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ddf6d1f..237c253 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -7,6 +7,11 @@ import type { MatchProbeCompletedPayload } from "./generated/MatchProbeCompleted import type { MatchReport } from "./generated/MatchReport.js"; import type { MatchReportClient } from "./generated/MatchReportClient.js"; import type { MatchReportCompatibility } from "./generated/MatchReportCompatibility.js"; +import type { MatchPreflightPayload } from "./generated/MatchPreflightPayload.js"; +import type { LaunchBarrierPayload } from "./generated/LaunchBarrierPayload.js"; +import type { NativeRouteCapability } from "./generated/NativeRouteCapability.js"; +import type { RoomInvitePayload } from "./generated/RoomInvitePayload.js"; +import type { RoomSnapshotPayload } from "./generated/RoomSnapshotPayload.js"; import type { AlphaEvidenceKind } from "./generated/AlphaEvidenceKind.js"; import type { AlphaFailureReport } from "./generated/AlphaFailureReport.js"; import type { AlphaFailureStage } from "./generated/AlphaFailureStage.js"; @@ -17,6 +22,11 @@ import type { RoomPayload } from "./generated/RoomPayload.js"; import type { SessionAnswerPayload } from "./generated/SessionAnswerPayload.js"; import type { SessionCandidatePayload } from "./generated/SessionCandidatePayload.js"; import type { SessionOfferPayload } from "./generated/SessionOfferPayload.js"; +import type { ActivationSummaryPayload } from "./generated/ActivationSummaryPayload.js"; +import type { ProductEventName } from "./generated/ProductEventName.js"; +import type { ProductEventPayload } from "./generated/ProductEventPayload.js"; +import type { ReadinessBlockCount } from "./generated/ReadinessBlockCount.js"; +import type { ReadinessCheckId } from "./generated/ReadinessCheckId.js"; export type { ChallengePayload, @@ -28,6 +38,11 @@ export type { MatchReport, MatchReportClient, MatchReportCompatibility, + MatchPreflightPayload, + LaunchBarrierPayload, + NativeRouteCapability, + RoomInvitePayload, + RoomSnapshotPayload, AlphaEvidenceKind, AlphaFailureReport, AlphaFailureStage, @@ -38,6 +53,11 @@ export type { SessionAnswerPayload, SessionCandidatePayload, SessionOfferPayload, + ActivationSummaryPayload, + ProductEventName, + ProductEventPayload, + ReadinessBlockCount, + ReadinessCheckId, }; export type { ChallengeState } from "./generated/ChallengeState.js"; export type { RoomState } from "./generated/RoomState.js"; @@ -71,6 +91,7 @@ export type EnvelopeType = | "match.probe.completed" | "match.probe.completed.relayed" | "room.state" + | "room.snapshot" | "connection.hello" | "error" | "ping" @@ -93,6 +114,7 @@ const KNOWN_ENVELOPE_TYPES: ReadonlySet = new Set([ "match.probe.completed", "match.probe.completed.relayed", "room.state", + "room.snapshot", "connection.hello", "error", "ping", diff --git a/packages/shared/Cargo.toml b/packages/shared/Cargo.toml index 49356e0..eac7af8 100644 --- a/packages/shared/Cargo.toml +++ b/packages/shared/Cargo.toml @@ -13,3 +13,4 @@ thiserror = "2.0.20" hmac = "0.13.0" sha2 = "0.11.0" hex = "0.4.3" +uuid = { version = "1.25.0", features = ["v4", "serde"] } diff --git a/packages/shared/src/lib.rs b/packages/shared/src/lib.rs index b286e50..6ff3927 100644 --- a/packages/shared/src/lib.rs +++ b/packages/shared/src/lib.rs @@ -1,14 +1,33 @@ use hmac::{Hmac, KeyInit, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; +use uuid::Uuid; pub const MAX_RELAY_TICKET_LIFETIME_SECONDS: i64 = 300; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayCapability { + Probe, + NativeTcpTunnel, +} + +impl RelayCapability { + pub fn as_str(self) -> &'static str { + match self { + Self::Probe => "probe", + Self::NativeTcpTunnel => "native_tcp_tunnel", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RelayTicket { pub room_id: String, pub user_id: String, pub expires_at: i64, + pub capability: RelayCapability, + pub nonce: Uuid, pub signature: String, } @@ -32,13 +51,26 @@ impl RelayTicket { room_id: &str, user_id: &str, expires_at: i64, + ) -> Result { + Self::issue_scoped(secret, room_id, user_id, expires_at, RelayCapability::Probe) + } + + pub fn issue_scoped( + secret: &[u8], + room_id: &str, + user_id: &str, + expires_at: i64, + capability: RelayCapability, ) -> Result { validate_inputs(secret, room_id, user_id)?; - let signature = signature(secret, room_id, user_id, expires_at)?; + let nonce = Uuid::new_v4(); + let signature = signature(secret, room_id, user_id, expires_at, capability, nonce)?; Ok(Self { room_id: room_id.to_owned(), user_id: user_id.to_owned(), expires_at, + capability, + nonce, signature: hex::encode(signature), }) } @@ -58,6 +90,8 @@ impl RelayTicket { &self.room_id, &self.user_id, self.expires_at, + self.capability, + self.nonce, )); mac.verify_slice(&provided) .map_err(|_| RelayTicketError::InvalidSignature) @@ -83,17 +117,28 @@ fn signature( room_id: &str, user_id: &str, expires_at: i64, + capability: RelayCapability, + nonce: Uuid, ) -> Result, RelayTicketError> { let mut mac = relay_mac(secret)?; - mac.update(&canonical_claims(room_id, user_id, expires_at)); + mac.update(&canonical_claims( + room_id, user_id, expires_at, capability, nonce, + )); Ok(mac.finalize().into_bytes().to_vec()) } -fn canonical_claims(room_id: &str, user_id: &str, expires_at: i64) -> Vec { +fn canonical_claims( + room_id: &str, + user_id: &str, + expires_at: i64, + capability: RelayCapability, + nonce: Uuid, +) -> Vec { format!( - "{}:{room_id}:{}:{user_id}:{expires_at}", + "{}:{room_id}:{}:{user_id}:{expires_at}:{}:{nonce}", room_id.len(), - user_id.len() + user_id.len(), + capability.as_str() ) .into_bytes() } @@ -128,4 +173,22 @@ mod tests { Err(RelayTicketError::ExcessiveLifetime) ); } + + #[test] + fn capability_is_cryptographically_scoped() { + let mut ticket = RelayTicket::issue_scoped( + SECRET, + "room", + "user", + 1_120, + RelayCapability::NativeTcpTunnel, + ) + .expect("ticket"); + assert_eq!(ticket.verify(SECRET, 1_000), Ok(())); + ticket.capability = RelayCapability::Probe; + assert_eq!( + ticket.verify(SECRET, 1_000), + Err(RelayTicketError::InvalidSignature) + ); + } } diff --git a/scripts/db/backup.sh b/scripts/db/backup.sh new file mode 100755 index 0000000..5808fb2 --- /dev/null +++ b/scripts/db/backup.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +: "${DATABASE_URL:?set DATABASE_URL to the PostgreSQL database to back up}" +: "${BACKUP_PATH:?set BACKUP_PATH to a new .dump file}" + +if [ -e "$BACKUP_PATH" ]; then + echo "refusing to overwrite existing backup: $BACKUP_PATH" >&2 + exit 1 +fi + +umask 077 +pg_dump --dbname "$DATABASE_URL" --format custom --no-owner --no-acl --file "$BACKUP_PATH" +pg_restore --list "$BACKUP_PATH" >/dev/null + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$BACKUP_PATH" >"$BACKUP_PATH.sha256" +else + shasum -a 256 "$BACKUP_PATH" >"$BACKUP_PATH.sha256" +fi +echo "verified backup written to $BACKUP_PATH" diff --git a/scripts/db/restore.sh b/scripts/db/restore.sh new file mode 100755 index 0000000..451db41 --- /dev/null +++ b/scripts/db/restore.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +set -eu + +: "${DATABASE_URL:?set DATABASE_URL to the empty PostgreSQL restore target}" +: "${BACKUP_PATH:?set BACKUP_PATH to a verified .dump file}" +: "${CONFIRM_RESTORE:?set CONFIRM_RESTORE=restore-empty-database}" + +if [ "$CONFIRM_RESTORE" != "restore-empty-database" ]; then + echo "restore confirmation did not match" >&2 + exit 1 +fi +if [ ! -f "$BACKUP_PATH" ]; then + echo "backup does not exist: $BACKUP_PATH" >&2 + exit 1 +fi + +pg_restore --list "$BACKUP_PATH" >/dev/null +table_count=$(psql "$DATABASE_URL" -Atqc \ + "SELECT count(*) FROM pg_catalog.pg_tables WHERE schemaname = 'public'") +if [ "$table_count" != "0" ]; then + echo "restore target is not empty; refusing to overwrite it" >&2 + exit 1 +fi + +pg_restore --dbname "$DATABASE_URL" --no-owner --no-acl --exit-on-error "$BACKUP_PATH" +echo "restore completed; run application readiness and smoke tests before promotion" diff --git a/scripts/ops/decision-summary.sh b/scripts/ops/decision-summary.sh new file mode 100755 index 0000000..2507474 --- /dev/null +++ b/scripts/ops/decision-summary.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +set -eu + +: "${OPENCADE_API_URL:?set OPENCADE_API_URL to the server origin}" +: "${SESSION_TOKEN:?set SESSION_TOKEN to an authenticated operator session}" +: "${OPERATOR_TOKEN:?set OPERATOR_TOKEN to the independent operator credential}" + +fetch() { + curl --fail --silent --show-error \ + --header "Authorization: Bearer $SESSION_TOKEN" \ + --header "x-operator-token: $OPERATOR_TOKEN" \ + "$OPENCADE_API_URL$1" +} + +echo "campaign" +fetch "/api/v1/alpha/campaign" +echo +echo "activation" +fetch "/api/v1/telemetry/activation" +echo diff --git a/services/relay/Cargo.toml b/services/relay/Cargo.toml index 4367dc3..3e8ea65 100644 --- a/services/relay/Cargo.toml +++ b/services/relay/Cargo.toml @@ -22,3 +22,4 @@ uuid = { version = "1.25.0", features = ["v4"] } [dev-dependencies] tokio-tungstenite = "0.30.0" +opencade-networking = { path = "../../packages/networking" } diff --git a/services/relay/Dockerfile b/services/relay/Dockerfile index 74bae92..aaff4a2 100644 --- a/services/relay/Dockerfile +++ b/services/relay/Dockerfile @@ -15,10 +15,11 @@ COPY packages/game-definitions/Cargo.toml packages/game-definitions/Cargo.toml COPY adapters/fbneo/Cargo.toml adapters/fbneo/Cargo.toml COPY adapters/retroarch/Cargo.toml adapters/retroarch/Cargo.toml COPY services/relay/Cargo.toml services/relay/Cargo.toml +COPY crates/proof-of-match/Cargo.toml crates/proof-of-match/Cargo.toml # Create stub sources so workspace resolves RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ - packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src adapters/retroarch/src services/relay/src \ + packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src adapters/retroarch/src services/relay/src crates/proof-of-match/src \ && echo "fn main() {}" > apps/server/src/main.rs \ && echo "" > packages/protocol/src/lib.rs \ && echo "" > packages/networking/src/lib.rs \ @@ -28,6 +29,7 @@ RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ && echo "" > adapters/fbneo/src/lib.rs \ && echo "" > adapters/retroarch/src/lib.rs \ && echo "fn main() {}" > services/relay/src/main.rs \ + && echo "" > crates/proof-of-match/src/lib.rs \ && cargo build --release --locked -p opencade-relay # Copy real sources diff --git a/services/relay/src/main.rs b/services/relay/src/main.rs index 49c0214..9749bdb 100644 --- a/services/relay/src/main.rs +++ b/services/relay/src/main.rs @@ -10,16 +10,16 @@ use axum::{ }; use dashmap::DashMap; use futures::{SinkExt, StreamExt}; -use opencade_shared::RelayTicket; +use opencade_shared::{RelayCapability, RelayTicket}; use serde_json::{Value, json}; use std::{ env, net::SocketAddr, sync::Arc, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use tokio::sync::mpsc; -use tracing::{info, warn}; +use tokio::sync::{mpsc, watch}; +use tracing::{debug, info, warn}; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -70,12 +70,14 @@ impl Config { struct RelayState { config: Config, rooms: DashMap>, + used_tickets: DashMap, } #[derive(Debug, Clone)] struct RelayPeer { connection_id: Uuid, sender: mpsc::Sender, + cancel: watch::Sender<()>, } type SharedState = Arc; @@ -138,11 +140,29 @@ async fn relay_ws_handler( { return (StatusCode::UNAUTHORIZED, "invalid or expired relay ticket").into_response(); } + state + .used_tickets + .retain(|_, expires_at| *expires_at >= now); + if state + .used_tickets + .insert(ticket.nonce, ticket.expires_at) + .is_some() + { + return ( + StatusCode::UNAUTHORIZED, + "relay ticket has already been used", + ) + .into_response(); + } let room_id = ticket.room_id; let user_id = ticket.user_id; + let capability = ticket.capability; + let expires_at = ticket.expires_at; info!(room_id = %room_id, "authorized relay upgrade"); - ws.on_upgrade(move |socket| handle_socket(socket, state, room_id, user_id)) - .into_response() + ws.on_upgrade(move |socket| { + handle_socket(socket, state, room_id, user_id, capability, expires_at) + }) + .into_response() } fn broadcast_message(state: &SharedState, room_id: &str, msg: Message, skip_id: Uuid) -> usize { @@ -160,30 +180,44 @@ fn broadcast_message(state: &SharedState, room_id: &str, msg: Message, skip_id: sent } -async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, user_id: String) { +async fn handle_socket( + socket: WebSocket, + state: SharedState, + room_id: String, + user_id: String, + capability: RelayCapability, + expires_at: i64, +) { let id = Uuid::new_v4(); + let room_key = format!("{room_id}:{}", capability.as_str()); let (mut ws_sender, mut ws_receiver) = socket.split(); let (tx, mut rx) = mpsc::channel::(64); + let (cancel_tx, mut cancel_rx) = watch::channel(()); { - let room = state.rooms.entry(room_id.clone()).or_default(); + let room = state.rooms.entry(room_key.clone()).or_default(); if room.len() >= 2 && !room.contains_key(&user_id) { warn!(room_id = %room_id, "relay room is full"); return; } - room.insert( + let previous = room.insert( user_id.clone(), RelayPeer { connection_id: id, sender: tx.clone(), + cancel: cancel_tx, }, ); + if let Some(previous) = previous { + let _ = previous.cancel.send(()); + let _ = previous.sender.try_send(Message::Close(None)); + } } info!(peer_id = %id, room_id = %room_id, "relay peer connected"); info!( peer_id = %id, room_id = %room_id, - peers_in_room = state.rooms.get(&room_id).map(|r| r.len()).unwrap_or(0), + peers_in_room = state.rooms.get(&room_key).map(|r| r.len()).unwrap_or(0), "peer inserted" ); @@ -195,11 +229,42 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u } }); - while let Some(result) = ws_receiver.next().await { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or(expires_at); + let lifetime = Duration::from_secs(u64::try_from(expires_at.saturating_sub(now)).unwrap_or(0)); + let expiry = tokio::time::sleep(lifetime); + tokio::pin!(expiry); + let mut window_started = Instant::now(); + let mut window_messages = 0_u32; + let mut window_bytes = 0_usize; + + loop { + let result = tokio::select! { + biased; + _ = cancel_rx.changed() => break, + _ = &mut expiry => { + debug!(peer_id = %id, room_id = %room_id, "relay ticket lifetime elapsed"); + break; + } + result = ws_receiver.next() => result, + }; + let Some(result) = result else { break }; match result { Ok(msg) => match msg { Message::Text(text) => { let text_str = text.to_string(); + if exceeds_rate_limit( + &mut window_started, + &mut window_messages, + &mut window_bytes, + text_str.len(), + ) { + warn!(peer_id = %id, room_id = %room_id, "relay rate limit exceeded"); + break; + } if text_str.len() > 64 * 1024 { warn!(peer_id = %id, room_id = %room_id, "oversized relay text rejected"); continue; @@ -237,20 +302,33 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u continue; } let sent = - broadcast_message(&state, &room_id, Message::Text(text.clone()), id); - info!(peer_id = %id, room_id = %room_id, sent = sent, "broadcast envelope"); + broadcast_message(&state, &room_key, Message::Text(text.clone()), id); + debug!(peer_id = %id, room_id = %room_id, sent = sent, "broadcast envelope"); } else { warn!(peer_id = %id, room_id = %room_id, "non-envelope relay text rejected"); } } Message::Binary(bin) => { - if bin.len() > 64 * 1024 { + if exceeds_rate_limit( + &mut window_started, + &mut window_messages, + &mut window_bytes, + bin.len(), + ) { + warn!(peer_id = %id, room_id = %room_id, "relay rate limit exceeded"); + break; + } + let maximum = match capability { + RelayCapability::Probe => 64 * 1024, + RelayCapability::NativeTcpTunnel => 16 * 1024, + }; + if bin.len() > maximum { warn!(peer_id = %id, room_id = %room_id, "oversized relay frame rejected"); continue; } let sent = - broadcast_message(&state, &room_id, Message::Binary(bin.clone()), id); - info!(peer_id = %id, sent = sent, "broadcast binary"); + broadcast_message(&state, &room_key, Message::Binary(bin.clone()), id); + debug!(peer_id = %id, sent = sent, "broadcast binary"); } Message::Ping(payload) => { let _ = tx.try_send(Message::Pong(payload)); @@ -270,7 +348,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u } } - if let Some(room) = state.rooms.get(&room_id) + if let Some(room) = state.rooms.get(&room_key) && room .get(&user_id) .is_some_and(|peer| peer.connection_id == id) @@ -291,6 +369,24 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u info!(peer_id = %id, "relay peer disconnected"); } +fn exceeds_rate_limit( + window_started: &mut Instant, + messages: &mut u32, + bytes: &mut usize, + frame_bytes: usize, +) -> bool { + const MAX_MESSAGES_PER_SECOND: u32 = 128; + const MAX_BYTES_PER_SECOND: usize = 1024 * 1024; + if window_started.elapsed() >= Duration::from_secs(1) { + *window_started = Instant::now(); + *messages = 0; + *bytes = 0; + } + *messages = messages.saturating_add(1); + *bytes = bytes.saturating_add(frame_bytes); + *messages > MAX_MESSAGES_PER_SECOND || *bytes > MAX_BYTES_PER_SECOND +} + async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c() @@ -326,6 +422,7 @@ async fn main() -> Result<(), Box> { let state = Arc::new(RelayState { config: config.clone(), rooms: DashMap::new(), + used_tickets: DashMap::new(), }); let app = build_relay_app(state); @@ -345,6 +442,7 @@ async fn main() -> Result<(), Box> { mod tests { use super::*; use futures::{SinkExt, StreamExt}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_tungstenite::{connect_async, tungstenite::Message as ClientMessage}; const SECRET: &str = "relay-integration-secret-at-least-32-bytes"; @@ -357,13 +455,19 @@ mod tests { auth_secret: SECRET.into(), }, rooms: DashMap::new(), + used_tickets: DashMap::new(), }) } fn ticket_url(address: SocketAddr, ticket: &RelayTicket) -> String { format!( - "ws://{address}/relay?room_id={}&user_id={}&expires_at={}&signature={}", - ticket.room_id, ticket.user_id, ticket.expires_at, ticket.signature + "ws://{address}/relay?room_id={}&user_id={}&expires_at={}&capability={}&nonce={}&signature={}", + ticket.room_id, + ticket.user_id, + ticket.expires_at, + ticket.capability.as_str(), + ticket.nonce, + ticket.signature ) } @@ -392,6 +496,10 @@ mod tests { let (mut host_socket, _) = connect_async(ticket_url(address, &host)) .await .expect("host relay"); + assert!( + connect_async(ticket_url(address, &host)).await.is_err(), + "a relay ticket must authorize exactly one upgrade" + ); let (mut guest_socket, _) = connect_async(ticket_url(address, &guest)) .await .expect("guest relay"); @@ -412,4 +520,108 @@ mod tests { assert!(connect_async(ticket_url(address, &tampered)).await.is_err()); server.abort(); } + + #[test] + fn byte_and_message_rate_limits_fail_closed() { + let mut started = Instant::now(); + let mut messages = 127; + let mut bytes = 0; + assert!(!exceeds_rate_limit( + &mut started, + &mut messages, + &mut bytes, + 1 + )); + assert!(exceeds_rate_limit( + &mut started, + &mut messages, + &mut bytes, + 1 + )); + + let mut started = Instant::now(); + let mut messages = 0; + let mut bytes = 1024 * 1024; + assert!(exceeds_rate_limit( + &mut started, + &mut messages, + &mut bytes, + 1 + )); + } + + async fn tcp_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("TCP listener"); + let address = listener.local_addr().expect("TCP address"); + let (client, accepted) = + tokio::join!(tokio::net::TcpStream::connect(address), listener.accept()); + ( + client.expect("TCP connect"), + accepted.expect("TCP accept").0, + ) + } + + #[tokio::test] + async fn scoped_native_tunnel_carries_bounded_tcp_stream_on_loopback() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("relay listener"); + let address = listener.local_addr().expect("relay address"); + let server = tokio::spawn(async move { + axum::serve(listener, build_relay_app(state())) + .await + .expect("relay server"); + }); + let now = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs(), + ) + .expect("timestamp"); + let host_ticket = RelayTicket::issue_scoped( + SECRET.as_bytes(), + "native-room", + "host", + now + 120, + RelayCapability::NativeTcpTunnel, + ) + .expect("host tunnel ticket"); + let guest_ticket = RelayTicket::issue_scoped( + SECRET.as_bytes(), + "native-room", + "guest", + now + 120, + RelayCapability::NativeTcpTunnel, + ) + .expect("guest tunnel ticket"); + let relay_url = format!("ws://{address}/relay"); + let (mut host_app, host_tunnel) = tcp_pair().await; + let (mut guest_app, guest_tunnel) = tcp_pair().await; + let host_url = relay_url.clone(); + let host = tokio::spawn(async move { + opencade_networking::run_native_tcp_tunnel(host_tunnel, &host_url, &host_ticket).await + }); + let guest = tokio::spawn(async move { + opencade_networking::run_native_tcp_tunnel(guest_tunnel, &relay_url, &guest_ticket) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let payload = vec![0x5a; 40 * 1024]; + host_app.write_all(&payload).await.expect("TCP write"); + let mut received = vec![0_u8; payload.len()]; + tokio::time::timeout( + std::time::Duration::from_secs(2), + guest_app.read_exact(&mut received), + ) + .await + .expect("tunnel timeout") + .expect("TCP read"); + assert_eq!(received, payload); + host.abort(); + guest.abort(); + server.abort(); + } }