From ceece03bd8b41136c4ed107db65a822f39f43deb Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 25 Aug 2026 18:08:45 -0300 Subject: [PATCH 1/7] feat: add community alpha match loop --- Cargo.lock | 2 + apps/client/src-tauri/src/commands/mod.rs | 1 + apps/client/src-tauri/src/commands/process.rs | 36 ++ apps/client/src-tauri/src/commands/tunnel.rs | 119 ++++++ apps/client/src-tauri/src/main.rs | 4 + apps/client/src/App.tsx | 63 ++- .../src/components/CampaignDashboard.tsx | 59 +++ apps/client/src/lib/api.ts | 53 ++- apps/client/src/lib/match.test.ts | 39 ++ apps/client/src/lib/match.ts | 22 + apps/client/src/lib/native.ts | 36 ++ apps/client/src/lib/report.ts | 16 +- apps/client/src/lib/snapshot.test.ts | 31 ++ apps/client/src/lib/snapshot.ts | 8 + apps/client/src/lib/store.ts | 13 +- apps/client/src/lib/usePlayableMatch.ts | 94 +++- apps/client/src/routes/Games.tsx | 8 +- apps/client/src/routes/Lobby.tsx | 56 ++- apps/client/src/routes/Match.tsx | 68 ++- apps/server/Cargo.toml | 1 + .../server/migrations/006_community_alpha.sql | 52 +++ apps/server/src/lib.rs | 29 ++ apps/server/src/routes/alpha.rs | 401 ++++++++++++++++++ apps/server/src/routes/alpha_evidence.rs | 205 +++++++++ apps/server/src/routes/mod.rs | 2 + apps/server/src/routes/rooms.rs | 75 +++- apps/server/tests/api_flow.rs | 193 +++++++++ docs/ARCHITECTURE.md | 13 +- docs/IMPLEMENTATION_STATUS.md | 9 + .../adr/0005-community-alpha-orchestration.md | 31 ++ packages/networking/Cargo.toml | 2 +- packages/networking/src/lib.rs | 2 + packages/networking/src/relay.rs | 1 + packages/networking/src/tcp_tunnel.rs | 106 +++++ .../src/generated/LaunchBarrierPayload.ts | 11 + .../src/generated/MatchPreflightPayload.ts | 11 + .../src/generated/NativeRouteCapability.ts | 6 + .../src/generated/RoomInvitePayload.ts | 6 + .../src/generated/RoomSnapshotPayload.ts | 16 + packages/protocol/src/index.ts | 5 + packages/protocol/src/lib.rs | 50 +++ packages/protocol/src/messages.ts | 12 + packages/shared/src/lib.rs | 64 ++- services/relay/Cargo.toml | 1 + services/relay/src/main.rs | 114 ++++- 45 files changed, 2091 insertions(+), 55 deletions(-) create mode 100644 apps/client/src-tauri/src/commands/tunnel.rs create mode 100644 apps/client/src/components/CampaignDashboard.tsx create mode 100644 apps/client/src/lib/snapshot.test.ts create mode 100644 apps/client/src/lib/snapshot.ts create mode 100644 apps/server/migrations/006_community_alpha.sql create mode 100644 apps/server/src/routes/alpha.rs create mode 100644 apps/server/src/routes/alpha_evidence.rs create mode 100644 docs/adr/0005-community-alpha-orchestration.md create mode 100644 packages/networking/src/tcp_tunnel.rs create mode 100644 packages/protocol/src/generated/LaunchBarrierPayload.ts create mode 100644 packages/protocol/src/generated/MatchPreflightPayload.ts create mode 100644 packages/protocol/src/generated/NativeRouteCapability.ts create mode 100644 packages/protocol/src/generated/RoomInvitePayload.ts create mode 100644 packages/protocol/src/generated/RoomSnapshotPayload.ts diff --git a/Cargo.lock b/Cargo.lock index b6970b9..d97acbc 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", 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..b4c6733 100644 --- a/apps/client/src-tauri/src/commands/process.rs +++ b/apps/client/src-tauri/src/commands/process.rs @@ -88,6 +88,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, 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..c2c6649 --- /dev/null +++ b/apps/client/src-tauri/src/commands/tunnel.rs @@ -0,0 +1,119 @@ +use opencade_networking::run_native_tcp_tunnel; +use opencade_shared::{RelayCapability, RelayTicket}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, net::SocketAddr, sync::Mutex, time::Duration}; +use tokio::{net::TcpListener, task::JoinHandle}; + +#[derive(Default)] +pub struct NativeTunnelState { + tasks: Mutex>>, +} + +#[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 relay_url = request.relay_url; + let ticket = request.ticket; + let (local_endpoint, task) = match request.mode { + TunnelMode::Listen => { + 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 { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + if let Err(error) = run_native_tcp_tunnel(stream, &relay_url, &ticket).await { + tracing::warn!(%error, "native TCP tunnel stopped"); + } + }); + (address, task) + } + TunnelMode::Connect => { + let address = request.local_endpoint; + let task = tokio::spawn(async move { + let stream = loop { + 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"); + } + }); + (address, task) + } + }; + let mut tasks = state + .tasks + .lock() + .map_err(|_| "native tunnel registry is unavailable".to_string())?; + if let Some(previous) = tasks.insert(room_id.clone(), task) { + previous.abort(); + } + Ok(NativeTunnelStarted { + room_id, + local_endpoint, + }) +} + +#[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.abort(); + } + Ok(()) +} + +#[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..4a6fee5 100644 --- a/apps/client/src-tauri/src/main.rs +++ b/apps/client/src-tauri/src/main.rs @@ -7,10 +7,12 @@ fn main() { tauri::Builder::default() .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,6 +23,8 @@ 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"); diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 5112a3b..07f258e 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 ( @@ -131,6 +161,7 @@ export default function App() { if (view.name === "match") void cancelMatchProbe(view.roomId); setPeerEndpoint(null); setPeerCompletion(null); + setActiveRoom(null); setView({ name: "games" }); }; return ( @@ -159,7 +190,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 +216,25 @@ export default function App() { ); } +function isRoomPayload(payload: unknown): payload is RoomPayload { + return ( + typeof payload === "object" && + payload !== null && + typeof Reflect.get(payload, "id") === "string" && + typeof Reflect.get(payload, "game_id") === "string" && + typeof Reflect.get(payload, "state") === "string" + ); +} + +function isRoomSnapshot(payload: unknown): payload is RoomSnapshotPayload { + return ( + typeof payload === "object" && + payload !== null && + isRoomPayload(Reflect.get(payload, "room")) && + typeof Reflect.get(payload, "revision") === "number" + ); +} + 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/CampaignDashboard.tsx b/apps/client/src/components/CampaignDashboard.tsx new file mode 100644 index 0000000..00974c6 --- /dev/null +++ b/apps/client/src/components/CampaignDashboard.tsx @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; + +export default function CampaignDashboard({ token }: { token: string }) { + const campaign = useQuery({ + queryKey: ["alpha-campaign"], + queryFn: () => api.campaign(token), + staleTime: 15_000, + }); + if (campaign.isPending) return null; + if (campaign.isError) { + return

Campaign evidence unavailable: {campaign.error.message}

; + } + const summary = campaign.data; + return ( +
+
+
+

Evidence-backed community alpha

+

Proof-of-play campaign

+
+ + {summary.verified}/{summary.attempts} verified · {Math.round(summary.success_rate * 100)}% + +
+
+
+ {summary.gate_passed ? "✓" : "α"} + + + {summary.gate_passed ? "8-of-10 gate passed" : "Collecting paired evidence"} + + + {summary.failed} failed · {summary.reports} privacy-safe reports + + +
+ {summary.compatibility.map((row) => ( +
+ + {row.verified}/{row.attempts} + + + + {row.game_id} · {row.transport} + + + {row.platform} · {row.nat} · {row.candidate} + + +
+ ))} +
+
+ ); +} diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts index a00935a..6fb9823 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -1,4 +1,12 @@ -import type { Envelope, RoomPayload } from "@opencade/protocol"; +import type { + AlphaFailureReport, + Envelope, + MatchPreflightPayload, + MatchReport, + 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 }; @@ -28,9 +36,29 @@ export type RelayTicket = { user_id: string; expires_at: number; signature: string; + capability: "probe" | "native_tcp_tunnel"; }; }; export type MatchLaunchGrant = { grant: string; expires_at: string }; +export type CampaignSummary = { + schema_version: number; + reports: number; + attempts: number; + verified: number; + failed: number; + success_rate: number; + gate_passed: boolean; + failures: { room_id: string; code: string }[]; + compatibility: { + game_id: string; + platform: string; + transport: string; + nat: string; + candidate: string; + attempts: number; + verified: number; + }[]; +}; type ErrorPayload = { code?: string; message?: string }; @@ -156,7 +184,8 @@ export const api = { roomId: string, localEndpoint: string, peerEndpoint: string, - inputDelayFrames = 2 + inputDelayFrames = 2, + campaignMode = true ) => request( `/api/v1/rooms/${roomId}/launch-grant`, @@ -165,8 +194,28 @@ 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 }) + ), + campaign: (token: string) => request("/api/v1/alpha/campaign", token), }; 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/native.ts b/apps/client/src/lib/native.ts index 1edb632..11ecf89 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,14 @@ 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; + capability: "native_tcp_tunnel"; + signature: string; +}; let runtimeStunServer = import.meta.env.VITE_STUN_SERVER || undefined; @@ -90,6 +106,11 @@ export async function launchRetroarchMatch(request: { 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 { @@ -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/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/usePlayableMatch.ts b/apps/client/src/lib/usePlayableMatch.ts index 794da39..d3b81f9 100644 --- a/apps/client/src/lib/usePlayableMatch.ts +++ b/apps/client/src/lib/usePlayableMatch.ts @@ -1,16 +1,17 @@ -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 { matchParticipants, selectNativeRoute } from "./match"; import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator"; import { launchRetroarchMatch, onEmulatorExit, + retroarchPreflight, stopGame, type MatchEndpointCandidate, type MatchProbeReport, @@ -38,11 +39,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,13 +101,17 @@ 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" }); + const grant = await api.createLaunchGrant(token, roomId, route.local, route.peer); const launch = await launchRetroarchMatch({ api_url: getApiBase(), session_token: token, @@ -101,6 +155,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 +193,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/routes/Games.tsx b/apps/client/src/routes/Games.tsx index c138e6b..56f233d 100644 --- a/apps/client/src/routes/Games.tsx +++ b/apps/client/src/routes/Games.tsx @@ -1,6 +1,7 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { api, type Game } from "../lib/api"; import { scanGame } from "../lib/native"; +import CampaignDashboard from "../components/CampaignDashboard"; type Props = { token: string; onSelect: (gameId: string) => void }; @@ -8,7 +9,12 @@ export default function Games({ token, onSelect }: Props) { const games = useQuery({ queryKey: ["games"], queryFn: () => api.games(token) }); if (games.isPending) return ; if (games.isError) return ; - return ; + return ( + <> + + + + ); } function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (gameId: string) => void }) { diff --git a/apps/client/src/routes/Lobby.tsx b/apps/client/src/routes/Lobby.tsx index cfd018a..8584350 100644 --- a/apps/client/src/routes/Lobby.tsx +++ b/apps/client/src/routes/Lobby.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type Challenge } from "../lib/api"; +import type { RoomPayload } from "@opencade/protocol"; type Props = { token: string; @@ -13,12 +14,18 @@ type Props = { export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) { const queryClient = useQueryClient(); const [notice, setNotice] = useState("Creating your lobby presence…"); + const [lobbyRoom, setLobbyRoom] = useState(null); + const [inviteCode, setInviteCode] = useState(""); + const [joinCode, setJoinCode] = useState(""); useEffect(() => { let active = true; api .joinLobby(token, gameId) - .then(() => { - if (active) setNotice("Ready for challenges"); + .then((room) => { + if (active) { + setLobbyRoom(room); + setNotice("Ready for challenges"); + } }) .catch((error: Error) => { if (active) setNotice(error.message); @@ -42,6 +49,22 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) onSuccess: () => setNotice("Challenge sent. Waiting for response…"), onError: (error) => setNotice(error.message), }); + const createInvite = useMutation({ + mutationFn: () => { + if (!lobbyRoom) throw new Error("Lobby room is not ready"); + return api.createInvite(token, lobbyRoom.id); + }, + onSuccess: (invite) => { + setInviteCode(invite.code); + setNotice(`Invite expires ${new Date(invite.expires_at).toLocaleTimeString()}`); + }, + onError: (error) => setNotice(error.message), + }); + const joinInvite = useMutation({ + mutationFn: () => api.joinInvite(token, joinCode), + onSuccess: (room) => onMatch(room.id), + onError: (error) => setNotice(error.message), + }); const pending = incoming.data?.challenges.filter((item) => item.game_id === gameId) ?? []; const peers = lobby.data?.members.filter((member) => member.user_id !== userId) ?? []; const respond = async (item: Challenge, accept: boolean) => { @@ -63,6 +86,35 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) {notice} +
+
+ Private alpha invite + {inviteCode || "Create a one-use 15-minute code or join one."} +
+
+ setJoinCode(event.target.value.toUpperCase())} + /> + + +
+
{pending.map((item) => (
diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index 4e9a75c..6f48905 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -1,7 +1,14 @@ +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 type { OpenCadeSocket } from "../lib/ws"; @@ -28,7 +35,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 +48,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 +67,14 @@ export default function Match({ probeReport, peerCompletion, }); + const [now, setNow] = useState(Date.now()); + 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 +102,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 +154,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 @@ -177,7 +237,7 @@ export default function Match({ {coordinator.phase === "ready" && participants && !playableMatch.data && ( +

+
+

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/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/routes/Games.tsx b/apps/client/src/routes/Games.tsx index 56f233d..5807ba4 100644 --- a/apps/client/src/routes/Games.tsx +++ b/apps/client/src/routes/Games.tsx @@ -1,23 +1,35 @@ +import { useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { api, type Game } from "../lib/api"; import { scanGame } from "../lib/native"; import CampaignDashboard from "../components/CampaignDashboard"; +import MatchReadiness from "../components/MatchReadiness"; 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 ; + if (selectedGame) { + return ( + setSelectedGame(null)} + onContinue={() => onSelect(selectedGame.id)} + /> + ); + } return ( <> - + ); } -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], @@ -36,7 +48,7 @@ function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (gameId: st
{games.map((game, index) => ( - + +
+ + {consent === null + ? "No choice saved · sharing is off" + : consent + ? "Sharing on" + : "Sharing off"} + + + ); +} diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts index 6fb9823..f83bda4 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -3,6 +3,8 @@ import type { Envelope, MatchPreflightPayload, MatchReport, + ActivationSummaryPayload, + ProductEventPayload, RoomInvitePayload, RoomPayload, RoomSnapshotPayload, @@ -218,4 +220,12 @@ export const api = { post({ evidence }) ), campaign: (token: string) => request("/api/v1/alpha/campaign", token), + recordProductEvent: (token: string, event: ProductEventPayload) => + request<{ accepted: boolean; duplicate: boolean }>( + "/api/v1/telemetry/events", + token, + post(event) + ), + activationSummary: (token: string) => + request("/api/v1/telemetry/activation", token), }; 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/routes/Games.tsx b/apps/client/src/routes/Games.tsx index 5807ba4..2539b2d 100644 --- a/apps/client/src/routes/Games.tsx +++ b/apps/client/src/routes/Games.tsx @@ -4,6 +4,8 @@ import { api, type Game } from "../lib/api"; import { scanGame } from "../lib/native"; import CampaignDashboard from "../components/CampaignDashboard"; import MatchReadiness from "../components/MatchReadiness"; +import TelemetryConsent from "../components/TelemetryConsent"; +import { trackProductEvent } from "../lib/telemetry"; type Props = { token: string; onSelect: (gameId: string) => void }; @@ -15,6 +17,7 @@ export default function Games({ token, onSelect }: Props) { if (selectedGame) { return ( setSelectedGame(null)} onContinue={() => onSelect(selectedGame.id)} @@ -23,8 +26,15 @@ export default function Games({ token, onSelect }: Props) { } return ( <> + - + { + setSelectedGame(game); + void trackProductEvent(token, "game_selected", game.id).catch(() => undefined); + }} + /> ); } diff --git a/apps/client/src/routes/Lobby.tsx b/apps/client/src/routes/Lobby.tsx index 8584350..20e8541 100644 --- a/apps/client/src/routes/Lobby.tsx +++ b/apps/client/src/routes/Lobby.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type Challenge } from "../lib/api"; import type { RoomPayload } from "@opencade/protocol"; +import { trackProductEvent } from "../lib/telemetry"; type Props = { token: string; @@ -17,6 +18,7 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) const [lobbyRoom, setLobbyRoom] = useState(null); const [inviteCode, setInviteCode] = useState(""); const [joinCode, setJoinCode] = useState(""); + const lobbyTracked = useRef(false); useEffect(() => { let active = true; api @@ -25,6 +27,10 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) if (active) { setLobbyRoom(room); setNotice("Ready for challenges"); + if (!lobbyTracked.current) { + lobbyTracked.current = true; + void trackProductEvent(token, "lobby_entered", gameId).catch(() => undefined); + } } }) .catch((error: Error) => { diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 7a29db9..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; @@ -510,6 +549,18 @@ 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; 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/src/lib.rs b/apps/server/src/lib.rs index d14a890..530a7cc 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -80,6 +80,14 @@ pub fn build_app(state: AppState) -> Router { "/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), diff --git a/apps/server/src/routes/mod.rs b/apps/server/src/routes/mod.rs index 1e56cfc..942a0b9 100644 --- a/apps/server/src/routes/mod.rs +++ b/apps/server/src/routes/mod.rs @@ -6,3 +6,4 @@ pub mod games; pub mod lobbies; pub mod rooms; pub mod servers; +pub mod telemetry; diff --git a/apps/server/src/routes/telemetry.rs b/apps/server/src/routes/telemetry.rs new file mode 100644 index 0000000..5512668 --- /dev/null +++ b/apps/server/src/routes/telemetry.rs @@ -0,0 +1,312 @@ +use std::collections::HashSet; + +use axum::{Json, extract::State, http::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, +) -> Result>, AppError> { + 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(*) 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 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, + readiness_blocked_events, + selected_to_ready_rate: ratio(ready_sessions, selected_sessions), + selected_to_lobby_rate: ratio(lobby_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", + } +} + +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, + readiness_blocked_events: 0, + selected_to_ready_rate: 0.0, + selected_to_lobby_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/tests/api_flow.rs b/apps/server/tests/api_flow.rs index 55bf82e..bc70ab0 100644 --- a/apps/server/tests/api_flow.rs +++ b/apps/server/tests/api_flow.rs @@ -73,6 +73,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") @@ -690,3 +705,195 @@ 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) = 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["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["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/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4b776e4..5865550 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -44,6 +44,9 @@ Updated 2026-08-25. 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. 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/packages/protocol/src/generated/ActivationSummaryPayload.ts b/packages/protocol/src/generated/ActivationSummaryPayload.ts new file mode 100644 index 0000000..554dd1b --- /dev/null +++ b/packages/protocol/src/generated/ActivationSummaryPayload.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 { 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; + readiness_blocked_events: number; + selected_to_ready_rate: number; + selected_to_lobby_rate: number; + blocked_by_check: Array; +}; diff --git a/packages/protocol/src/generated/ProductEventName.ts b/packages/protocol/src/generated/ProductEventName.ts new file mode 100644 index 0000000..12d1060 --- /dev/null +++ b/packages/protocol/src/generated/ProductEventName.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. + +/** + * Product-funnel events accepted by the privacy-minimized telemetry endpoint. + */ +export type ProductEventName = + "game_selected" | "readiness_completed" | "readiness_blocked" | "lobby_entered"; 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/index.ts b/packages/protocol/src/index.ts index b7c15e2..f2e399a 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -33,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 5b5616e..9be4a08 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -397,6 +397,67 @@ 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, +} + +/// 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 readiness_blocked_events: i64, + pub selected_to_ready_rate: f64, + pub selected_to_lobby_rate: f64, + pub blocked_by_check: Vec, +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -674,4 +735,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 c02e406..237c253 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -22,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, @@ -48,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"; From f8a4195962baf471949248e0d6a886674512114b Mon Sep 17 00:00:00 2001 From: Oleksii Date: Wed, 26 Aug 2026 08:58:12 -0300 Subject: [PATCH 4/7] feat: harden community alpha for production --- .env.example | 9 +- .github/workflows/ci.yml | 33 +++ .github/workflows/release.yml | 72 ++++++ Cargo.lock | 2 + README.md | 2 +- apps/client/src-tauri/Cargo.lock | 1 + .../src-tauri/src/commands/match_probe.rs | 68 ++++- apps/client/src-tauri/src/commands/process.rs | 79 +++--- apps/client/src-tauri/src/commands/runtime.rs | 44 ++-- apps/client/src-tauri/src/commands/session.rs | 4 + apps/client/src-tauri/src/commands/tunnel.rs | 90 ++++++- apps/client/src-tauri/src/main.rs | 27 +- apps/client/src/App.tsx | 50 +++- .../src/components/CampaignDashboard.tsx | 59 ----- apps/client/src/lib/api.test.ts | 20 +- apps/client/src/lib/api.ts | 41 ++- apps/client/src/lib/matchCoordinator.ts | 57 +++-- apps/client/src/lib/native.ts | 4 +- apps/client/src/lib/usePlayableMatch.ts | 7 +- apps/client/src/lib/ws.ts | 31 ++- apps/client/src/routes/Games.tsx | 13 +- apps/client/src/routes/Lobby.tsx | 48 +++- apps/client/src/routes/Match.tsx | 72 +++++- apps/server/Cargo.toml | 3 +- apps/server/Dockerfile | 4 +- .../server/migrations/008_match_lifecycle.sql | 116 +++++++++ apps/server/src/config.rs | 108 +++++++- apps/server/src/lib.rs | 43 +++- apps/server/src/lifecycle.rs | 78 ++++++ apps/server/src/main.rs | 19 +- apps/server/src/metrics.rs | 76 ++++-- apps/server/src/room_state.rs | 14 +- apps/server/src/routes/alpha.rs | 157 +++++++++--- apps/server/src/routes/alpha_evidence.rs | 74 ++++-- apps/server/src/routes/auth.rs | 67 +++-- apps/server/src/routes/challenges.rs | 167 +++++++++++-- apps/server/src/routes/rooms.rs | 233 +++++++++++++++--- apps/server/src/routes/telemetry.rs | 26 +- apps/server/src/state.rs | 21 ++ apps/server/tests/api_flow.rs | 97 +++++++- docker-compose.yml | 35 ++- docs/ARCHITECTURE.md | 41 +-- docs/operations/RUNBOOK.md | 45 ++++ packages/networking/src/relay.rs | 1 + packages/networking/src/tcp_tunnel.rs | 1 + packages/protocol/src/envelope.test.ts | 11 + packages/protocol/src/envelope.ts | 18 +- .../src/generated/ActivationSummaryPayload.ts | 3 + .../src/generated/ProductEventName.ts | 7 +- packages/protocol/src/lib.rs | 7 + packages/shared/Cargo.toml | 1 + packages/shared/src/lib.rs | 15 +- scripts/db/backup.sh | 21 ++ scripts/db/restore.sh | 26 ++ scripts/ops/decision-summary.sh | 20 ++ services/relay/Dockerfile | 4 +- services/relay/src/main.rs | 140 ++++++++++- 57 files changed, 2130 insertions(+), 402 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 apps/client/src/components/CampaignDashboard.tsx create mode 100644 apps/server/migrations/008_match_lifecycle.sql create mode 100644 apps/server/src/lifecycle.rs create mode 100644 docs/operations/RUNBOOK.md create mode 100755 scripts/db/backup.sh create mode 100755 scripts/db/restore.sh create mode 100755 scripts/ops/decision-summary.sh 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 d97acbc..8ddcf7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1165,6 +1165,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -1177,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/process.rs b/apps/client/src-tauri/src/commands/process.rs index b4c6733..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, } @@ -162,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); @@ -199,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))?; @@ -219,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())? @@ -229,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)) @@ -257,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 || { @@ -357,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 index c2c6649..417b868 100644 --- a/apps/client/src-tauri/src/commands/tunnel.rs +++ b/apps/client/src-tauri/src/commands/tunnel.rs @@ -1,12 +1,22 @@ use opencade_networking::run_native_tcp_tunnel; use opencade_shared::{RelayCapability, RelayTicket}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, net::SocketAddr, sync::Mutex, time::Duration}; +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: Mutex>>, + tasks: Arc>>, +} + +struct TunnelTask { + run_id: uuid::Uuid, + handle: JoinHandle<()>, } #[derive(Debug, Deserialize)] @@ -42,10 +52,14 @@ pub async fn start_native_tcp_tunnel( 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 (local_endpoint, task) = match request.mode { + 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())?; @@ -53,19 +67,38 @@ pub async fn start_native_tcp_tunnel( .local_addr() .map_err(|_| "native tunnel address is unavailable".to_string())?; let task = tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { + 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) + (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, @@ -74,23 +107,40 @@ pub async fn start_native_tcp_tunnel( 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) + (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(), task) { - previous.abort(); + 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>, @@ -102,11 +152,33 @@ pub fn stop_native_tcp_tunnel( .map_err(|_| "native tunnel registry is unavailable".to_string())? .remove(&room_id) { - task.abort(); + 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::*; diff --git a/apps/client/src-tauri/src/main.rs b/apps/client/src-tauri/src/main.rs index 4a6fee5..033b226 100644 --- a/apps/client/src-tauri/src/main.rs +++ b/apps/client/src-tauri/src/main.rs @@ -2,9 +2,14 @@ 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()) @@ -26,6 +31,22 @@ fn main() { 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 07f258e..abd6850 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -151,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(); @@ -167,7 +170,14 @@ export default function App() { return (
- @@ -175,7 +185,12 @@ export default function App() { {connection} {user.username} -
@@ -217,21 +232,46 @@ 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, "state") === "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")) && - typeof Reflect.get(payload, "revision") === "number" + 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"))) ); } diff --git a/apps/client/src/components/CampaignDashboard.tsx b/apps/client/src/components/CampaignDashboard.tsx deleted file mode 100644 index 00974c6..0000000 --- a/apps/client/src/components/CampaignDashboard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/api"; - -export default function CampaignDashboard({ token }: { token: string }) { - const campaign = useQuery({ - queryKey: ["alpha-campaign"], - queryFn: () => api.campaign(token), - staleTime: 15_000, - }); - if (campaign.isPending) return null; - if (campaign.isError) { - return

Campaign evidence unavailable: {campaign.error.message}

; - } - const summary = campaign.data; - return ( -
-
-
-

Evidence-backed community alpha

-

Proof-of-play campaign

-
- - {summary.verified}/{summary.attempts} verified · {Math.round(summary.success_rate * 100)}% - -
-
-
- {summary.gate_passed ? "✓" : "α"} - - - {summary.gate_passed ? "8-of-10 gate passed" : "Collecting paired evidence"} - - - {summary.failed} failed · {summary.reports} privacy-safe reports - - -
- {summary.compatibility.map((row) => ( -
- - {row.verified}/{row.attempts} - - - - {row.game_id} · {row.transport} - - - {row.platform} · {row.nat} · {row.candidate} - - -
- ))} -
-
- ); -} 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 f83bda4..839de68 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -1,9 +1,9 @@ +import { validateEnvelope } from "@opencade/protocol"; import type { AlphaFailureReport, Envelope, MatchPreflightPayload, MatchReport, - ActivationSummaryPayload, ProductEventPayload, RoomInvitePayload, RoomPayload, @@ -37,31 +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 }; -export type CampaignSummary = { - schema_version: number; - reports: number; - attempts: number; - verified: number; - failed: number; - success_rate: number; - gate_passed: boolean; - failures: { room_id: string; code: string }[]; - compatibility: { - game_id: string; - platform: string; - transport: string; - nat: string; - candidate: string; - attempts: number; - verified: number; - }[]; -}; - type ErrorPayload = { code?: string; message?: string }; export class ApiError extends Error { @@ -127,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 { @@ -181,6 +173,8 @@ 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, @@ -219,13 +213,10 @@ export const api = { token, post({ evidence }) ), - campaign: (token: string) => request("/api/v1/alpha/campaign", token), recordProductEvent: (token: string, event: ProductEventPayload) => request<{ accepted: boolean; duplicate: boolean }>( "/api/v1/telemetry/events", token, post(event) ), - activationSummary: (token: string) => - request("/api/v1/telemetry/activation", token), }; 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 11ecf89..9f6a0e6 100644 --- a/apps/client/src/lib/native.ts +++ b/apps/client/src/lib/native.ts @@ -58,6 +58,7 @@ export type NativeTunnelTicket = { room_id: string; user_id: string; expires_at: number; + nonce: string; capability: "native_tcp_tunnel"; signature: string; }; @@ -98,8 +99,6 @@ 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"); @@ -179,6 +178,7 @@ export async function runRelayMatchProbe(request: { room_id: string; user_id: string; expires_at: number; + nonce: string; signature: string; }; room_id: string; diff --git a/apps/client/src/lib/usePlayableMatch.ts b/apps/client/src/lib/usePlayableMatch.ts index d3b81f9..7e50ae5 100644 --- a/apps/client/src/lib/usePlayableMatch.ts +++ b/apps/client/src/lib/usePlayableMatch.ts @@ -5,9 +5,10 @@ import type { MatchProbeCompletedPayload, RoomPayload, } from "@opencade/protocol"; -import { api, getApiBase } from "./api"; +import { api } from "./api"; import { matchParticipants, selectNativeRoute } from "./match"; import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator"; +import { trackProductEvent } from "./telemetry"; import { launchRetroarchMatch, onEmulatorExit, @@ -111,10 +112,9 @@ export function usePlayableMatch({ 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 { @@ -124,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" }), 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 2539b2d..68c55ff 100644 --- a/apps/client/src/routes/Games.tsx +++ b/apps/client/src/routes/Games.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { api, type Game } from "../lib/api"; import { scanGame } from "../lib/native"; -import CampaignDashboard from "../components/CampaignDashboard"; import MatchReadiness from "../components/MatchReadiness"; import TelemetryConsent from "../components/TelemetryConsent"; import { trackProductEvent } from "../lib/telemetry"; @@ -27,7 +26,6 @@ export default function Games({ token, onSelect }: Props) { return ( <> - { @@ -66,7 +64,7 @@ function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (game: Game {game.name} {game.emulator} · {game.default_version ?? "version pending"} ·{" "} - {availability[index]?.data?.available ? "installed" : "ROM not detected"} + {availabilityLabel(availability[index])}
+ {(lobby.isError || incoming.isError || responseError) && ( +
+ {responseError || lobby.error?.message || incoming.error?.message} + +
+ )} {pending.map((item) => (
@@ -128,10 +152,18 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props) Room {item.room_id.slice(0, 8)}
- -
@@ -154,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 6f48905..b282640 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -11,6 +11,7 @@ import { } 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({ @@ -68,6 +69,8 @@ export default function Match({ 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(() => { @@ -193,6 +196,11 @@ export default function Match({ {playableMatch.error.message}

)} + {leaveError && ( +

+ {leaveError} +

+ )} {playableMatch.data && (

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

diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 3fba713..bf8ff17 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -21,12 +21,13 @@ 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" } diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 370f422..1ac18c6 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). 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 530a7cc..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::{ @@ -122,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 { @@ -137,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 index 3d9f389..62558fc 100644 --- a/apps/server/src/routes/alpha.rs +++ b/apps/server/src/routes/alpha.rs @@ -6,7 +6,7 @@ use opencade_protocol::{ }; use serde::Deserialize; use serde_json::{Value, json}; -use sqlx::Row; +use sqlx::{Postgres, Row, Transaction}; use uuid::Uuid; use crate::{ @@ -171,27 +171,42 @@ pub async fn submit_preflight( )); } validate_compatibility(&payload.compatibility)?; - ensure_member(&state, room_id, user.id).await?; + 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) - VALUES ($1, $2, $3, $4, FALSE, now()) + (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) - .execute(&state.pool) + .bind(attempt_id) + .execute(&mut *transaction) .await?; - sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, 'match.preflight')") - .bind(room_id) - .execute(&state.pool) - .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( @@ -214,42 +229,61 @@ pub async fn ready_to_launch( user: AuthUser, Path(room_id): Path, ) -> Result>, AppError> { - ensure_member(&state, room_id, user.id).await?; - let snapshot = build_snapshot(&state, room_id, user.id).await?; - if snapshot.preflight_count != 2 || !snapshot.compatibility_matched { + 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 native_port_available = TRUE", + WHERE room_id = $1 AND user_id = $2 AND attempt_id = $3 + AND native_port_available = TRUE", ) .bind(room_id) .bind(user.id) - .execute(&state.pool) + .bind(attempt_id) + .execute(&mut *transaction) .await?; let ready_count = sqlx::query_scalar::<_, i64>( - "SELECT count(*) FROM match_preflights WHERE room_id = $1 AND ready = TRUE", + "SELECT count(*) FROM match_preflights + WHERE room_id = $1 AND attempt_id = $2 AND ready = TRUE", ) .bind(room_id) - .fetch_one(&state.pool) + .bind(attempt_id) + .fetch_one(&mut *transaction) .await?; if ready_count == 2 { sqlx::query( - "INSERT INTO room_launch_barriers (room_id, launch_at) - VALUES ($1, now() + make_interval(secs => $2)) - ON CONFLICT (room_id) DO NOTHING", + "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(&state.pool) + .execute(&mut *transaction) .await?; } - sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, 'match.launch.ready')") - .bind(room_id) - .execute(&state.pool) - .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)))) @@ -263,7 +297,10 @@ async fn build_snapshot( 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 ORDER BY user_id", + 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) @@ -283,7 +320,9 @@ async fn build_snapshot( .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", + "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) @@ -309,19 +348,67 @@ async fn build_snapshot( }) } -async fn ensure_member(state: &AppState, room_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - let member = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = $1 AND user_id = $2)", +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_one(&state.pool) - .await?; - if member { - Ok(()) - } else { - Err(AppError::Forbidden("not a room member".into())) + .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( diff --git a/apps/server/src/routes/alpha_evidence.rs b/apps/server/src/routes/alpha_evidence.rs index e4da35d..73e7f72 100644 --- a/apps/server/src/routes/alpha_evidence.rs +++ b/apps/server/src/routes/alpha_evidence.rs @@ -1,4 +1,8 @@ -use axum::{Json, extract::State, http::StatusCode}; +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; use opencade_networking::summarize_campaign_evidence; use opencade_protocol::{AlphaFailureReport, Envelope, MatchReport, MatchReportRole}; use serde::Deserialize; @@ -9,6 +13,7 @@ use uuid::Uuid; use crate::{authn::AuthUser, error::AppError, state::AppState}; #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct EvidenceRequest { evidence: Value, } @@ -18,14 +23,21 @@ pub async fn submit_evidence( user: AuthUser, Json(request): Json, ) -> Result<(StatusCode, Json>), AppError> { - let encoded = serde_json::to_vec(&request.evidence) + let raw_encoded = serde_json::to_vec(&request.evidence) .map_err(|_| AppError::BadRequest("evidence is invalid".into()))?; - if encoded.len() > 64 * 1024 || contains_private_key(&request.evidence) { + if raw_encoded.len() > 64 * 1024 { return Err(AppError::BadRequest( - "evidence exceeds limits or contains private fields".into(), + "evidence exceeds the 64 KiB limit".into(), )); } - let (room_id, role, kind) = decode_identity(&request.evidence)?; + 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( @@ -43,7 +55,7 @@ pub async fn submit_evidence( .bind(user.id) .bind(role_label(role)) .bind(kind) - .bind(request.evidence) + .bind(canonical) .execute(&state.pool) .await?; reject_conflicting_duplicate(&state, room_id, user.id, kind, &digest, &inserted).await?; @@ -64,9 +76,22 @@ pub async fn submit_evidence( 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>( - "SELECT payload FROM alpha_evidence ORDER BY created_at DESC LIMIT 1000", + "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?; @@ -81,27 +106,40 @@ pub async fn campaign_summary( failures.push(report); } } - Ok(Json(Envelope::new( - "alpha.campaign.summary", - json!(summarize_campaign_evidence(&matches, &failures)), - ))) + 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_identity(value: &Value) -> Result<(Uuid, MatchReportRole, &'static str), AppError> { +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()))?; - Ok((parse_room_id(&report.room.id)?, report.probe.role, "match")) + 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()))?; - Ok(( - parse_room_id(&report.room.id)?, - report.role, - "attempt_failure", - )) + 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())), } 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/rooms.rs b/apps/server/src/routes/rooms.rs index 4de0374..c21bc1d 100644 --- a/apps/server/src/routes/rooms.rs +++ b/apps/server/src/routes/rooms.rs @@ -151,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); @@ -198,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) @@ -216,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( @@ -233,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( @@ -244,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( @@ -273,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 @@ -320,6 +387,7 @@ pub async fn native_tunnel_ticket( user: AuthUser, Path(id): Path, ) -> Result>, AppError> { + require_ticket_budget(&state, user.id)?; let relay_url = state .config .relay_url @@ -364,6 +432,19 @@ pub async fn native_tunnel_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, @@ -458,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( @@ -587,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 @@ -600,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?; @@ -642,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>( @@ -665,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) @@ -676,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 => {} @@ -734,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 index 5512668..76dffe1 100644 --- a/apps/server/src/routes/telemetry.rs +++ b/apps/server/src/routes/telemetry.rs @@ -1,6 +1,10 @@ use std::collections::HashSet; -use axum::{Json, extract::State, http::StatusCode}; +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; use opencade_protocol::{ ActivationSummaryPayload, Envelope, ProductEventName, ProductEventPayload, ReadinessBlockCount, ReadinessCheckId, @@ -79,7 +83,13 @@ pub async fn record_event( 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 @@ -96,6 +106,10 @@ pub async fn activation_summary( 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 @@ -110,6 +124,8 @@ pub async fn activation_summary( 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( @@ -161,9 +177,12 @@ pub async fn activation_summary( 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, }, ))) @@ -219,6 +238,8 @@ fn event_label(event: ProductEventName) -> &'static str { ProductEventName::ReadinessCompleted => "readiness_completed", ProductEventName::ReadinessBlocked => "readiness_blocked", ProductEventName::LobbyEntered => "lobby_entered", + ProductEventName::LaunchAttempted => "launch_attempted", + ProductEventName::LaunchSucceeded => "launch_succeeded", } } @@ -247,9 +268,12 @@ fn empty_summary() -> ActivationSummaryPayload { 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(), } } 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 bc70ab0..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"); @@ -185,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, @@ -488,6 +530,54 @@ 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") @@ -668,7 +758,7 @@ async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool ) .await; assert_eq!(privacy_status, StatusCode::BAD_REQUEST); - let (campaign_status, campaign) = request( + let (campaign_status, campaign) = operator_request( &app, Method::GET, "/api/v1/alpha/campaign", @@ -866,7 +956,7 @@ async fn product_telemetry_is_private_idempotent_bounded_and_aggregated(pool: Pg 0 ); - let (summary_status, summary) = request( + let (summary_status, summary) = operator_request( &app, Method::GET, "/api/v1/telemetry/activation", @@ -879,9 +969,12 @@ async fn product_telemetry_is_private_idempotent_bounded_and_aggregated(pool: Pg 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" 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 73942c3..e53cf7d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -447,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`. @@ -567,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, @@ -806,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. @@ -828,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. @@ -879,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/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/src/relay.rs b/packages/networking/src/relay.rs index 3c02de1..06232c0 100644 --- a/packages/networking/src/relay.rs +++ b/packages/networking/src/relay.rs @@ -22,6 +22,7 @@ impl RelayPeer { .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 index a2f7d7f..f4bf9c6 100644 --- a/packages/networking/src/tcp_tunnel.rs +++ b/packages/networking/src/tcp_tunnel.rs @@ -34,6 +34,7 @@ pub async fn run_native_tcp_tunnel( .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 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 index 554dd1b..ef02f40 100644 --- a/packages/protocol/src/generated/ActivationSummaryPayload.ts +++ b/packages/protocol/src/generated/ActivationSummaryPayload.ts @@ -9,8 +9,11 @@ export type ActivationSummaryPayload = { 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/ProductEventName.ts b/packages/protocol/src/generated/ProductEventName.ts index 12d1060..174e20c 100644 --- a/packages/protocol/src/generated/ProductEventName.ts +++ b/packages/protocol/src/generated/ProductEventName.ts @@ -4,4 +4,9 @@ * Product-funnel events accepted by the privacy-minimized telemetry endpoint. */ export type ProductEventName = - "game_selected" | "readiness_completed" | "readiness_blocked" | "lobby_entered"; + | "game_selected" + | "readiness_completed" + | "readiness_blocked" + | "lobby_entered" + | "launch_attempted" + | "launch_succeeded"; diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 9be4a08..da23e7c 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -406,6 +406,8 @@ pub enum ProductEventName { ReadinessCompleted, ReadinessBlocked, LobbyEntered, + LaunchAttempted, + LaunchSucceeded, } /// Stable readiness checks used for aggregate blocker analysis. @@ -452,9 +454,14 @@ pub struct ActivationSummaryPayload { #[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, } 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 9721840..6ff3927 100644 --- a/packages/shared/src/lib.rs +++ b/packages/shared/src/lib.rs @@ -1,6 +1,7 @@ use hmac::{Hmac, KeyInit, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; +use uuid::Uuid; pub const MAX_RELAY_TICKET_LIFETIME_SECONDS: i64 = 300; @@ -26,6 +27,7 @@ pub struct RelayTicket { pub user_id: String, pub expires_at: i64, pub capability: RelayCapability, + pub nonce: Uuid, pub signature: String, } @@ -61,12 +63,14 @@ impl RelayTicket { capability: RelayCapability, ) -> Result { validate_inputs(secret, room_id, user_id)?; - let signature = signature(secret, room_id, user_id, expires_at, capability)?; + 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), }) } @@ -87,6 +91,7 @@ impl RelayTicket { &self.user_id, self.expires_at, self.capability, + self.nonce, )); mac.verify_slice(&provided) .map_err(|_| RelayTicketError::InvalidSignature) @@ -113,9 +118,12 @@ fn signature( 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, capability)); + mac.update(&canonical_claims( + room_id, user_id, expires_at, capability, nonce, + )); Ok(mac.finalize().into_bytes().to_vec()) } @@ -124,9 +132,10 @@ fn canonical_claims( 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(), capability.as_str() 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/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 d2fb474..9749bdb 100644 --- a/services/relay/src/main.rs +++ b/services/relay/src/main.rs @@ -16,10 +16,10 @@ 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,12 +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, capability)) - .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 { @@ -167,11 +186,13 @@ async fn handle_socket( 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_key.clone()).or_default(); @@ -179,13 +200,18 @@ async fn handle_socket( 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!( @@ -203,11 +229,42 @@ async fn handle_socket( } }); - 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; @@ -246,12 +303,21 @@ async fn handle_socket( } let sent = broadcast_message(&state, &room_key, Message::Text(text.clone()), id); - info!(peer_id = %id, room_id = %room_id, sent = sent, "broadcast envelope"); + 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 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, @@ -262,7 +328,7 @@ async fn handle_socket( } let sent = broadcast_message(&state, &room_key, Message::Binary(bin.clone()), id); - info!(peer_id = %id, sent = sent, "broadcast binary"); + debug!(peer_id = %id, sent = sent, "broadcast binary"); } Message::Ping(payload) => { let _ = tx.try_send(Message::Pong(payload)); @@ -303,6 +369,24 @@ async fn handle_socket( 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() @@ -338,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); @@ -370,16 +455,18 @@ 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={}&capability={}&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 ) } @@ -409,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"); @@ -430,6 +521,35 @@ mod tests { 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 From df98a91f0202b727bda00f16a3a33f6545873b25 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Wed, 26 Aug 2026 21:56:49 -0300 Subject: [PATCH 5/7] fix: rebuild workspace crates in production image --- apps/server/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 1ac18c6..5101910 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -38,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 From 88905c47786d4153a7a2a28dc83421e23ecb123a Mon Sep 17 00:00:00 2001 From: Oleksii Date: Fri, 28 Aug 2026 22:56:05 -0300 Subject: [PATCH 6/7] feat(alpha): ship evidence-backed opportunity portfolio Add deterministic no-ROM Proof-of-Play, controller preflight, route policy, match receipts, compatibility publishing, release hardening, and protocol/relay performance improvements. --- .env.example | 3 + .github/workflows/ci.yml | 9 + .github/workflows/release.yml | 66 ++- Cargo.lock | 4 +- README.md | 3 +- adapters/retroarch/src/lib.rs | 186 ++++++-- apps/client/src-tauri/src/commands/fs.rs | 29 +- apps/client/src-tauri/src/commands/process.rs | 40 +- apps/client/src/App.tsx | 54 +-- apps/client/src/lib/api.test.ts | 20 + apps/client/src/lib/api.ts | 38 +- apps/client/src/lib/controller.test.ts | 21 + apps/client/src/lib/controller.ts | 9 + apps/client/src/lib/native.ts | 5 +- apps/client/src/lib/report.test.ts | 27 ++ apps/client/src/lib/report.ts | 8 +- apps/client/src/lib/snapshot.test.ts | 27 +- apps/client/src/lib/snapshot.ts | 67 ++- apps/client/src/lib/usePlayableMatch.ts | 74 +++- apps/client/src/lib/ws.test.ts | 11 +- apps/client/src/lib/ws.ts | 6 +- apps/client/src/routes/Match.tsx | 176 +++++++- apps/client/src/styles.css | 34 ++ apps/server/Cargo.toml | 2 +- apps/server/Dockerfile | 2 +- .../migrations/009_opportunity_portfolio.sql | 42 ++ apps/server/src/config.rs | 55 +++ apps/server/src/lib.rs | 9 + apps/server/src/lifecycle.rs | 63 ++- apps/server/src/main.rs | 6 +- apps/server/src/route_policy.rs | 141 ++++++ apps/server/src/routes/alpha.rs | 328 +++++++++++++- apps/server/src/routes/alpha_evidence.rs | 150 ++++++- apps/server/src/routes/auth.rs | 16 +- apps/server/src/routes/rooms.rs | 11 + apps/server/src/routes/telemetry.rs | 6 - apps/server/src/state.rs | 16 +- apps/server/src/ws.rs | 312 +++++++------ apps/server/tests/api_flow.rs | 146 ++++++- crates/proof-of-match/src/lib.rs | 1 + docker-compose.yml | 4 +- docs/ARCHITECTURE.md | 5 +- docs/IMPLEMENTATION_STATUS.md | 17 +- docs/NEXT_IMPLEMENTATIONS.md | 413 +++--------------- docs/OPPORTUNITIES_2026-08-28.md | 308 +++++++++++++ docs/PRODUCT_TELEMETRY.md | 9 +- ...evidence-backed-proof-of-play-portfolio.md | 37 ++ docs/alpha/RETROARCH_TEST.md | 43 +- docs/operations/RUNBOOK.md | 15 +- .../opencade-test-core/opencade_test.ocade | 1 + .../opencade-test-core/opencade_test_core.c | 236 ++++++++++ .../game-definitions/games/opencade_test.toml | 14 + .../game-definitions/schema/game.schema.json | 2 +- packages/game-definitions/src/index.ts | 6 +- packages/game-definitions/src/loader.rs | 2 +- .../src/bin/opencade-match-probe.rs | 1 + packages/networking/src/report.rs | 2 + packages/networking/tests/report_verifier.rs | 2 + packages/protocol/Cargo.toml | 2 +- packages/protocol/examples/throughput.rs | 47 ++ packages/protocol/src/envelope.ts | 39 +- .../src/generated/ActivationSummaryPayload.ts | 14 +- .../src/generated/AlphaFailureReport.ts | 13 +- .../src/generated/AlphaFailureStage.ts | 9 +- .../src/generated/ChallengePayload.ts | 9 +- .../protocol/src/generated/ChatPayload.ts | 2 +- .../generated/ControllerPreflightPayload.ts | 6 + .../src/generated/LaunchBarrierPayload.ts | 7 +- .../src/generated/MatchEndpointPayload.ts | 8 +- .../src/generated/MatchPreflightPayload.ts | 7 +- .../generated/MatchProbeCompletedPayload.ts | 6 +- .../src/generated/MatchReceiptPayload.ts | 8 + .../protocol/src/generated/MatchReport.ts | 10 +- .../src/generated/MatchReportClient.ts | 2 +- .../src/generated/MatchReportCompatibility.ts | 8 +- .../src/generated/MatchReportProbe.ts | 12 +- .../protocol/src/generated/MatchReportRoom.ts | 2 +- .../src/generated/NativeRoutePolicyPayload.ts | 7 + .../protocol/src/generated/PresencePayload.ts | 8 +- .../src/generated/ProductEventName.ts | 8 +- .../src/generated/ProductEventPayload.ts | 8 +- .../generated/PublicCompatibilityCohort.ts | 7 + .../generated/PublicCompatibilityPayload.ts | 7 + .../src/generated/ReadinessBlockCount.ts | 2 +- .../src/generated/ReadinessCheckId.ts | 3 +- .../src/generated/RoomInvitePayload.ts | 2 +- .../protocol/src/generated/RoomPayload.ts | 8 +- .../src/generated/RoomSnapshotPayload.ts | 10 +- packages/protocol/src/generated/RoomState.ts | 3 +- .../src/generated/SessionAnswerPayload.ts | 2 +- .../src/generated/SessionCandidatePayload.ts | 7 +- .../src/generated/SessionOfferPayload.ts | 2 +- packages/protocol/src/index.ts | 5 + packages/protocol/src/lib.rs | 136 +++++- packages/protocol/src/messages.ts | 10 + scripts/alpha/Build-TestCore.ps1 | 29 ++ scripts/alpha/OpenCade-Alpha.ps1 | 41 +- scripts/alpha/README.txt | 8 +- services/relay/src/main.rs | 57 ++- site/compatibility/index.html | 47 ++ site/compatibility/main.js | 67 +++ site/compatibility/styles.css | 127 ++++++ 102 files changed, 3241 insertions(+), 921 deletions(-) create mode 100644 apps/client/src/lib/controller.test.ts create mode 100644 apps/client/src/lib/controller.ts create mode 100644 apps/server/migrations/009_opportunity_portfolio.sql create mode 100644 apps/server/src/route_policy.rs create mode 100644 docs/OPPORTUNITIES_2026-08-28.md create mode 100644 docs/adr/0006-evidence-backed-proof-of-play-portfolio.md create mode 100644 fixtures/libretro/opencade-test-core/opencade_test.ocade create mode 100644 fixtures/libretro/opencade-test-core/opencade_test_core.c create mode 100644 packages/game-definitions/games/opencade_test.toml create mode 100644 packages/protocol/examples/throughput.rs create mode 100644 packages/protocol/src/generated/ControllerPreflightPayload.ts create mode 100644 packages/protocol/src/generated/MatchReceiptPayload.ts create mode 100644 packages/protocol/src/generated/NativeRoutePolicyPayload.ts create mode 100644 packages/protocol/src/generated/PublicCompatibilityCohort.ts create mode 100644 packages/protocol/src/generated/PublicCompatibilityPayload.ts create mode 100644 scripts/alpha/Build-TestCore.ps1 create mode 100644 site/compatibility/index.html create mode 100644 site/compatibility/main.js create mode 100644 site/compatibility/styles.css diff --git a/.env.example b/.env.example index 3bf5dd1..f21317f 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,6 @@ STUN_HOST=stun.example.com # 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 +# Evidence-gated native route promotion. Leave disabled until physical-host evidence qualifies. +# OPENCADE_EVIDENCE_TCP_TUNNEL=false +# ROUTE_POLICY_MIN_ATTEMPTS=10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c0c2e..a082c4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,15 @@ jobs: - name: Build Windows LAN alpha tools run: cargo build -p opencade-networking --bins --release --locked + - name: Configure MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Build deterministic OpenCade test core + shell: pwsh + run: .\scripts\alpha\Build-TestCore.ps1 + - name: Build Windows desktop alpha run: cargo build --manifest-path apps/client/src-tauri/Cargo.toml --release --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7337e44..fd1cd48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,65 @@ permissions: id-token: write attestations: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: + validate: + name: Validate tagged commit + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: opencade + POSTGRES_PASSWORD: opencade + POSTGRES_DB: opencade_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U opencade -d opencade_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + 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" + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: pnpm install --frozen-lockfile + - name: Require a matching semantic version + shell: bash + run: | + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "${GITHUB_REF_NAME#v}" = "$(node -p "require('./package.json').version")" + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace --locked + env: + DATABASE_URL: postgres://opencade:opencade@localhost:5432/opencade_test + SESSION_SECRET: release-test-secret-at-least-32-bytes + - run: pnpm format:check + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build + - name: Audit Rust dependencies + run: | + cargo install cargo-audit --locked + cargo audit --deny warnings + - run: pnpm audit --prod --audit-level high + windows: runs-on: windows-latest + needs: validate steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -26,6 +82,13 @@ jobs: - 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: Configure MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - name: Build deterministic OpenCade test core + shell: pwsh + run: .\scripts\alpha\Build-TestCore.ps1 - name: Require and import Windows signing certificate shell: pwsh env: @@ -49,7 +112,8 @@ jobs: "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" + "target\release\opencade-alpha-summary.exe", + "fixtures\libretro\opencade-test-core\build\opencade_test_libretro.dll" ) foreach ($executable in $executables) { & $signTool sign /sha1 $env:OPENCADE_CERT_THUMBPRINT /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $executable diff --git a/Cargo.lock b/Cargo.lock index 8ddcf7b..3a17292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,9 +198,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", diff --git a/README.md b/README.md index 6ba33ef..9784e86 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > **Open-source arcade netplay — a clean-room, community-owned alternative for low-latency rollback matchmaking and emulation.** -OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, safe local FBNeo launch, direct UDP/STUN/hole-punching, authenticated readiness-probe relay fallback, and an experimental native-process RetroArch adapter. Standalone FBNeo netplay, physical cross-NAT results, behavior classification, and real two-machine RetroArch play remain explicitly unproven. +OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, an original no-ROM libretro Proof-of-Play fixture, safe local FBNeo launch, direct UDP/STUN/hole-punching, authenticated readiness-probe relay fallback, and an experimental native-process RetroArch adapter. Standalone FBNeo netplay, physical cross-NAT results, behavior classification, and real two-machine RetroArch play remain explicitly unproven. ## Support — keep it community-owned @@ -106,6 +106,7 @@ OpenCade/ │ ├── adr/ # Architecture decision records │ ├── alpha/ # LAN test and match-report procedures │ ├── IMPLEMENTATION_STATUS.md # Verified scope and explicit non-claims +│ ├── OPPORTUNITIES_2026-08-28.md # 20 screened bets + one recommended keystone │ └── reference-fightcade-install.md # D:/Fightcade read-only notes (never copied) ├── docker/ │ └── (compose at root: `docker-compose.yml` — see also `docker/` if present) diff --git a/adapters/retroarch/src/lib.rs b/adapters/retroarch/src/lib.rs index 63b2804..d32bc70 100644 --- a/adapters/retroarch/src/lib.rs +++ b/adapters/retroarch/src/lib.rs @@ -27,6 +27,52 @@ const FBNEO_CORE: &str = "fbneo_libretro.dylib"; #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] const FBNEO_CORE: &str = "fbneo_libretro.so"; +#[cfg(target_os = "windows")] +const TEST_CORE: &str = "opencade_test_libretro.dll"; +#[cfg(target_os = "macos")] +const TEST_CORE: &str = "opencade_test_libretro.dylib"; +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] +const TEST_CORE: &str = "opencade_test_libretro.so"; + +pub const TEST_GAME_ID: &str = "opencade_test"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CoreProfile { + Fbneo, + OpenCadeTest, +} + +impl CoreProfile { + fn for_game(game_id: &str) -> Self { + if game_id == TEST_GAME_ID { + Self::OpenCadeTest + } else { + Self::Fbneo + } + } + + fn core_name(self) -> &'static str { + match self { + Self::Fbneo => FBNEO_CORE, + Self::OpenCadeTest => TEST_CORE, + } + } + + fn adapter_id(self) -> &'static str { + match self { + Self::Fbneo => "retroarch_fbneo", + Self::OpenCadeTest => "retroarch_test", + } + } + + fn content_extension(self) -> &'static str { + match self { + Self::Fbneo => "zip", + Self::OpenCadeTest => "ocade", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct CompatibilityFingerprint { pub retroarch_version: Option, @@ -55,8 +101,8 @@ impl RetroarchAdapter { self.install_root.join(EXECUTABLE) } - fn core(&self) -> PathBuf { - self.install_root.join("cores").join(FBNEO_CORE) + fn core(&self, profile: CoreProfile) -> PathBuf { + self.install_root.join("cores").join(profile.core_name()) } fn installed_version(&self) -> Option { @@ -67,28 +113,51 @@ impl RetroarchAdapter { } pub fn fingerprint(&self, rom_path: &Path) -> Result { + self.fingerprint_for_game("fbneo", rom_path) + } + + pub fn fingerprint_for_game( + &self, + game_id: &str, + content_path: &Path, + ) -> Result { + let profile = CoreProfile::for_game(game_id); let executable = canonicalize_below(&self.executable(), &self.install_root)?; - let core = canonicalize_below(&self.core(), &self.install_root)?; - let rom = canonicalize_below(rom_path, &self.rom_root())?; + let core = canonicalize_below(&self.core(profile), &self.install_root)?; + let content = canonicalize_below(content_path, &self.rom_root())?; Ok(CompatibilityFingerprint { retroarch_version: self.installed_version(), executable_sha256: sha256_file(&executable)?, core_sha256: sha256_file(&core)?, - content_sha256: sha256_file(&rom)?, + content_sha256: sha256_file(&content)?, }) } + pub fn adapter_id_for_game(&self, game_id: &str) -> &'static str { + CoreProfile::for_game(game_id).adapter_id() + } + pub fn match_launch_spec( &self, rom_path: &Path, descriptor: &MatchDescriptor, ) -> Result { + self.match_launch_spec_for_game("fbneo", rom_path, descriptor) + } + + pub fn match_launch_spec_for_game( + &self, + game_id: &str, + content_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + let profile = CoreProfile::for_game(game_id); self.prepare_match(descriptor)?; let transport = descriptor.transport_lease()?; - self.validate(rom_path)?; + self.validate_profile(profile, content_path)?; let executable = canonicalize_below(&self.executable(), &self.install_root)?; - let core = canonicalize_below(&self.core(), &self.install_root)?; - let rom = canonicalize_below(rom_path, &self.rom_root())?; + let core = canonicalize_below(&self.core(profile), &self.install_root)?; + let content = canonicalize_below(content_path, &self.rom_root())?; let current_dir = executable .parent() .ok_or_else(|| AdapterError::Validation("RetroArch has no parent directory".into()))? @@ -112,7 +181,7 @@ impl RetroarchAdapter { OsString::from("--frames"), OsString::from(descriptor.input_delay_frames.to_string()), OsString::from("--verbose"), - rom.into_os_string(), + content.into_os_string(), ]); Ok(LaunchSpec { executable, @@ -129,6 +198,58 @@ impl RetroarchAdapter { ) -> Result { launcher.spawn(&self.match_launch_spec(rom_path, descriptor)?) } + + pub fn launch_match_for_game_with( + &self, + launcher: &L, + game_id: &str, + content_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + launcher.spawn(&self.match_launch_spec_for_game(game_id, content_path, descriptor)?) + } + + pub fn launch_match_for_game( + &self, + game_id: &str, + content_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + self.launch_match_for_game_with(&StdProcessLauncher, game_id, content_path, descriptor) + } + + fn validate_profile( + &self, + profile: CoreProfile, + content_path: &Path, + ) -> Result { + let executable = self.executable(); + let core = self.core(profile); + if !executable.is_file() || !core.is_file() { + return Err(AdapterError::NotDetected(format!( + "expected {EXECUTABLE} and cores/{} below the RetroArch root", + profile.core_name() + ))); + } + canonicalize_below(content_path, &self.rom_root())?; + if content_path.extension().and_then(|value| value.to_str()) + != Some(profile.content_extension()) + { + return Err(AdapterError::Validation(format!( + "{} content must use .{}", + profile.adapter_id(), + profile.content_extension() + ))); + } + let mut report = ValidationReport::valid(); + if self.installed_version().is_none() { + report.warnings.push( + "VERSION.txt is missing; exact RetroArch version will not appear in evidence" + .into(), + ); + } + Ok(report) + } } impl EmulatorAdapter for RetroarchAdapter { @@ -160,21 +281,7 @@ impl EmulatorAdapter for RetroarchAdapter { } fn validate(&self, rom_path: &Path) -> Result { - self.detect(&self.install_root)?; - canonicalize_below(rom_path, &self.rom_root())?; - if rom_path.extension().and_then(|value| value.to_str()) != Some("zip") { - return Err(AdapterError::Validation( - "RetroArch FBNeo content must be a .zip file".into(), - )); - } - let mut report = ValidationReport::valid(); - if self.installed_version().is_none() { - report.warnings.push( - "VERSION.txt is missing; exact RetroArch version will not appear in evidence" - .into(), - ); - } - Ok(report) + self.validate_profile(CoreProfile::Fbneo, rom_path) } fn get_version(&self) -> Result { @@ -185,7 +292,7 @@ impl EmulatorAdapter for RetroarchAdapter { fn launch(&self, rom_path: &Path) -> Result { self.validate(rom_path)?; let executable = canonicalize_below(&self.executable(), &self.install_root)?; - let core = canonicalize_below(&self.core(), &self.install_root)?; + let core = canonicalize_below(&self.core(CoreProfile::Fbneo), &self.install_root)?; let rom = canonicalize_below(rom_path, &self.rom_root())?; let current_dir = executable .parent() @@ -264,7 +371,11 @@ mod tests { std::fs::create_dir_all(root.join("ROMs")).expect("ROM directory"); std::fs::write(root.join(EXECUTABLE), b"retroarch").expect("executable"); std::fs::write(root.join("cores").join(FBNEO_CORE), b"fbneo-core").expect("core"); + std::fs::write(root.join("cores").join(TEST_CORE), b"opencade-test-core") + .expect("test core"); std::fs::write(root.join("ROMs/sfiii3.zip"), b"content").expect("content"); + std::fs::write(root.join("ROMs/opencade_test.ocade"), b"OPENCADE-TEST-1\n") + .expect("test content"); std::fs::write(root.join("VERSION.txt"), "1.22.0\n").expect("version"); root } @@ -339,6 +450,31 @@ mod tests { std::fs::remove_dir_all(root).expect("remove fixture"); } + #[test] + fn fingerprints_and_launches_the_no_rom_test_profile() { + let root = fixture(); + let adapter = RetroarchAdapter::new(&root); + let content = root.join("ROMs/opencade_test.ocade"); + let fingerprint = adapter + .fingerprint_for_game(TEST_GAME_ID, &content) + .expect("test fingerprint"); + assert_eq!(adapter.adapter_id_for_game(TEST_GAME_ID), "retroarch_test"); + assert_eq!(fingerprint.content_sha256.len(), 64); + let spec = adapter + .match_launch_spec_for_game(TEST_GAME_ID, &content, &descriptor(PeerRole::Host)) + .expect("test launch spec"); + assert!(spec.args.iter().any(|arg| { + Path::new(arg) + .file_name() + .is_some_and(|name| name == TEST_CORE) + })); + assert_eq!( + spec.args.last(), + Some(&content.canonicalize().expect("content").into_os_string()) + ); + std::fs::remove_dir_all(root).expect("remove fixture"); + } + #[test] fn rejects_content_outside_the_rom_root() { let root = fixture(); diff --git a/apps/client/src-tauri/src/commands/fs.rs b/apps/client/src-tauri/src/commands/fs.rs index ad39086..be0147e 100644 --- a/apps/client/src-tauri/src/commands/fs.rs +++ b/apps/client/src-tauri/src/commands/fs.rs @@ -1,4 +1,5 @@ use opencade_adapter_fbneo::FbneoAdapter; +use opencade_adapter_retroarch::RetroarchAdapter; use opencade_emulator_sdk::EmulatorAdapter; use serde::Serialize; use std::path::PathBuf; @@ -37,8 +38,32 @@ pub fn scan_fbneo_rom(root: PathBuf, game_id: &str) -> Result Result { - scan_fbneo_rom(super::process::fbneo_root(&app)?, &game_id) +pub async fn scan_game(app: tauri::AppHandle, game_id: String) -> Result { + if game_id == opencade_adapter_retroarch::TEST_GAME_ID { + let root = super::process::retroarch_root(&app)?; + return tokio::task::spawn_blocking(move || { + let adapter = RetroarchAdapter::new(&root); + let content = root.join("ROMs").join("opencade_test.ocade"); + match adapter.fingerprint_for_game(&game_id, &content) { + Ok(_) => Ok(GameAvailability { + game_id, + available: true, + warnings: Vec::new(), + }), + Err(error) => Ok(GameAvailability { + game_id, + available: false, + warnings: vec![error.to_string()], + }), + } + }) + .await + .map_err(|error| format!("game scan worker failed: {error}"))?; + } + let root = super::process::fbneo_root(&app)?; + tokio::task::spawn_blocking(move || scan_fbneo_rom(root, &game_id)) + .await + .map_err(|error| format!("game scan worker failed: {error}"))? } #[cfg(test)] diff --git a/apps/client/src-tauri/src/commands/process.rs b/apps/client/src-tauri/src/commands/process.rs index 7d7b02c..7a637fd 100644 --- a/apps/client/src-tauri/src/commands/process.rs +++ b/apps/client/src-tauri/src/commands/process.rs @@ -25,7 +25,7 @@ pub fn fbneo_root(app: &tauri::AppHandle) -> Result { .map_err(|_| "application resource directory is unavailable".into()) } -fn retroarch_root(app: &tauri::AppHandle) -> Result { +pub(crate) fn retroarch_root(app: &tauri::AppHandle) -> Result { if let Some(root) = std::env::var_os("OPENCADE_RETROARCH_ROOT") { let root = PathBuf::from(root); if !root.is_absolute() { @@ -104,16 +104,17 @@ pub async fn retroarch_preflight( 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 content = content_path(&root, &game_id); + let adapter_id = adapter.adapter_id_for_game(&game_id); + let fingerprint = + tokio::task::spawn_blocking(move || adapter.fingerprint_for_game(&game_id, &content)) + .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(); + ensure_native_port_available(std::net::SocketAddr::from(([0, 0, 0, 0], 55_435))).is_ok(); Ok(RetroarchPreflight { - adapter: "retroarch_fbneo", + adapter: adapter_id, emulator_version: fingerprint.retroarch_version, executable_sha256: fingerprint.executable_sha256, core_sha256: fingerprint.core_sha256, @@ -168,9 +169,9 @@ pub async fn launch_retroarch_match( validate_game_id(&authorized.game_id)?; let root = retroarch_root(&app)?; let adapter = RetroarchAdapter::new(&root); - let rom = root - .join("ROMs") - .join(format!("{}.zip", authorized.game_id)); + let game_id = authorized.game_id.clone(); + let content = content_path(&root, &game_id); + let adapter_id = adapter.adapter_id_for_game(&game_id); let descriptor = MatchDescriptor { room_id: authorized.room_id.clone(), game_id: authorized.game_id, @@ -190,8 +191,8 @@ pub async fn launch_retroarch_match( ensure_native_port_available(descriptor.local_endpoint)?; } let (fingerprint, child) = tokio::task::spawn_blocking(move || { - let fingerprint = adapter.fingerprint(&rom)?; - let child = adapter.launch_match(&rom, &descriptor)?; + let fingerprint = adapter.fingerprint_for_game(&game_id, &content)?; + let child = adapter.launch_match_for_game(&game_id, &content, &descriptor)?; Ok::<_, opencade_emulator_sdk::AdapterError>((fingerprint, child)) }) .await @@ -206,12 +207,21 @@ pub async fn launch_retroarch_match( let pid = register_child(&app, &state, child, Some(callback))?; Ok(RetroarchMatchLaunch { pid, - adapter: "retroarch_fbneo", + adapter: adapter_id, room_id, fingerprint, }) } +fn content_path(root: &std::path::Path, game_id: &str) -> PathBuf { + let extension = if game_id == opencade_adapter_retroarch::TEST_GAME_ID { + "ocade" + } else { + "zip" + }; + root.join("ROMs").join(format!("{game_id}.{extension}")) +} + #[tauri::command] pub fn stop_game(state: tauri::State<'_, ProcessState>, pid: u32) -> Result<(), String> { let child = state diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index abd6850..3a9b87b 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -4,7 +4,6 @@ import type { Envelope, MatchEndpointPayload, MatchProbeCompletedPayload, - RoomPayload, RoomSnapshotPayload, } from "@opencade/protocol"; import DiagnosticsButton from "./components/DiagnosticsButton"; @@ -12,7 +11,7 @@ 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 { isRoomPayload, isRoomSnapshot, newestSnapshot } from "./lib/snapshot"; import { OpenCadeSocket, type ConnectionState } from "./lib/ws"; import Auth from "./routes/Auth"; import Games from "./routes/Games"; @@ -117,6 +116,13 @@ export default function App() { ); queryClient.setQueryData(["room", snapshot.room.id], snapshot.room); } + if (message.type === "room.deadline.expired") { + const roomId = roomIdFromPayload(message.payload); + if (roomId) { + void queryClient.invalidateQueries({ queryKey: ["room", roomId] }); + void queryClient.invalidateQueries({ queryKey: ["room-snapshot", roomId] }); + } + } }); socket.connect(); return () => { @@ -231,50 +237,6 @@ 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/lib/api.test.ts b/apps/client/src/lib/api.test.ts index 5a15a2f..93959af 100644 --- a/apps/client/src/lib/api.test.ts +++ b/apps/client/src/lib/api.test.ts @@ -42,6 +42,26 @@ describe("runtime API configuration", () => { await expect(api.games("session-token")).rejects.toMatchObject({ code: "invalid_response" }); }); + it("rejects a launch snapshot with an incomplete payload", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + type: "room.snapshot", + version: "1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + payload: { room: {}, route: "direct_lan" }, + }), + }) + ); + await expect(api.roomSnapshot("session-token", "room-1")).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 839de68..3dfef8d 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -3,12 +3,14 @@ import type { AlphaFailureReport, Envelope, MatchPreflightPayload, + MatchReceiptPayload, MatchReport, ProductEventPayload, RoomInvitePayload, RoomPayload, RoomSnapshotPayload, } from "@opencade/protocol"; +import { isRoomSnapshot } from "./snapshot"; export type User = { id: string; username: string; email?: string | null }; export type AuthPayload = { user: User; token: string; expires_at: string }; @@ -79,7 +81,12 @@ export function getApiBase(): string { return apiBase; } -async function request(path: string, token?: string | null, init?: RequestInit): Promise { +async function request( + path: string, + token?: string | null, + init?: RequestInit, + decoder?: (value: unknown) => value is T +): Promise { const headers = new Headers(init?.headers); headers.set("Accept", "application/json"); if (init?.body) headers.set("Content-Type", "application/json"); @@ -104,6 +111,13 @@ async function request(path: string, token?: string | null, init?: RequestIni if (!envelope || envelope.payload === undefined) { throw new ApiError(response.status, "invalid_response", "Server returned an invalid envelope"); } + if (decoder && !decoder(envelope.payload)) { + throw new ApiError( + response.status, + "invalid_response", + "Server returned an invalid response payload" + ); + } return envelope.payload as T; } @@ -202,17 +216,33 @@ export const api = { 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)), + request( + `/api/v1/rooms/${roomId}/preflight`, + token, + post(payload), + isRoomSnapshot + ), roomSnapshot: (token: string, roomId: string) => - request(`/api/v1/rooms/${roomId}/snapshot`, token), + request( + `/api/v1/rooms/${roomId}/snapshot`, + token, + undefined, + isRoomSnapshot + ), readyToLaunch: (token: string, roomId: string) => - request(`/api/v1/rooms/${roomId}/ready`, token, post()), + request(`/api/v1/rooms/${roomId}/ready`, token, post(), isRoomSnapshot), submitEvidence: (token: string, evidence: MatchReport | AlphaFailureReport) => request<{ digest: string; duplicate: boolean }>( "/api/v1/alpha/evidence", token, post({ evidence }) ), + createMatchReceipt: (token: string, roomId: string, idempotencyKey: string) => + request( + `/api/v1/rooms/${roomId}/receipt`, + token, + post({ idempotency_key: idempotencyKey }) + ), recordProductEvent: (token: string, event: ProductEventPayload) => request<{ accepted: boolean; duplicate: boolean }>( "/api/v1/telemetry/events", diff --git a/apps/client/src/lib/controller.test.ts b/apps/client/src/lib/controller.test.ts new file mode 100644 index 0000000..a30a084 --- /dev/null +++ b/apps/client/src/lib/controller.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { controllerPreflight } from "./controller"; + +describe("controllerPreflight", () => { + it("assigns deterministic complementary player slots", () => { + Object.defineProperty(navigator, "getGamepads", { + configurable: true, + value: vi.fn(() => []), + }); + expect(controllerPreflight("host")).toEqual({ connected: false, player_slot: 1 }); + expect(controllerPreflight("guest")).toEqual({ connected: false, player_slot: 2 }); + }); + + it("requires at least one connected controller", () => { + Object.defineProperty(navigator, "getGamepads", { + configurable: true, + value: vi.fn(() => [{ connected: true } as Gamepad, null, null, null]), + }); + expect(controllerPreflight("host").connected).toBe(true); + }); +}); diff --git a/apps/client/src/lib/controller.ts b/apps/client/src/lib/controller.ts new file mode 100644 index 0000000..9c97d3c --- /dev/null +++ b/apps/client/src/lib/controller.ts @@ -0,0 +1,9 @@ +import type { ControllerPreflightPayload, MatchReportRole } from "@opencade/protocol"; + +export function controllerPreflight(role: MatchReportRole): ControllerPreflightPayload { + const pads = typeof navigator.getGamepads === "function" ? navigator.getGamepads() : []; + return { + connected: Array.from(pads).some((pad) => pad?.connected), + player_slot: role === "host" ? 1 : 2, + }; +} diff --git a/apps/client/src/lib/native.ts b/apps/client/src/lib/native.ts index 9f6a0e6..8d13a24 100644 --- a/apps/client/src/lib/native.ts +++ b/apps/client/src/lib/native.ts @@ -29,7 +29,7 @@ export type MatchProbeReport = { }; export type RetroarchMatchLaunch = { pid: number; - adapter: "retroarch_fbneo"; + adapter: "retroarch_fbneo" | "retroarch_test"; room_id: string; fingerprint: { retroarch_version?: string | null; @@ -37,9 +37,10 @@ export type RetroarchMatchLaunch = { core_sha256: string; content_sha256: string; }; + native_route?: "direct_lan" | "tcp_tunnel"; }; export type RetroarchPreflight = { - adapter: "retroarch_fbneo"; + adapter: "retroarch_fbneo" | "retroarch_test"; emulator_version?: string | null; executable_sha256: string; core_sha256: string; diff --git a/apps/client/src/lib/report.test.ts b/apps/client/src/lib/report.test.ts index 2ae0845..b91601f 100644 --- a/apps/client/src/lib/report.test.ts +++ b/apps/client/src/lib/report.test.ts @@ -111,6 +111,31 @@ describe("buildMatchReport", () => { expect(JSON.stringify(report.compatibility)).not.toContain("C:\\"); expect(JSON.stringify(report.compatibility)).not.toContain("path"); }); + + it("records an evidence-qualified native tunnel separately from probe transport", () => { + const report = buildMatchReport( + { + id: "room-123", + game_id: "opencade_test", + host_id: "host", + guest_id: "guest", + state: "finished", + }, + probe, + new Date("2026-08-23T12:00:00Z"), + { + adapter: "retroarch_test", + emulator_version: "1.22.0", + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + }, + "tcp_tunnel" + ); + + expect(report.probe.transport).toBe("direct_udp"); + expect(report.native_route).toBe("tcp_tunnel"); + }); }); describe("buildAlphaFailureReport", () => { @@ -130,6 +155,7 @@ describe("buildAlphaFailureReport", () => { stage: "relay", error_code: "relay_probe_failed", transport: "relay", + native_route: "tcp_tunnel", }, new Date("2026-08-24T12:00:00Z") ); @@ -138,6 +164,7 @@ describe("buildAlphaFailureReport", () => { expect(report.kind).toBe("attempt_failure"); expect(report.stage).toBe("relay"); expect(report.transport).toBe("relay"); + expect(report.native_route).toBe("tcp_tunnel"); expect(serialized).not.toContain("host_id"); expect(serialized).not.toContain("guest_id"); expect(serialized).not.toContain("endpoint"); diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index 7b44d54..d579aa9 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -13,7 +13,8 @@ export function buildMatchReport( room: RoomPayload, probe: MatchProbeReport, now = new Date(), - compatibility?: MatchReportCompatibility + compatibility?: MatchReportCompatibility, + nativeRoute: "direct_lan" | "tcp_tunnel" = "direct_lan" ): MatchReport { return { schema_version: 1, @@ -35,6 +36,7 @@ export function buildMatchReport( user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, }, compatibility: compatibility ?? null, + native_route: nativeRoute, }; } @@ -46,7 +48,7 @@ export function downloadMatchReport( const compatibility = compatibilityFromLaunch(playable); downloadJson( `opencade-match-${room.id.slice(0, 8)}.json`, - buildMatchReport(room, probe, new Date(), compatibility) + buildMatchReport(room, probe, new Date(), compatibility, playable?.native_route) ); } @@ -68,6 +70,7 @@ export type AlphaFailureEvidence = { stage: AlphaFailureStage; error_code: string; transport?: MatchReportTransport; + native_route?: "direct_lan" | "tcp_tunnel"; }; export function buildAlphaFailureReport( @@ -88,6 +91,7 @@ export function buildAlphaFailureReport( stage: failure.stage, error_code: failure.error_code, transport: failure.transport ?? null, + native_route: failure.native_route ?? null, client: { platform: typeof navigator === "undefined" ? "unknown" : navigator.platform, user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, diff --git a/apps/client/src/lib/snapshot.test.ts b/apps/client/src/lib/snapshot.test.ts index 79ca963..4cc081f 100644 --- a/apps/client/src/lib/snapshot.test.ts +++ b/apps/client/src/lib/snapshot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { RoomSnapshotPayload } from "@opencade/protocol"; -import { newestSnapshot } from "./snapshot"; +import { isRoomSnapshot, newestSnapshot } from "./snapshot"; const snapshot = (revision: number): RoomSnapshotPayload => ({ room: { @@ -13,6 +13,7 @@ const snapshot = (revision: number): RoomSnapshotPayload => ({ revision, preflight_count: 2, compatibility_matched: true, + controller_ready_count: 2, barrier: { room_id: "room-1", ready_count: 2, @@ -20,6 +21,12 @@ const snapshot = (revision: number): RoomSnapshotPayload => ({ launch_at: "2026-08-25T12:00:00Z", }, route: "direct_lan", + route_policy: { + route: "direct_lan", + reason: "tcp_tunnel_operator_disabled", + evidence_attempts: 0, + evidence_verified: 0, + }, }); describe("newestSnapshot", () => { @@ -28,4 +35,22 @@ describe("newestSnapshot", () => { expect(newestSnapshot(snapshot(8), snapshot(8)).revision).toBe(8); expect(newestSnapshot(snapshot(8), snapshot(9)).revision).toBe(9); }); + + it("validates every launch-critical snapshot field", () => { + expect(isRoomSnapshot(snapshot(8))).toBe(true); + expect(isRoomSnapshot({ ...snapshot(8), controller_ready_count: undefined })).toBe(false); + expect(isRoomSnapshot({ ...snapshot(8), route_policy: undefined })).toBe(false); + expect( + isRoomSnapshot({ + ...snapshot(8), + route_policy: { ...snapshot(8).route_policy, route: "tcp_tunnel" }, + }) + ).toBe(false); + expect( + isRoomSnapshot({ + ...snapshot(8), + barrier: { ...snapshot(8).barrier, room_id: "another-room" }, + }) + ).toBe(false); + }); }); diff --git a/apps/client/src/lib/snapshot.ts b/apps/client/src/lib/snapshot.ts index 1a880f0..4a796a1 100644 --- a/apps/client/src/lib/snapshot.ts +++ b/apps/client/src/lib/snapshot.ts @@ -1,4 +1,69 @@ -import type { RoomSnapshotPayload } from "@opencade/protocol"; +import type { RoomPayload, RoomSnapshotPayload } from "@opencade/protocol"; + +const ROOM_STATES = new Set([ + "waiting", + "ready", + "challenging", + "connecting", + "playing", + "finished", + "cancelled", +]); +const ROUTES = new Set(["direct_lan", "tcp_tunnel"]); + +function isBoundedCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 255; +} + +function isEvidenceCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 4_294_967_295; +} + +export function isRoomPayload(payload: unknown): payload is RoomPayload { + 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") && + ROOM_STATES.has(String(Reflect.get(payload, "state"))) + ); +} + +export function isRoomSnapshot(payload: unknown): payload is RoomSnapshotPayload { + if (typeof payload !== "object" || payload === null) return false; + const room = Reflect.get(payload, "room"); + const barrier = Reflect.get(payload, "barrier"); + const policy = Reflect.get(payload, "route_policy"); + const route = Reflect.get(payload, "route"); + return ( + isRoomPayload(room) && + Number.isSafeInteger(Reflect.get(payload, "revision")) && + Number(Reflect.get(payload, "revision")) >= 0 && + isBoundedCount(Reflect.get(payload, "preflight_count")) && + typeof Reflect.get(payload, "compatibility_matched") === "boolean" && + isBoundedCount(Reflect.get(payload, "controller_ready_count")) && + typeof barrier === "object" && + barrier !== null && + Reflect.get(barrier, "room_id") === room.id && + isBoundedCount(Reflect.get(barrier, "ready_count")) && + isBoundedCount(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")))))) && + ROUTES.has(String(route)) && + typeof policy === "object" && + policy !== null && + Reflect.get(policy, "route") === route && + typeof Reflect.get(policy, "reason") === "string" && + isEvidenceCount(Reflect.get(policy, "evidence_attempts")) && + isEvidenceCount(Reflect.get(policy, "evidence_verified")) && + Number(Reflect.get(policy, "evidence_verified")) <= + Number(Reflect.get(policy, "evidence_attempts")) + ); +} export function newestSnapshot( current: RoomSnapshotPayload | undefined, diff --git a/apps/client/src/lib/usePlayableMatch.ts b/apps/client/src/lib/usePlayableMatch.ts index 7e50ae5..22df5c4 100644 --- a/apps/client/src/lib/usePlayableMatch.ts +++ b/apps/client/src/lib/usePlayableMatch.ts @@ -7,13 +7,16 @@ import type { } from "@opencade/protocol"; import { api } from "./api"; import { matchParticipants, selectNativeRoute } from "./match"; +import { controllerPreflight } from "./controller"; import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator"; import { trackProductEvent } from "./telemetry"; import { launchRetroarchMatch, onEmulatorExit, retroarchPreflight, + startNativeTcpTunnel, stopGame, + stopNativeTcpTunnel, type MatchEndpointCandidate, type MatchProbeReport, } from "./native"; @@ -56,6 +59,7 @@ export function usePlayableMatch({ const preflight = useMutation({ mutationFn: async () => { if (!room) throw new Error("Room is unavailable for compatibility preflight"); + if (!participants) throw new Error("Two room participants are required for preflight"); const local = await retroarchPreflight(room.game_id); return api.submitPreflight(token, roomId, { room_id: roomId, @@ -67,6 +71,7 @@ export function usePlayableMatch({ core_sha256: local.core_sha256, content_sha256: local.content_sha256, }, + controller: controllerPreflight(participants.role), }); }, onSuccess: (value) => queryClient.setQueryData(["room-snapshot", roomId], value), @@ -103,29 +108,50 @@ export function usePlayableMatch({ throw new Error("Native gameplay requires a verified direct UDP path"); } if (!canLaunch) throw new Error("Synchronized launch barrier is not ready"); - const route = selectNativeRoute( - room, - userId, - localEndpoint.endpoint, - peerEndpoint.endpoint, - probeReport.transport, - probeReport.candidate - ); + const nativeRoute = snapshot.data?.route ?? "direct_lan"; + const route = + nativeRoute === "tcp_tunnel" + ? { local: "127.0.0.1:55435", peer: "127.0.0.1:55435" } + : 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({ - launch_grant: grant.grant, - }); + if (nativeRoute === "tcp_tunnel") { + const relay = await api.nativeTunnelTicket(token, roomId); + if (relay.ticket.capability !== "native_tcp_tunnel") { + throw new Error("Server returned an invalid native tunnel capability"); + } + await startNativeTcpTunnel({ + relay_url: relay.relay_url, + ticket: { ...relay.ticket, capability: "native_tcp_tunnel" }, + mode: participants.role === "host" ? "connect" : "listen", + local_endpoint: "127.0.0.1:55435", + }); + } + let launch; + try { + const grant = await api.createLaunchGrant(token, roomId, route.local, route.peer); + launch = await launchRetroarchMatch({ launch_grant: grant.grant }); + } catch (error) { + if (nativeRoute === "tcp_tunnel") await stopNativeTcpTunnel(roomId).catch(() => undefined); + throw error; + } try { await api.startRoom(token, roomId); } catch (error) { await stopGame(launch.pid).catch(() => undefined); + if (nativeRoute === "tcp_tunnel") await stopNativeTcpTunnel(roomId).catch(() => undefined); throw error; } await queryClient.invalidateQueries({ queryKey: ["room", roomId] }); await trackProductEvent(token, "launch_succeeded", room.game_id).catch(() => false); - return launch; + return { ...launch, native_route: nativeRoute }; }, onSuccess: () => dispatch({ type: "native_spawned" }), onError: (error) => @@ -163,10 +189,15 @@ export function usePlayableMatch({ }, [coordinator.phase, preflight]); useEffect(() => { - if (!snapshot.data?.compatibility_matched || readyStarted.current) return; + if ( + !snapshot.data?.compatibility_matched || + snapshot.data.controller_ready_count !== 2 || + readyStarted.current + ) + return; readyStarted.current = true; ready.mutate(); - }, [ready, snapshot.data?.compatibility_matched]); + }, [ready, snapshot.data?.compatibility_matched, snapshot.data?.controller_ready_count]); useEffect(() => { if (room?.state === "playing") dispatch({ type: "room_playing" }); @@ -190,6 +221,13 @@ export function usePlayableMatch({ }; }, [queryClient, roomId]); + useEffect( + () => () => { + void stopNativeTcpTunnel(roomId); + }, + [roomId] + ); + return { coordinator, participants, @@ -198,6 +236,12 @@ export function usePlayableMatch({ preflightPending: preflight.isPending, launchBarrierPending: ready.isPending, canLaunch, + retryPreflight: () => { + preflightStarted.current = true; + readyStarted.current = false; + preflight.reset(); + preflight.mutate(); + }, resetCoordinator: () => { preflightStarted.current = false; readyStarted.current = false; diff --git a/apps/client/src/lib/ws.test.ts b/apps/client/src/lib/ws.test.ts index d8fb866..82e6b9e 100644 --- a/apps/client/src/lib/ws.test.ts +++ b/apps/client/src/lib/ws.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { reconnectDelay } from "./ws.js"; describe("reconnectDelay", () => { - it("backs off exponentially and caps at 30 seconds", () => { - expect(reconnectDelay(0)).toBe(500); - expect(reconnectDelay(3)).toBe(4_000); - expect(reconnectDelay(20)).toBe(30_000); + it("uses bounded jitter while backing off exponentially", () => { + expect(reconnectDelay(0, () => 0)).toBe(250); + expect(reconnectDelay(0, () => 1)).toBe(500); + expect(reconnectDelay(3, () => 0)).toBe(2_000); + expect(reconnectDelay(3, () => 1)).toBe(4_000); + expect(reconnectDelay(20, () => 0)).toBe(15_000); + expect(reconnectDelay(20, () => 1)).toBe(30_000); }); }); diff --git a/apps/client/src/lib/ws.ts b/apps/client/src/lib/ws.ts index 50c544c..74cc3c6 100644 --- a/apps/client/src/lib/ws.ts +++ b/apps/client/src/lib/ws.ts @@ -3,8 +3,10 @@ import { createEnvelope, parseEnvelope, type Envelope } from "@opencade/protocol export type ConnectionState = "idle" | "connecting" | "open" | "reconnecting" | "closed"; export type MessageListener = (message: Envelope) => void; -export function reconnectDelay(attempt: number): number { - return Math.min(30_000, 500 * 2 ** Math.max(0, attempt)); +export function reconnectDelay(attempt: number, random: () => number = Math.random): number { + const cap = Math.min(30_000, 500 * 2 ** Math.max(0, attempt)); + const unit = Math.max(0, Math.min(1, random())); + return Math.round(cap / 2 + (cap / 2) * unit); } export class OpenCadeSocket { diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index b282640..11b0eb0 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { MatchEndpointPayload, MatchProbeCompletedPayload } from "@opencade/protocol"; import { api } from "../lib/api"; import { @@ -57,6 +57,7 @@ export default function Match({ preflightPending, launchBarrierPending, canLaunch, + retryPreflight, resetCoordinator, } = usePlayableMatch({ token, @@ -71,13 +72,18 @@ export default function Match({ const [now, setNow] = useState(Date.now()); const [isLeaving, setIsLeaving] = useState(false); const [leaveError, setLeaveError] = useState(""); + const [receiptCopied, setReceiptCopied] = useState(false); + const [receiptCopyError, setReceiptCopyError] = useState(""); + const receiptKey = useRef(crypto.randomUUID()); const uploadedSuccess = useRef(false); const uploadedFailure = useRef(false); useEffect(() => { - if (!snapshot?.barrier.launch_at) return; + if (!snapshot?.barrier.launch_at || state !== "connecting") return; + const deadline = new Date(snapshot.barrier.launch_at).getTime(); + if (deadline <= Date.now()) return; const timer = window.setInterval(() => setNow(Date.now()), 500); return () => window.clearInterval(timer); - }, [snapshot?.barrier.launch_at]); + }, [snapshot?.barrier.launch_at, state]); const steps = ["connecting", "playing", "finished"]; const active = Math.max(0, steps.indexOf(state)); const heading = @@ -97,6 +103,7 @@ export default function Match({ stage: "native_launch" as const, error_code: "native_launch_failed", transport: probeReport?.transport, + native_route: snapshot?.route, } : coordinator.phase === "failed" && coordinator.error?.includes("transcript") ? { @@ -105,19 +112,39 @@ 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(() => { + const evidenceUpload = useMutation({ + mutationFn: async () => { + if (!completedRoom || !probeReport || !playableMatch.data) { + throw new Error("Completed match evidence is unavailable"); + } + const report = buildMatchReport( + completedRoom, + probeReport, + new Date(), + compatibilityFromLaunch(playableMatch.data), + playableMatch.data.native_route + ); + return api.submitEvidence(token, report); + }, + retry: 2, + onMutate: () => { + uploadedSuccess.current = true; + }, + onError: () => { uploadedSuccess.current = false; - }); - }, [completedRoom, playableMatch.data, probeReport, token]); + }, + }); + useEffect(() => { + if ( + !completedRoom || + !probeReport || + !playableMatch.data || + uploadedSuccess.current || + evidenceUpload.status !== "idle" + ) + return; + evidenceUpload.mutate(); + }, [completedRoom, evidenceUpload, playableMatch.data, probeReport]); useEffect(() => { if (!failureRoom || !participants || !failureEvidence || uploadedFailure.current) return; uploadedFailure.current = true; @@ -129,6 +156,17 @@ export default function Match({ const launchSeconds = snapshot?.barrier.launch_at ? Math.max(0, Math.ceil((new Date(snapshot.barrier.launch_at).getTime() - now) / 1000)) : undefined; + const receipt = useMutation({ + mutationFn: () => api.createMatchReceipt(token, roomId, receiptKey.current), + }); + const receiptText = receipt.data + ? [ + `OpenCade ${receipt.data.game_id} match: ${receipt.data.result}`, + `Route: ${receipt.data.route}`, + `Next-match invite: ${receipt.data.invite.code}`, + `Expires: ${new Date(receipt.data.invite.expires_at).toLocaleString()}`, + ].join("\n") + : ""; return (

Room {roomId.slice(0, 8)}

@@ -140,7 +178,11 @@ export default function Match({
    {steps.map((step, index) => ( -
  1. +
  2. {step}
  3. ))} @@ -163,11 +205,13 @@ export default function Match({ ? "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"} + : snapshot.controller_ready_count < 2 + ? `Connect a controller · ready ${snapshot.controller_ready_count}/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" && ( @@ -207,6 +251,42 @@ export default function Match({ {playableMatch.data.fingerprint.content_sha256.slice(0, 12)}

    )} + {receipt.data && ( +
    + + Verified match receipt created. + +

    Verified match receipt

    +

    Invite the next opponent

    +

    + {receipt.data.game_id} · {receipt.data.route.replaceAll("_", " ")} · compatibility + verified +

    + {receipt.data.invite.code} + Expires {new Date(receipt.data.invite.expires_at).toLocaleString()} +
    + )} + {receipt.isError && ( + + )} + {evidenceUpload.isError && ( + + )} + {receiptCopied && ( +

    + Match receipt copied. +

    + )} + {receiptCopyError && ( +

    + {receiptCopyError} +

    + )}
    {(probeError || coordinator.phase === "relay_probe_only") && ( + )} {completedRoom && probeReport && ( + )} + {evidenceUpload.isError && ( + + )} + {receipt.data && ( + + )} {failureRoom && participants && failureEvidence && ( +
    +

    Loading compatibility evidence…

    +
    + + + + + + + + + + + +
    GamePlatformAdapterRouteVerified rate and sample
    +
    +
+ + + + diff --git a/site/compatibility/main.js b/site/compatibility/main.js new file mode 100644 index 0000000..73890d1 --- /dev/null +++ b/site/compatibility/main.js @@ -0,0 +1,67 @@ +const status = document.querySelector("#status"); +const cohorts = document.querySelector("#cohorts"); +const refresh = document.querySelector("#refresh"); + +function apiOrigin() { + const requested = new URLSearchParams(window.location.search).get("api"); + const candidate = requested || window.location.origin; + const parsed = new URL(candidate); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local)) { + throw new Error("The compatibility API must use HTTPS unless it runs on this machine."); + } + return parsed.origin; +} + +function cell(row, value) { + const element = document.createElement("td"); + element.textContent = value; + row.append(element); +} + +function render(items) { + cohorts.replaceChildren(); + const ranked = [...items].sort((left, right) => { + const leftRate = left.attempts > 0 ? left.verified / left.attempts : 0; + const rightRate = right.attempts > 0 ? right.verified / right.attempts : 0; + return rightRate - leftRate || right.attempts - left.attempts; + }); + for (const item of ranked) { + const row = document.createElement("tr"); + cell(row, item.game_id); + cell(row, item.platform); + cell(row, `${item.adapter}${item.emulator_version ? ` ${item.emulator_version}` : ""}`); + cell(row, `${item.native_route.replaceAll("_", " ")} · ${item.transport.replaceAll("_", " ")}`); + const rate = item.attempts > 0 ? Math.round((item.verified / item.attempts) * 100) : 0; + cell(row, `${rate}% · ${item.verified}/${item.attempts} rooms`); + cohorts.append(row); + } +} + +async function load() { + refresh.setAttribute("aria-busy", "true"); + status.textContent = "Loading compatibility evidence…"; + try { + const response = await fetch(`${apiOrigin()}/api/v1/public/compatibility`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) throw new Error(`Evidence service returned HTTP ${response.status}.`); + const envelope = await response.json(); + const items = Array.isArray(envelope?.payload?.cohorts) ? envelope.payload.cohorts : []; + render(items); + const threshold = Number(envelope?.payload?.minimum_cohort_size) || 3; + status.textContent = items.length + ? `${items.length} privacy-safe cohorts · minimum ${threshold} rooms · generated ${new Date(envelope.payload.generated_at).toLocaleString()}` + : `No cohort has reached the ${threshold}-room publication threshold yet.`; + } catch (error) { + status.textContent = + error instanceof Error + ? `${error.message} Add ?api=https://your-server.example to select the public API.` + : "Compatibility evidence is unavailable. Try again."; + } finally { + refresh.removeAttribute("aria-busy"); + } +} + +refresh.addEventListener("click", () => void load()); +void load(); diff --git a/site/compatibility/styles.css b/site/compatibility/styles.css new file mode 100644 index 0000000..0859dc0 --- /dev/null +++ b/site/compatibility/styles.css @@ -0,0 +1,127 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: hsl(220 15% 10%); + color: hsl(220 10% 94%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: hsl(220 15% 10%); +} + +main { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0 96px; +} + +header { + max-width: 720px; + margin-bottom: 48px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 16px; + font-size: clamp(32px, 6vw, 64px); + line-height: 1.1; +} + +h2 { + margin-bottom: 0; + font-size: 24px; +} + +.eyebrow { + margin-bottom: 8px; + color: hsl(212 92% 68%); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lede, +#status { + color: hsl(220 8% 70%); + line-height: 1.6; +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +button { + min-height: 48px; + padding: 0 16px; + border: 1px solid hsl(212 80% 58%); + border-radius: 8px; + background: hsl(212 80% 52%); + color: white; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button:hover { + background: hsl(212 80% 46%); +} + +button:active { + background: hsl(212 80% 42%); +} + +button:focus-visible, +.table-scroll:focus-visible { + outline: 3px solid hsl(48 100% 60%); + outline-offset: 3px; +} + +.table-scroll { + overflow-x: auto; + border-radius: 8px; + background: hsl(220 13% 14%); +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +th, +td { + padding: 16px; + border-bottom: 1px solid hsl(220 10% 24%); +} + +th { + color: hsl(220 8% 70%); + font-size: 13px; + font-weight: 600; +} + +@media (max-width: 768px) { + main { + padding-top: 32px; + } + + .section-heading { + align-items: stretch; + flex-direction: column; + } +} From 51d8e19752d6afd4f84bb954e1dfd6c1caedb612 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Fri, 28 Aug 2026 23:03:16 -0300 Subject: [PATCH 7/7] chore(protocol): normalize generated TypeScript bindings --- .../src/generated/ActivationSummaryPayload.ts | 14 +++++++++++++- .../protocol/src/generated/AlphaFailureReport.ts | 13 ++++++++++++- .../protocol/src/generated/AlphaFailureStage.ts | 9 ++++++++- .../protocol/src/generated/ChallengePayload.ts | 9 ++++++++- packages/protocol/src/generated/ChatPayload.ts | 2 +- .../src/generated/ControllerPreflightPayload.ts | 2 +- .../protocol/src/generated/LaunchBarrierPayload.ts | 7 ++++++- .../protocol/src/generated/MatchEndpointPayload.ts | 8 +++++++- .../src/generated/MatchPreflightPayload.ts | 7 ++++++- .../src/generated/MatchProbeCompletedPayload.ts | 6 +++++- .../protocol/src/generated/MatchReceiptPayload.ts | 10 +++++++++- packages/protocol/src/generated/MatchReport.ts | 10 +++++++++- .../protocol/src/generated/MatchReportClient.ts | 2 +- .../src/generated/MatchReportCompatibility.ts | 8 +++++++- .../protocol/src/generated/MatchReportProbe.ts | 12 +++++++++++- packages/protocol/src/generated/MatchReportRoom.ts | 2 +- .../src/generated/NativeRoutePolicyPayload.ts | 7 ++++++- packages/protocol/src/generated/PresencePayload.ts | 8 +++++++- .../protocol/src/generated/ProductEventName.ts | 8 +++++++- .../protocol/src/generated/ProductEventPayload.ts | 8 +++++++- .../src/generated/PublicCompatibilityCohort.ts | 11 ++++++++++- .../src/generated/PublicCompatibilityPayload.ts | 7 ++++++- .../protocol/src/generated/ReadinessBlockCount.ts | 2 +- .../protocol/src/generated/ReadinessCheckId.ts | 3 ++- .../protocol/src/generated/RoomInvitePayload.ts | 2 +- packages/protocol/src/generated/RoomPayload.ts | 8 +++++++- .../protocol/src/generated/RoomSnapshotPayload.ts | 11 ++++++++++- packages/protocol/src/generated/RoomState.ts | 3 ++- .../protocol/src/generated/SessionAnswerPayload.ts | 2 +- .../src/generated/SessionCandidatePayload.ts | 7 ++++++- .../protocol/src/generated/SessionOfferPayload.ts | 2 +- 31 files changed, 179 insertions(+), 31 deletions(-) diff --git a/packages/protocol/src/generated/ActivationSummaryPayload.ts b/packages/protocol/src/generated/ActivationSummaryPayload.ts index 40c13be..ef02f40 100644 --- a/packages/protocol/src/generated/ActivationSummaryPayload.ts +++ b/packages/protocol/src/generated/ActivationSummaryPayload.ts @@ -4,4 +4,16 @@ 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, }; +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/AlphaFailureReport.ts b/packages/protocol/src/generated/AlphaFailureReport.ts index 6fea9ce..db30b82 100644 --- a/packages/protocol/src/generated/AlphaFailureReport.ts +++ b/packages/protocol/src/generated/AlphaFailureReport.ts @@ -10,4 +10,15 @@ import type { NativeRouteCapability } from "./NativeRouteCapability"; /** * Canonical redacted evidence for an alpha attempt that did not complete. */ -export type AlphaFailureReport = { schema_version: number, kind: AlphaEvidenceKind, exported_at: string, room: MatchReportRoom, role: MatchReportRole, stage: AlphaFailureStage, error_code: string, transport: MatchReportTransport | null, native_route?: NativeRouteCapability | null, client: MatchReportClient, }; +export type AlphaFailureReport = { + schema_version: number; + kind: AlphaEvidenceKind; + exported_at: string; + room: MatchReportRoom; + role: MatchReportRole; + stage: AlphaFailureStage; + error_code: string; + transport: MatchReportTransport | null; + native_route?: NativeRouteCapability | null; + client: MatchReportClient; +}; diff --git a/packages/protocol/src/generated/AlphaFailureStage.ts b/packages/protocol/src/generated/AlphaFailureStage.ts index d047934..f32a9cd 100644 --- a/packages/protocol/src/generated/AlphaFailureStage.ts +++ b/packages/protocol/src/generated/AlphaFailureStage.ts @@ -3,4 +3,11 @@ /** * Stable stages for privacy-minimized evidence from an abandoned alpha attempt. */ -export type AlphaFailureStage = "endpoint_reservation" | "direct_udp" | "relay_ticket" | "relay" | "peer_transcript" | "room_transition" | "native_launch"; +export type AlphaFailureStage = + | "endpoint_reservation" + | "direct_udp" + | "relay_ticket" + | "relay" + | "peer_transcript" + | "room_transition" + | "native_launch"; diff --git a/packages/protocol/src/generated/ChallengePayload.ts b/packages/protocol/src/generated/ChallengePayload.ts index 2520840..4c340c5 100644 --- a/packages/protocol/src/generated/ChallengePayload.ts +++ b/packages/protocol/src/generated/ChallengePayload.ts @@ -4,4 +4,11 @@ import type { ChallengeState } from "./ChallengeState"; /** * Challenge request between two users for a game/room. */ -export type ChallengePayload = { challenge_id: string, room_id: string, game_id: string, challenger_id: string, challenged_id: string, state: ChallengeState, }; +export type ChallengePayload = { + challenge_id: string; + room_id: string; + game_id: string; + challenger_id: string; + challenged_id: string; + state: ChallengeState; +}; diff --git a/packages/protocol/src/generated/ChatPayload.ts b/packages/protocol/src/generated/ChatPayload.ts index bcc124c..5b12881 100644 --- a/packages/protocol/src/generated/ChatPayload.ts +++ b/packages/protocol/src/generated/ChatPayload.ts @@ -3,4 +3,4 @@ /** * Chat message. Used for `chat.message` in rooms/lobbies. */ -export type ChatPayload = { channel: string, body: string, author_id: string, }; +export type ChatPayload = { channel: string; body: string; author_id: string }; diff --git a/packages/protocol/src/generated/ControllerPreflightPayload.ts b/packages/protocol/src/generated/ControllerPreflightPayload.ts index d304abb..4ac16a1 100644 --- a/packages/protocol/src/generated/ControllerPreflightPayload.ts +++ b/packages/protocol/src/generated/ControllerPreflightPayload.ts @@ -3,4 +3,4 @@ /** * Local controller observation and deterministic player assignment for native netplay. */ -export type ControllerPreflightPayload = { connected: boolean, player_slot: number, }; +export type ControllerPreflightPayload = { connected: boolean; player_slot: number }; diff --git a/packages/protocol/src/generated/LaunchBarrierPayload.ts b/packages/protocol/src/generated/LaunchBarrierPayload.ts index f5542e9..e6bdb03 100644 --- a/packages/protocol/src/generated/LaunchBarrierPayload.ts +++ b/packages/protocol/src/generated/LaunchBarrierPayload.ts @@ -3,4 +3,9 @@ /** * 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, }; +export type LaunchBarrierPayload = { + room_id: string; + ready_count: number; + required_count: number; + launch_at: string | null; +}; diff --git a/packages/protocol/src/generated/MatchEndpointPayload.ts b/packages/protocol/src/generated/MatchEndpointPayload.ts index 67bb257..d1eacc2 100644 --- a/packages/protocol/src/generated/MatchEndpointPayload.ts +++ b/packages/protocol/src/generated/MatchEndpointPayload.ts @@ -4,4 +4,10 @@ import type { NatMappingState } from "./NatMappingState"; /** * Direct-UDP endpoint candidate exchanged between authenticated room members. */ -export type MatchEndpointPayload = { room_id: string, endpoint: string, reflexive_endpoint: string | null, nat: NatMappingState, nonce: string, }; +export type MatchEndpointPayload = { + room_id: string; + endpoint: string; + reflexive_endpoint: string | null; + nat: NatMappingState; + nonce: string; +}; diff --git a/packages/protocol/src/generated/MatchPreflightPayload.ts b/packages/protocol/src/generated/MatchPreflightPayload.ts index 72aacbf..9cb2471 100644 --- a/packages/protocol/src/generated/MatchPreflightPayload.ts +++ b/packages/protocol/src/generated/MatchPreflightPayload.ts @@ -5,4 +5,9 @@ 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, controller: ControllerPreflightPayload, }; +export type MatchPreflightPayload = { + room_id: string; + compatibility: MatchReportCompatibility; + native_port_available: boolean; + controller: ControllerPreflightPayload; +}; diff --git a/packages/protocol/src/generated/MatchProbeCompletedPayload.ts b/packages/protocol/src/generated/MatchProbeCompletedPayload.ts index 33596fb..92fd040 100644 --- a/packages/protocol/src/generated/MatchProbeCompletedPayload.ts +++ b/packages/protocol/src/generated/MatchProbeCompletedPayload.ts @@ -3,4 +3,8 @@ /** * Result of a completed direct-UDP proof, relayed to the other room member. */ -export type MatchProbeCompletedPayload = { room_id: string, frames_received: number, transcript_checksum: string, }; +export type MatchProbeCompletedPayload = { + room_id: string; + frames_received: number; + transcript_checksum: string; +}; diff --git a/packages/protocol/src/generated/MatchReceiptPayload.ts b/packages/protocol/src/generated/MatchReceiptPayload.ts index bb63675..b072de1 100644 --- a/packages/protocol/src/generated/MatchReceiptPayload.ts +++ b/packages/protocol/src/generated/MatchReceiptPayload.ts @@ -5,4 +5,12 @@ import type { RoomInvitePayload } from "./RoomInvitePayload"; /** * Privacy-safe proof of a completed match paired with a fresh, expiring continuation invite. */ -export type MatchReceiptPayload = { receipt_id: string, game_id: string, result: string, route: NativeRouteCapability, compatibility_verified: boolean, created_at: string, invite: RoomInvitePayload, }; +export type MatchReceiptPayload = { + receipt_id: string; + game_id: string; + result: string; + route: NativeRouteCapability; + compatibility_verified: boolean; + created_at: string; + invite: RoomInvitePayload; +}; diff --git a/packages/protocol/src/generated/MatchReport.ts b/packages/protocol/src/generated/MatchReport.ts index 93d00de..fcfedc2 100644 --- a/packages/protocol/src/generated/MatchReport.ts +++ b/packages/protocol/src/generated/MatchReport.ts @@ -8,4 +8,12 @@ import type { NativeRouteCapability } from "./NativeRouteCapability"; /** * Canonical, redacted evidence emitted by both the desktop and standalone LAN probe. */ -export type MatchReport = { schema_version: number, exported_at: string, room: MatchReportRoom, probe: MatchReportProbe, client: MatchReportClient, compatibility?: MatchReportCompatibility | null, native_route?: NativeRouteCapability | null, }; +export type MatchReport = { + schema_version: number; + exported_at: string; + room: MatchReportRoom; + probe: MatchReportProbe; + client: MatchReportClient; + compatibility?: MatchReportCompatibility | null; + native_route?: NativeRouteCapability | null; +}; diff --git a/packages/protocol/src/generated/MatchReportClient.ts b/packages/protocol/src/generated/MatchReportClient.ts index 58c44e7..ec4099a 100644 --- a/packages/protocol/src/generated/MatchReportClient.ts +++ b/packages/protocol/src/generated/MatchReportClient.ts @@ -3,4 +3,4 @@ /** * Non-sensitive producer metadata used to diagnose platform-specific alpha failures. */ -export type MatchReportClient = { platform: string, user_agent: string, }; +export type MatchReportClient = { platform: string; user_agent: string }; diff --git a/packages/protocol/src/generated/MatchReportCompatibility.ts b/packages/protocol/src/generated/MatchReportCompatibility.ts index 57502a9..0e55622 100644 --- a/packages/protocol/src/generated/MatchReportCompatibility.ts +++ b/packages/protocol/src/generated/MatchReportCompatibility.ts @@ -3,4 +3,10 @@ /** * Optional, privacy-safe hashes that let both peers prove identical native emulator inputs. */ -export type MatchReportCompatibility = { adapter: string, emulator_version: string | null, executable_sha256: string, core_sha256: string, content_sha256: string, }; +export type MatchReportCompatibility = { + adapter: string; + emulator_version: string | null; + executable_sha256: string; + core_sha256: string; + content_sha256: string; +}; diff --git a/packages/protocol/src/generated/MatchReportProbe.ts b/packages/protocol/src/generated/MatchReportProbe.ts index a2ee8e7..9a74243 100644 --- a/packages/protocol/src/generated/MatchReportProbe.ts +++ b/packages/protocol/src/generated/MatchReportProbe.ts @@ -7,4 +7,14 @@ import type { NatMappingState } from "./NatMappingState"; /** * Deterministic transport evidence from one side of an alpha match. */ -export type MatchReportProbe = { role: MatchReportRole, transport: MatchReportTransport, frames_sent: number, frames_received: number, transcript_checksum: string, elapsed_ms: number, nat: NatMappingState | null, candidate: MatchCandidateKind | null, punch_attempts: number | null, }; +export type MatchReportProbe = { + role: MatchReportRole; + transport: MatchReportTransport; + frames_sent: number; + frames_received: number; + transcript_checksum: string; + elapsed_ms: number; + nat: NatMappingState | null; + candidate: MatchCandidateKind | null; + punch_attempts: number | null; +}; diff --git a/packages/protocol/src/generated/MatchReportRoom.ts b/packages/protocol/src/generated/MatchReportRoom.ts index 8d3bf4a..b16a26f 100644 --- a/packages/protocol/src/generated/MatchReportRoom.ts +++ b/packages/protocol/src/generated/MatchReportRoom.ts @@ -4,4 +4,4 @@ import type { RoomState } from "./RoomState"; /** * Privacy-minimized room correlation included in an alpha report. */ -export type MatchReportRoom = { id: string, game_id: string, state: RoomState, }; +export type MatchReportRoom = { id: string; game_id: string; state: RoomState }; diff --git a/packages/protocol/src/generated/NativeRoutePolicyPayload.ts b/packages/protocol/src/generated/NativeRoutePolicyPayload.ts index 6b0de37..cdd50b8 100644 --- a/packages/protocol/src/generated/NativeRoutePolicyPayload.ts +++ b/packages/protocol/src/generated/NativeRoutePolicyPayload.ts @@ -4,4 +4,9 @@ import type { NativeRouteCapability } from "./NativeRouteCapability"; /** * Evidence-backed route decision. Insufficient samples always preserve the direct-LAN policy. */ -export type NativeRoutePolicyPayload = { route: NativeRouteCapability, reason: string, evidence_attempts: number, evidence_verified: number, }; +export type NativeRoutePolicyPayload = { + route: NativeRouteCapability; + reason: string; + evidence_attempts: number; + evidence_verified: number; +}; diff --git a/packages/protocol/src/generated/PresencePayload.ts b/packages/protocol/src/generated/PresencePayload.ts index a533baa..d640596 100644 --- a/packages/protocol/src/generated/PresencePayload.ts +++ b/packages/protocol/src/generated/PresencePayload.ts @@ -3,4 +3,10 @@ /** * Presence / latency update. Mirrors `diagnose_network` and `presence.update`. */ -export type PresencePayload = { user_id: string, rtt_ms: number, loss: number, jitter: number, relay_reachable: boolean, }; +export type PresencePayload = { + user_id: string; + rtt_ms: number; + loss: number; + jitter: number; + relay_reachable: boolean; +}; diff --git a/packages/protocol/src/generated/ProductEventName.ts b/packages/protocol/src/generated/ProductEventName.ts index 945f703..174e20c 100644 --- a/packages/protocol/src/generated/ProductEventName.ts +++ b/packages/protocol/src/generated/ProductEventName.ts @@ -3,4 +3,10 @@ /** * 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"; +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 index 318746b..f89c956 100644 --- a/packages/protocol/src/generated/ProductEventPayload.ts +++ b/packages/protocol/src/generated/ProductEventPayload.ts @@ -5,4 +5,10 @@ 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, }; +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/PublicCompatibilityCohort.ts b/packages/protocol/src/generated/PublicCompatibilityCohort.ts index 6c796a4..aab6dca 100644 --- a/packages/protocol/src/generated/PublicCompatibilityCohort.ts +++ b/packages/protocol/src/generated/PublicCompatibilityCohort.ts @@ -4,4 +4,13 @@ import type { NativeRouteCapability } from "./NativeRouteCapability"; /** * A k-anonymous compatibility cohort suitable for unauthenticated publication. */ -export type PublicCompatibilityCohort = { game_id: string, platform: string, adapter: string, emulator_version: string | null, transport: string, native_route: NativeRouteCapability, attempts: number, verified: number, }; +export type PublicCompatibilityCohort = { + game_id: string; + platform: string; + adapter: string; + emulator_version: string | null; + transport: string; + native_route: NativeRouteCapability; + attempts: number; + verified: number; +}; diff --git a/packages/protocol/src/generated/PublicCompatibilityPayload.ts b/packages/protocol/src/generated/PublicCompatibilityPayload.ts index edfa942..369a5fc 100644 --- a/packages/protocol/src/generated/PublicCompatibilityPayload.ts +++ b/packages/protocol/src/generated/PublicCompatibilityPayload.ts @@ -4,4 +4,9 @@ import type { PublicCompatibilityCohort } from "./PublicCompatibilityCohort"; /** * Public aggregate contract. Cohorts below `minimum_cohort_size` are omitted server-side. */ -export type PublicCompatibilityPayload = { schema_version: number, generated_at: string, minimum_cohort_size: number, cohorts: Array, }; +export type PublicCompatibilityPayload = { + schema_version: number; + generated_at: string; + minimum_cohort_size: number; + cohorts: Array; +}; diff --git a/packages/protocol/src/generated/ReadinessBlockCount.ts b/packages/protocol/src/generated/ReadinessBlockCount.ts index c7b25bb..04d017c 100644 --- a/packages/protocol/src/generated/ReadinessBlockCount.ts +++ b/packages/protocol/src/generated/ReadinessBlockCount.ts @@ -1,4 +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, }; +export type ReadinessBlockCount = { check: ReadinessCheckId; count: number }; diff --git a/packages/protocol/src/generated/ReadinessCheckId.ts b/packages/protocol/src/generated/ReadinessCheckId.ts index 138bd1b..af94dc4 100644 --- a/packages/protocol/src/generated/ReadinessCheckId.ts +++ b/packages/protocol/src/generated/ReadinessCheckId.ts @@ -3,4 +3,5 @@ /** * Stable readiness checks used for aggregate blocker analysis. */ -export type ReadinessCheckId = "desktop" | "control_plane" | "game_runtime" | "native_port" | "network"; +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 index 69679a8..94fa087 100644 --- a/packages/protocol/src/generated/RoomInvitePayload.ts +++ b/packages/protocol/src/generated/RoomInvitePayload.ts @@ -3,4 +3,4 @@ /** * Short-lived room invite. Only the creator receives the secret code. */ -export type RoomInvitePayload = { room_id: string, code: string, expires_at: string, }; +export type RoomInvitePayload = { room_id: string; code: string; expires_at: string }; diff --git a/packages/protocol/src/generated/RoomPayload.ts b/packages/protocol/src/generated/RoomPayload.ts index dda9de4..ea01842 100644 --- a/packages/protocol/src/generated/RoomPayload.ts +++ b/packages/protocol/src/generated/RoomPayload.ts @@ -4,4 +4,10 @@ import type { RoomState } from "./RoomState"; /** * Room snapshot pushed via `room.state`. */ -export type RoomPayload = { id: string, game_id: string, host_id: string, guest_id: string | null, state: RoomState, }; +export type RoomPayload = { + id: string; + game_id: string; + host_id: string; + guest_id: string | null; + state: RoomState; +}; diff --git a/packages/protocol/src/generated/RoomSnapshotPayload.ts b/packages/protocol/src/generated/RoomSnapshotPayload.ts index c9eedc9..ae5568e 100644 --- a/packages/protocol/src/generated/RoomSnapshotPayload.ts +++ b/packages/protocol/src/generated/RoomSnapshotPayload.ts @@ -7,4 +7,13 @@ 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, controller_ready_count: number, barrier: LaunchBarrierPayload, route: NativeRouteCapability, route_policy: NativeRoutePolicyPayload, }; +export type RoomSnapshotPayload = { + room: RoomPayload; + revision: number; + preflight_count: number; + compatibility_matched: boolean; + controller_ready_count: number; + barrier: LaunchBarrierPayload; + route: NativeRouteCapability; + route_policy: NativeRoutePolicyPayload; +}; diff --git a/packages/protocol/src/generated/RoomState.ts b/packages/protocol/src/generated/RoomState.ts index f24a382..528d39e 100644 --- a/packages/protocol/src/generated/RoomState.ts +++ b/packages/protocol/src/generated/RoomState.ts @@ -6,4 +6,5 @@ * AGENTS.md describes WAITING→CHALLENGING→CONNECTING→PLAYING→FINISHED|CANCELLED. * Rust payload is snake_case lowercase for the wire; `Ready` is kept for compat with ARCH's READY. */ -export type RoomState = "waiting" | "ready" | "challenging" | "connecting" | "playing" | "finished" | "cancelled"; +export type RoomState = + "waiting" | "ready" | "challenging" | "connecting" | "playing" | "finished" | "cancelled"; diff --git a/packages/protocol/src/generated/SessionAnswerPayload.ts b/packages/protocol/src/generated/SessionAnswerPayload.ts index d748db1..c63b2a1 100644 --- a/packages/protocol/src/generated/SessionAnswerPayload.ts +++ b/packages/protocol/src/generated/SessionAnswerPayload.ts @@ -3,4 +3,4 @@ /** * SDP answer relayed verbatim between authenticated room members. */ -export type SessionAnswerPayload = { room_id: string, sdp: string, }; +export type SessionAnswerPayload = { room_id: string; sdp: string }; diff --git a/packages/protocol/src/generated/SessionCandidatePayload.ts b/packages/protocol/src/generated/SessionCandidatePayload.ts index a43ec7e..8e3d1ac 100644 --- a/packages/protocol/src/generated/SessionCandidatePayload.ts +++ b/packages/protocol/src/generated/SessionCandidatePayload.ts @@ -3,4 +3,9 @@ /** * ICE candidate relayed verbatim between authenticated room members. */ -export type SessionCandidatePayload = { room_id: string, candidate: string, sdp_mid: string | null, sdp_mline_index: number | null, }; +export type SessionCandidatePayload = { + room_id: string; + candidate: string; + sdp_mid: string | null; + sdp_mline_index: number | null; +}; diff --git a/packages/protocol/src/generated/SessionOfferPayload.ts b/packages/protocol/src/generated/SessionOfferPayload.ts index 98aff87..f4c3b6e 100644 --- a/packages/protocol/src/generated/SessionOfferPayload.ts +++ b/packages/protocol/src/generated/SessionOfferPayload.ts @@ -3,4 +3,4 @@ /** * SDP offer relayed verbatim between authenticated room members. */ -export type SessionOfferPayload = { room_id: string, sdp: string, }; +export type SessionOfferPayload = { room_id: string; sdp: string };