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())}
+ />
+ createInvite.mutate()}
+ >
+ Create code
+
+ joinInvite.mutate()}
+ >
+ Join code
+
+
+
{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 && (
playableMatch.mutate()}
>
{playableMatch.isPending ? "Launching RetroArch…" : "Launch playable alpha"}
diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml
index e343c20..3fba713 100644
--- a/apps/server/Cargo.toml
+++ b/apps/server/Cargo.toml
@@ -29,6 +29,7 @@ dashmap = "6.2.1"
thiserror = "2.0.20"
opencade-protocol = { path = "../../packages/protocol" }
opencade-shared = { path = "../../packages/shared" }
+opencade-networking = { path = "../../packages/networking" }
[dev-dependencies]
futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] }
diff --git a/apps/server/migrations/006_community_alpha.sql b/apps/server/migrations/006_community_alpha.sql
new file mode 100644
index 0000000..38c65c4
--- /dev/null
+++ b/apps/server/migrations/006_community_alpha.sql
@@ -0,0 +1,52 @@
+-- Community alpha orchestration, compatibility handshakes, and privacy-safe evidence.
+
+CREATE TABLE room_invites (
+ code_hash TEXT PRIMARY KEY,
+ room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ consumed_by UUID REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_room_invites_room ON room_invites(room_id, expires_at);
+
+CREATE TABLE match_preflights (
+ room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ compatibility JSONB NOT NULL,
+ native_port_available BOOLEAN NOT NULL,
+ ready BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (room_id, user_id)
+);
+
+CREATE TABLE room_launch_barriers (
+ room_id UUID PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
+ launch_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE room_events (
+ revision BIGSERIAL PRIMARY KEY,
+ room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ event_type TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT room_events_type CHECK (event_type ~ '^[a-z0-9_.]{3,64}$')
+);
+
+CREATE INDEX idx_room_events_room_revision ON room_events(room_id, revision DESC);
+
+CREATE TABLE alpha_evidence (
+ digest TEXT PRIMARY KEY,
+ room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ submitted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL CHECK (role IN ('host', 'guest')),
+ kind TEXT NOT NULL CHECK (kind IN ('match', 'attempt_failure')),
+ payload JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (room_id, submitted_by, kind)
+);
+
+CREATE INDEX idx_alpha_evidence_room ON alpha_evidence(room_id, created_at);
diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs
index f12f4b6..d14a890 100644
--- a/apps/server/src/lib.rs
+++ b/apps/server/src/lib.rs
@@ -55,6 +55,31 @@ pub fn build_app(state: AppState) -> Router {
.route("/api/v1/lobbies/{game_id}", get(routes::lobbies::get_lobby))
.route("/api/v1/rooms", post(routes::rooms::create_room))
.route("/api/v1/rooms/{id}", get(routes::rooms::get_room))
+ .route(
+ "/api/v1/rooms/{id}/invite",
+ post(routes::alpha::create_invite),
+ )
+ .route("/api/v1/invites/join", post(routes::alpha::join_invite))
+ .route(
+ "/api/v1/rooms/{id}/preflight",
+ post(routes::alpha::submit_preflight),
+ )
+ .route(
+ "/api/v1/rooms/{id}/snapshot",
+ get(routes::alpha::room_snapshot),
+ )
+ .route(
+ "/api/v1/rooms/{id}/ready",
+ post(routes::alpha::ready_to_launch),
+ )
+ .route(
+ "/api/v1/alpha/evidence",
+ post(routes::alpha_evidence::submit_evidence),
+ )
+ .route(
+ "/api/v1/alpha/campaign",
+ get(routes::alpha_evidence::campaign_summary),
+ )
.route(
"/api/v1/rooms/{id}/accept",
post(routes::rooms::accept_room),
@@ -72,6 +97,10 @@ pub fn build_app(state: AppState) -> Router {
"/api/v1/rooms/{id}/relay-ticket",
post(routes::rooms::relay_ticket),
)
+ .route(
+ "/api/v1/rooms/{id}/native-tunnel-ticket",
+ post(routes::rooms::native_tunnel_ticket),
+ )
.route(
"/api/v1/rooms/{id}/launch-grant",
post(routes::rooms::create_launch_grant),
diff --git a/apps/server/src/routes/alpha.rs b/apps/server/src/routes/alpha.rs
new file mode 100644
index 0000000..3d9f389
--- /dev/null
+++ b/apps/server/src/routes/alpha.rs
@@ -0,0 +1,401 @@
+use axum::{Json, extract::Path, extract::State, http::StatusCode};
+use chrono::{DateTime, Duration, Utc};
+use opencade_protocol::{
+ Envelope, LaunchBarrierPayload, MatchPreflightPayload, MatchReportCompatibility,
+ NativeRouteCapability, RoomInvitePayload, RoomSnapshotPayload,
+};
+use serde::Deserialize;
+use serde_json::{Value, json};
+use sqlx::Row;
+use uuid::Uuid;
+
+use crate::{
+ authn::{AuthUser, hash_token},
+ error::AppError,
+ room_state::{RoomEvent, from_database, to_database, transition},
+ state::AppState,
+};
+
+use super::rooms::{notify_room_state, room_payload};
+
+const INVITE_TTL_MINUTES: i64 = 15;
+const LAUNCH_DELAY_SECONDS: i64 = 5;
+
+#[derive(Debug, Deserialize)]
+pub struct JoinInviteRequest {
+ code: String,
+}
+
+pub async fn create_invite(
+ State(state): State,
+ user: AuthUser,
+ Path(room_id): Path,
+) -> Result<(StatusCode, Json>), AppError> {
+ let host = sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS(SELECT 1 FROM rooms WHERE id = $1 AND host_user_id = $2 AND state = 'WAITING')",
+ )
+ .bind(room_id)
+ .bind(user.id)
+ .fetch_one(&state.pool)
+ .await?;
+ if !host {
+ return Err(AppError::Forbidden(
+ "only the host of a waiting room can create an invite".into(),
+ ));
+ }
+
+ let code = Uuid::new_v4().simple().to_string()[..10].to_ascii_uppercase();
+ let expires_at = Utc::now() + Duration::minutes(INVITE_TTL_MINUTES);
+ sqlx::query(
+ "INSERT INTO room_invites (code_hash, room_id, created_by, expires_at)
+ VALUES ($1, $2, $3, $4)",
+ )
+ .bind(hash_token(&code))
+ .bind(room_id)
+ .bind(user.id)
+ .bind(expires_at)
+ .execute(&state.pool)
+ .await?;
+ sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, 'room.invite.created')")
+ .bind(room_id)
+ .execute(&state.pool)
+ .await?;
+
+ let payload = RoomInvitePayload {
+ room_id: room_id.to_string(),
+ code,
+ expires_at,
+ };
+ Ok((
+ StatusCode::CREATED,
+ Json(Envelope::new("room.invite.created", json!(payload))),
+ ))
+}
+
+pub async fn join_invite(
+ State(state): State,
+ user: AuthUser,
+ Json(request): Json,
+) -> Result>, AppError> {
+ let code = request.code.trim().to_ascii_uppercase();
+ if code.len() != 10 || !code.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(AppError::BadRequest("invite code is invalid".into()));
+ }
+
+ let mut transaction = state.pool.begin().await?;
+ let row = sqlx::query(
+ "SELECT room_id FROM room_invites
+ WHERE code_hash = $1 AND expires_at > now() AND consumed_at IS NULL
+ FOR UPDATE",
+ )
+ .bind(hash_token(&code))
+ .fetch_optional(&mut *transaction)
+ .await?
+ .ok_or_else(|| AppError::NotFound("invite is invalid, expired, or already used".into()))?;
+ let room_id: Uuid = row
+ .try_get("room_id")
+ .map_err(|_| AppError::Internal("invalid invite record".into()))?;
+ let room =
+ sqlx::query("SELECT host_user_id, game_id, state FROM rooms WHERE id = $1 FOR UPDATE")
+ .bind(room_id)
+ .fetch_one(&mut *transaction)
+ .await?;
+ let host_id: Uuid = room
+ .try_get("host_user_id")
+ .map_err(|_| AppError::Internal("invalid room record".into()))?;
+ let game_id: String = room
+ .try_get("game_id")
+ .map_err(|_| AppError::Internal("invalid room record".into()))?;
+ if host_id == user.id {
+ return Err(AppError::Forbidden(
+ "room hosts cannot redeem their own invite".into(),
+ ));
+ }
+ let current = from_database(
+ room.try_get::("state")
+ .map_err(|_| AppError::Internal("invalid room record".into()))?
+ .as_str(),
+ )
+ .map_err(AppError::Internal)?;
+ let next = transition(current, RoomEvent::Accept)
+ .map_err(|error| AppError::Conflict(error.to_string()))?;
+ let members =
+ sqlx::query_scalar::<_, i64>("SELECT count(*) FROM room_members WHERE room_id = $1")
+ .bind(room_id)
+ .fetch_one(&mut *transaction)
+ .await?;
+ if members >= 2 {
+ return Err(AppError::Conflict("room is full".into()));
+ }
+ sqlx::query(
+ "UPDATE rooms SET state = 'CANCELLED'
+ WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING' AND id <> $3",
+ )
+ .bind(user.id)
+ .bind(&game_id)
+ .bind(room_id)
+ .execute(&mut *transaction)
+ .await?;
+ sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES ($1, $2)")
+ .bind(room_id)
+ .bind(user.id)
+ .execute(&mut *transaction)
+ .await?;
+ sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1")
+ .bind(room_id)
+ .bind(to_database(&next))
+ .execute(&mut *transaction)
+ .await?;
+ sqlx::query(
+ "UPDATE room_invites SET consumed_at = now(), consumed_by = $2 WHERE code_hash = $1",
+ )
+ .bind(hash_token(&code))
+ .bind(user.id)
+ .execute(&mut *transaction)
+ .await?;
+ record_event(&mut transaction, room_id, "room.invite.joined").await?;
+ transaction.commit().await?;
+
+ notify_room_state(&state, room_id, user.id).await
+}
+
+pub async fn submit_preflight(
+ State(state): State,
+ user: AuthUser,
+ Path(room_id): Path,
+ Json(payload): Json,
+) -> Result>, AppError> {
+ if payload.room_id != room_id.to_string() {
+ return Err(AppError::BadRequest(
+ "preflight room does not match path".into(),
+ ));
+ }
+ validate_compatibility(&payload.compatibility)?;
+ ensure_member(&state, room_id, user.id).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())
+ ON CONFLICT (room_id, user_id) DO UPDATE SET
+ compatibility = EXCLUDED.compatibility,
+ native_port_available = EXCLUDED.native_port_available,
+ ready = FALSE,
+ updated_at = now()",
+ )
+ .bind(room_id)
+ .bind(user.id)
+ .bind(json!(payload.compatibility))
+ .bind(payload.native_port_available)
+ .execute(&state.pool)
+ .await?;
+ sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, 'match.preflight')")
+ .bind(room_id)
+ .execute(&state.pool)
+ .await?;
+ let snapshot = build_snapshot(&state, room_id, user.id).await?;
+ notify_members(&state, room_id, "room.snapshot", json!(snapshot)).await?;
+ Ok(Json(Envelope::new(
+ "match.preflight.accepted",
+ json!(snapshot),
+ )))
+}
+
+pub async fn room_snapshot(
+ State(state): State,
+ user: AuthUser,
+ Path(room_id): Path,
+) -> Result>, AppError> {
+ let snapshot = build_snapshot(&state, room_id, user.id).await?;
+ Ok(Json(Envelope::new("room.snapshot", json!(snapshot))))
+}
+
+pub async fn ready_to_launch(
+ State(state): State,
+ user: AuthUser,
+ Path(room_id): Path,
+) -> Result>, AppError> {
+ 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 {
+ 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",
+ )
+ .bind(room_id)
+ .bind(user.id)
+ .execute(&state.pool)
+ .await?;
+ let ready_count = sqlx::query_scalar::<_, i64>(
+ "SELECT count(*) FROM match_preflights WHERE room_id = $1 AND ready = TRUE",
+ )
+ .bind(room_id)
+ .fetch_one(&state.pool)
+ .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",
+ )
+ .bind(room_id)
+ .bind(LAUNCH_DELAY_SECONDS as f64)
+ .execute(&state.pool)
+ .await?;
+ }
+ sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, 'match.launch.ready')")
+ .bind(room_id)
+ .execute(&state.pool)
+ .await?;
+ let snapshot = build_snapshot(&state, room_id, user.id).await?;
+ notify_members(&state, room_id, "room.snapshot", json!(snapshot)).await?;
+ Ok(Json(Envelope::new("match.launch.ready", json!(snapshot))))
+}
+
+async fn build_snapshot(
+ state: &AppState,
+ room_id: Uuid,
+ user_id: Uuid,
+) -> Result {
+ let room = room_payload(state, room_id, user_id).await?;
+ let rows = sqlx::query(
+ "SELECT compatibility, native_port_available, ready
+ FROM match_preflights WHERE room_id = $1 ORDER BY user_id",
+ )
+ .bind(room_id)
+ .fetch_all(&state.pool)
+ .await?;
+ let compatibilities = rows
+ .iter()
+ .filter_map(|row| row.try_get::("compatibility").ok())
+ .collect::>();
+ let compatibility_matched = compatibilities.len() == 2
+ && compatibilities[0] == compatibilities[1]
+ && rows.iter().all(|row| {
+ row.try_get::("native_port_available")
+ .unwrap_or(false)
+ });
+ let ready_count = rows
+ .iter()
+ .filter(|row| row.try_get::("ready").unwrap_or(false))
+ .count();
+ let launch_at = sqlx::query_scalar::<_, DateTime>(
+ "SELECT launch_at FROM room_launch_barriers WHERE room_id = $1",
+ )
+ .bind(room_id)
+ .fetch_optional(&state.pool)
+ .await?;
+ let revision = sqlx::query_scalar::<_, i64>(
+ "SELECT COALESCE(max(revision), 0) FROM room_events WHERE room_id = $1",
+ )
+ .bind(room_id)
+ .fetch_one(&state.pool)
+ .await?;
+ Ok(RoomSnapshotPayload {
+ room,
+ revision,
+ preflight_count: u8::try_from(rows.len()).unwrap_or(u8::MAX),
+ compatibility_matched,
+ barrier: LaunchBarrierPayload {
+ room_id: room_id.to_string(),
+ ready_count: u8::try_from(ready_count).unwrap_or(u8::MAX),
+ required_count: 2,
+ launch_at,
+ },
+ route: NativeRouteCapability::DirectLan,
+ })
+}
+
+async fn 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)",
+ )
+ .bind(room_id)
+ .bind(user_id)
+ .fetch_one(&state.pool)
+ .await?;
+ if member {
+ Ok(())
+ } else {
+ Err(AppError::Forbidden("not a room member".into()))
+ }
+}
+
+async fn notify_members(
+ state: &AppState,
+ room_id: Uuid,
+ event: &str,
+ payload: Value,
+) -> Result<(), AppError> {
+ let members =
+ sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM room_members WHERE room_id = $1")
+ .bind(room_id)
+ .fetch_all(&state.pool)
+ .await?;
+ for member in members {
+ state.notify_user(member, event, payload.clone());
+ }
+ Ok(())
+}
+
+async fn record_event(
+ transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
+ room_id: Uuid,
+ event: &str,
+) -> Result<(), AppError> {
+ sqlx::query("INSERT INTO room_events (room_id, event_type) VALUES ($1, $2)")
+ .bind(room_id)
+ .bind(event)
+ .execute(&mut **transaction)
+ .await?;
+ Ok(())
+}
+
+fn validate_compatibility(value: &MatchReportCompatibility) -> Result<(), AppError> {
+ let valid_hash = |hash: &str| {
+ hash.len() == 64
+ && hash
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ };
+ if value.adapter != "retroarch_fbneo"
+ || value
+ .emulator_version
+ .as_ref()
+ .is_some_and(|version| version.trim().is_empty() || version.len() > 64)
+ || !valid_hash(&value.executable_sha256)
+ || !valid_hash(&value.core_sha256)
+ || !valid_hash(&value.content_sha256)
+ {
+ return Err(AppError::BadRequest(
+ "compatibility preflight is invalid".into(),
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn compatibility() -> MatchReportCompatibility {
+ MatchReportCompatibility {
+ adapter: "retroarch_fbneo".into(),
+ emulator_version: Some("1.22.0".into()),
+ executable_sha256: "a".repeat(64),
+ core_sha256: "b".repeat(64),
+ content_sha256: "c".repeat(64),
+ }
+ }
+
+ #[test]
+ fn compatibility_is_fail_closed() {
+ assert!(validate_compatibility(&compatibility()).is_ok());
+ let mut invalid = compatibility();
+ invalid.content_sha256 = "C:\\ROMs\\sfiii3.zip".into();
+ assert!(validate_compatibility(&invalid).is_err());
+ }
+}
diff --git a/apps/server/src/routes/alpha_evidence.rs b/apps/server/src/routes/alpha_evidence.rs
new file mode 100644
index 0000000..e4da35d
--- /dev/null
+++ b/apps/server/src/routes/alpha_evidence.rs
@@ -0,0 +1,205 @@
+use axum::{Json, extract::State, http::StatusCode};
+use opencade_networking::summarize_campaign_evidence;
+use opencade_protocol::{AlphaFailureReport, Envelope, MatchReport, MatchReportRole};
+use serde::Deserialize;
+use serde_json::{Value, json};
+use sha2::{Digest, Sha256};
+use uuid::Uuid;
+
+use crate::{authn::AuthUser, error::AppError, state::AppState};
+
+#[derive(Debug, Deserialize)]
+pub struct EvidenceRequest {
+ evidence: Value,
+}
+
+pub async fn submit_evidence(
+ State(state): State,
+ user: AuthUser,
+ Json(request): Json,
+) -> Result<(StatusCode, Json>), AppError> {
+ let 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) {
+ return Err(AppError::BadRequest(
+ "evidence exceeds limits or contains private fields".into(),
+ ));
+ }
+ let (room_id, role, kind) = decode_identity(&request.evidence)?;
+ let expected_role = member_role(&state, room_id, user.id).await?;
+ if role != expected_role {
+ return Err(AppError::Forbidden(
+ "evidence role does not match room membership".into(),
+ ));
+ }
+ let digest = hex::encode(Sha256::digest(&encoded));
+ let inserted = sqlx::query(
+ "INSERT INTO alpha_evidence (digest, room_id, submitted_by, role, kind, payload)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT DO NOTHING",
+ )
+ .bind(&digest)
+ .bind(room_id)
+ .bind(user.id)
+ .bind(role_label(role))
+ .bind(kind)
+ .bind(request.evidence)
+ .execute(&state.pool)
+ .await?;
+ reject_conflicting_duplicate(&state, room_id, user.id, kind, &digest, &inserted).await?;
+ let duplicate = inserted.rows_affected() == 0;
+ Ok((
+ if duplicate {
+ StatusCode::OK
+ } else {
+ StatusCode::CREATED
+ },
+ Json(Envelope::new(
+ "alpha.evidence.accepted",
+ json!({ "digest": digest, "duplicate": duplicate }),
+ )),
+ ))
+}
+
+pub async fn campaign_summary(
+ State(state): State,
+ _user: AuthUser,
+) -> Result>, AppError> {
+ let values = sqlx::query_scalar::<_, Value>(
+ "SELECT payload FROM alpha_evidence ORDER BY created_at DESC LIMIT 1000",
+ )
+ .fetch_all(&state.pool)
+ .await?;
+ let mut matches = Vec::new();
+ let mut failures = Vec::new();
+ for value in values {
+ if value.get("kind").is_none() {
+ if let Ok(report) = serde_json::from_value::(value) {
+ matches.push(report);
+ }
+ } else if let Ok(report) = serde_json::from_value::(value) {
+ failures.push(report);
+ }
+ }
+ Ok(Json(Envelope::new(
+ "alpha.campaign.summary",
+ json!(summarize_campaign_evidence(&matches, &failures)),
+ )))
+}
+
+fn decode_identity(value: &Value) -> Result<(Uuid, MatchReportRole, &'static str), 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"))
+ }
+ 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",
+ ))
+ }
+ _ => Err(AppError::BadRequest("evidence kind is invalid".into())),
+ }
+}
+
+async fn reject_conflicting_duplicate(
+ state: &AppState,
+ room_id: Uuid,
+ user_id: Uuid,
+ kind: &str,
+ digest: &str,
+ inserted: &sqlx::postgres::PgQueryResult,
+) -> Result<(), AppError> {
+ if inserted.rows_affected() == 1 {
+ return Ok(());
+ }
+ let existing = sqlx::query_scalar::<_, String>(
+ "SELECT digest FROM alpha_evidence
+ WHERE room_id = $1 AND submitted_by = $2 AND kind = $3",
+ )
+ .bind(room_id)
+ .bind(user_id)
+ .bind(kind)
+ .fetch_optional(&state.pool)
+ .await?;
+ if existing.as_deref().is_some_and(|value| value != digest) {
+ return Err(AppError::Conflict(
+ "different evidence already exists for this attempt and role".into(),
+ ));
+ }
+ Ok(())
+}
+
+async fn member_role(
+ state: &AppState,
+ room_id: Uuid,
+ user_id: Uuid,
+) -> Result {
+ let host = sqlx::query_scalar::<_, Uuid>(
+ "SELECT rooms.host_user_id FROM rooms
+ JOIN room_members ON room_members.room_id = rooms.id
+ WHERE rooms.id = $1 AND room_members.user_id = $2",
+ )
+ .bind(room_id)
+ .bind(user_id)
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| AppError::Forbidden("not a room member".into()))?;
+ Ok(if host == user_id {
+ MatchReportRole::Host
+ } else {
+ MatchReportRole::Guest
+ })
+}
+
+fn contains_private_key(value: &Value) -> bool {
+ const FORBIDDEN: &[&str] = &[
+ "token",
+ "session_token",
+ "local_user_id",
+ "peer_user_id",
+ "host_id",
+ "guest_id",
+ "endpoint",
+ "nonce",
+ "path",
+ "rom_path",
+ ];
+ match value {
+ Value::Object(map) => map
+ .iter()
+ .any(|(key, value)| FORBIDDEN.contains(&key.as_str()) || contains_private_key(value)),
+ Value::Array(values) => values.iter().any(contains_private_key),
+ _ => false,
+ }
+}
+
+fn parse_room_id(value: &str) -> Result {
+ Uuid::parse_str(value).map_err(|_| AppError::BadRequest("evidence room id is invalid".into()))
+}
+
+fn role_label(role: MatchReportRole) -> &'static str {
+ match role {
+ MatchReportRole::Host => "host",
+ MatchReportRole::Guest => "guest",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn privacy_filter_walks_nested_evidence() {
+ assert!(!contains_private_key(&json!({"room": {"id": "safe"}})));
+ assert!(contains_private_key(
+ &json!({"probe": {"endpoint": "127.0.0.1"}})
+ ));
+ assert!(contains_private_key(&json!([{"session_token": "secret"}])));
+ }
+}
diff --git a/apps/server/src/routes/mod.rs b/apps/server/src/routes/mod.rs
index 87fb054..1e56cfc 100644
--- a/apps/server/src/routes/mod.rs
+++ b/apps/server/src/routes/mod.rs
@@ -1,3 +1,5 @@
+pub mod alpha;
+pub mod alpha_evidence;
pub mod auth;
pub mod challenges;
pub mod games;
diff --git a/apps/server/src/routes/rooms.rs b/apps/server/src/routes/rooms.rs
index ca6a8a0..4de0374 100644
--- a/apps/server/src/routes/rooms.rs
+++ b/apps/server/src/routes/rooms.rs
@@ -2,7 +2,7 @@ use std::sync::atomic::Ordering;
use axum::{Json, extract::Path, extract::State, http::StatusCode};
use opencade_protocol::{Envelope, RoomPayload, RoomState};
-use opencade_shared::RelayTicket;
+use opencade_shared::{RelayCapability, RelayTicket};
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::{Postgres, Row, Transaction};
@@ -34,6 +34,8 @@ pub struct CreateLaunchGrantRequest {
local_endpoint: String,
peer_endpoint: String,
input_delay_frames: u8,
+ #[serde(default)]
+ campaign_mode: bool,
}
#[derive(Debug, Deserialize)]
@@ -45,7 +47,7 @@ fn default_max_players() -> i32 {
2
}
-async fn room_payload(
+pub(crate) async fn room_payload(
state: &AppState,
room_id: Uuid,
requesting_user: Uuid,
@@ -313,6 +315,55 @@ pub async fn relay_ticket(
)))
}
+pub async fn native_tunnel_ticket(
+ State(state): State,
+ user: AuthUser,
+ Path(id): Path,
+) -> Result>, AppError> {
+ let relay_url = state
+ .config
+ .relay_url
+ .as_deref()
+ .ok_or_else(|| AppError::Conflict("native tunnel is not configured".into()))?;
+ let relay_secret = state
+ .config
+ .relay_secret
+ .as_deref()
+ .ok_or_else(|| AppError::Internal("relay signing is not configured".into()))?;
+ let eligible = sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS(
+ SELECT 1 FROM room_members
+ JOIN rooms ON rooms.id = room_members.room_id
+ WHERE room_members.room_id = $1 AND room_members.user_id = $2
+ AND rooms.state IN ('CONNECTING', 'PLAYING')
+ AND (SELECT count(*) FROM match_preflights
+ WHERE room_id = $1 AND ready = TRUE) = 2
+ )",
+ )
+ .bind(id)
+ .bind(user.id)
+ .fetch_one(&state.pool)
+ .await?;
+ if !eligible {
+ return Err(AppError::Forbidden(
+ "native tunnel tickets require a ready two-peer room".into(),
+ ));
+ }
+ let expires_at = chrono::Utc::now().timestamp() + 120;
+ let ticket = RelayTicket::issue_scoped(
+ relay_secret.as_bytes(),
+ &id.to_string(),
+ &user.id.to_string(),
+ expires_at,
+ RelayCapability::NativeTcpTunnel,
+ )
+ .map_err(|_| AppError::Internal("failed to issue native tunnel ticket".into()))?;
+ Ok(Json(Envelope::new(
+ "native_tcp_tunnel.ticket",
+ json!({ "relay_url": relay_url, "ticket": ticket }),
+ )))
+}
+
pub async fn create_launch_grant(
State(state): State,
user: AuthUser,
@@ -348,6 +399,24 @@ pub async fn create_launch_grant(
"launch grants are issued only while a room is connecting".into(),
));
}
+ if request.campaign_mode {
+ let ready = sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS(
+ SELECT 1 FROM room_launch_barriers barrier
+ WHERE barrier.room_id = $1 AND barrier.launch_at <= now()
+ AND (SELECT count(*) FROM match_preflights
+ WHERE room_id = $1 AND ready = TRUE) = 2
+ )",
+ )
+ .bind(id)
+ .fetch_one(&mut *transaction)
+ .await?;
+ if !ready {
+ return Err(AppError::Conflict(
+ "community alpha launch barrier is not ready".into(),
+ ));
+ }
+ }
let row = sqlx::query(
"SELECT rooms.game_id, peer.user_id AS peer_user_id
FROM rooms
@@ -617,7 +686,7 @@ async fn confirm_native_exit(
notify_room_state(state, room_id, user.id).await
}
-async fn notify_room_state(
+pub(crate) async fn notify_room_state(
state: &AppState,
room_id: Uuid,
requesting_user: Uuid,
diff --git a/apps/server/tests/api_flow.rs b/apps/server/tests/api_flow.rs
index 3a7f6ed..55bf82e 100644
--- a/apps/server/tests/api_flow.rs
+++ b/apps/server/tests/api_flow.rs
@@ -473,6 +473,199 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) {
server.abort();
}
+#[sqlx::test]
+async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool) {
+ sqlx::migrate!("./migrations")
+ .run(&pool)
+ .await
+ .expect("migrations should succeed");
+ let mut config = Config::for_test();
+ config.relay_url = Some("wss://relay.example/relay".into());
+ config.relay_secret = Some("integration-relay-secret-at-least-32-bytes".into());
+ let app = build_app(AppState::new(pool, config));
+ let host_token = register(&app, "campaign_host").await;
+ let guest_token = register(&app, "campaign_guest").await;
+ let observer_token = register(&app, "campaign_observer").await;
+
+ let (_, room) = request(
+ &app,
+ Method::POST,
+ "/api/v1/rooms",
+ Some(&host_token),
+ Some(json!({ "game_id": "sfiii3" })),
+ )
+ .await;
+ let room_id = room.payload["id"].as_str().expect("room id");
+ let (invite_status, invite) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/invite"),
+ Some(&host_token),
+ None,
+ )
+ .await;
+ assert_eq!(invite_status, StatusCode::CREATED);
+ let code = invite.payload["code"].as_str().expect("invite code");
+ assert_eq!(code.len(), 10);
+ let (join_status, joined) = request(
+ &app,
+ Method::POST,
+ "/api/v1/invites/join",
+ Some(&guest_token),
+ Some(json!({ "code": code.to_ascii_lowercase() })),
+ )
+ .await;
+ assert_eq!(join_status, StatusCode::OK);
+ assert_eq!(joined.payload["state"], "connecting");
+ let (replay_status, _) = request(
+ &app,
+ Method::POST,
+ "/api/v1/invites/join",
+ Some(&observer_token),
+ Some(json!({ "code": code })),
+ )
+ .await;
+ assert_eq!(replay_status, StatusCode::NOT_FOUND);
+
+ let compatibility = json!({
+ "adapter": "retroarch_fbneo",
+ "emulator_version": "1.22.0",
+ "executable_sha256": "a".repeat(64),
+ "core_sha256": "b".repeat(64),
+ "content_sha256": "c".repeat(64)
+ });
+ for token in [&host_token, &guest_token] {
+ let (status, _) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/preflight"),
+ Some(token),
+ Some(json!({
+ "room_id": room_id,
+ "compatibility": compatibility,
+ "native_port_available": true
+ })),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+ }
+ for token in [&host_token, &guest_token] {
+ let (status, _) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/ready"),
+ Some(token),
+ None,
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+ }
+ let (snapshot_status, snapshot) = request(
+ &app,
+ Method::GET,
+ &format!("/api/v1/rooms/{room_id}/snapshot"),
+ Some(&host_token),
+ None,
+ )
+ .await;
+ assert_eq!(snapshot_status, StatusCode::OK);
+ assert_eq!(snapshot.payload["preflight_count"], 2);
+ assert_eq!(snapshot.payload["compatibility_matched"], true);
+ assert_eq!(snapshot.payload["barrier"]["ready_count"], 2);
+ assert!(snapshot.payload["barrier"]["launch_at"].is_string());
+ let launch_at = snapshot.payload["barrier"]["launch_at"].clone();
+ let (duplicate_ready_status, duplicate_ready) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/ready"),
+ Some(&host_token),
+ None,
+ )
+ .await;
+ assert_eq!(duplicate_ready_status, StatusCode::OK);
+ assert_eq!(duplicate_ready.payload["barrier"]["launch_at"], launch_at);
+
+ let (tunnel_status, tunnel) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/native-tunnel-ticket"),
+ Some(&host_token),
+ None,
+ )
+ .await;
+ assert_eq!(tunnel_status, StatusCode::OK);
+ assert_eq!(tunnel.payload["ticket"]["capability"], "native_tcp_tunnel");
+
+ let failure = json!({
+ "schema_version": 1,
+ "kind": "attempt_failure",
+ "exported_at": chrono::Utc::now(),
+ "room": { "id": room_id, "game_id": "sfiii3", "state": "connecting" },
+ "role": "host",
+ "stage": "direct_udp",
+ "error_code": "direct_udp_timeout",
+ "transport": "direct_udp",
+ "client": { "platform": "macos", "user_agent": "integration-test" }
+ });
+ let (evidence_status, evidence) = request(
+ &app,
+ Method::POST,
+ "/api/v1/alpha/evidence",
+ Some(&host_token),
+ Some(json!({ "evidence": failure.clone() })),
+ )
+ .await;
+ assert_eq!(evidence_status, StatusCode::CREATED);
+ assert_eq!(evidence.payload["duplicate"], false);
+ let (duplicate_status, duplicate) = request(
+ &app,
+ Method::POST,
+ "/api/v1/alpha/evidence",
+ Some(&host_token),
+ Some(json!({ "evidence": failure.clone() })),
+ )
+ .await;
+ assert_eq!(duplicate_status, StatusCode::OK);
+ assert_eq!(duplicate.payload["duplicate"], true);
+ let mut conflicting_failure = failure;
+ conflicting_failure["error_code"] = json!("different_failure");
+ let (conflict_status, _) = request(
+ &app,
+ Method::POST,
+ "/api/v1/alpha/evidence",
+ Some(&host_token),
+ Some(json!({ "evidence": conflicting_failure })),
+ )
+ .await;
+ assert_eq!(conflict_status, StatusCode::CONFLICT);
+
+ let (privacy_status, _) = request(
+ &app,
+ Method::POST,
+ "/api/v1/alpha/evidence",
+ Some(&host_token),
+ Some(json!({
+ "evidence": {
+ "kind": "attempt_failure",
+ "endpoint": "192.168.1.20:55435"
+ }
+ })),
+ )
+ .await;
+ assert_eq!(privacy_status, StatusCode::BAD_REQUEST);
+ let (campaign_status, campaign) = request(
+ &app,
+ Method::GET,
+ "/api/v1/alpha/campaign",
+ Some(&host_token),
+ None,
+ )
+ .await;
+ assert_eq!(campaign_status, StatusCode::OK);
+ assert_eq!(campaign.payload["attempts"], 1);
+ assert_eq!(campaign.payload["failed"], 1);
+}
+
#[sqlx::test]
async fn logout_revokes_the_current_session(pool: PgPool) {
sqlx::migrate!("./migrations")
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d891533..73942c3 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -302,9 +302,11 @@ Consequences:
- MVP has **three active containers**: `db` (`postgres:16-alpine`), `opencade-server` on `8080`,
and `opencade-relay` on `8081`; peers still prefer direct UDP.
- No Redis, no separate signaling service, and no message queue in MVP. Presence remains in the
- control plane; bounded readiness frames alone can use the relay fallback.
-- The relay accepts only short-lived room-member tickets issued by the control plane, limits rooms
- to two users and bounded frames, and does not relay RetroArch's native netplay socket.
+ control plane. Readiness frames use the probe capability; an isolated, bounded native TCP tunnel
+ capability exists but remains route-policy gated pending physical validation.
+- The relay accepts only short-lived room-member tickets issued by the control plane and limits
+ rooms to two users and bounded frames. Capability-scoped tickets keep readiness probes isolated
+ from the experimental native TCP tunnel described in ADR 0005.
---
@@ -657,8 +659,9 @@ fixes the connection to the signed room/user, permits at most two distinct users
64-message queues, and rejects frames above 64 KiB. Text frames must be valid versioned envelopes
whose payload room matches the signed room; bounded binary probe frames remain opaque.
-This relay carries OpenCade readiness frames. It is not TURN and does not relay RetroArch's native
-TCP netplay connection.
+Probe tickets carry only OpenCade readiness frames. Native-tunnel tickets use a separate signed
+capability and bounded TCP-over-WebSocket framing; the match coordinator does not select that route
+until ADR 0005's physical validation gate passes. The relay is not TURN.
### 10.4 Latency — RTT / loss / jitter
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index d8379ea..e98bdb5 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -38,6 +38,13 @@ Updated 2026-08-25.
safe host/guest argument construction, and SHA-256 executable/core/content fingerprints.
- Server-derived one-use native launch grants, exactly-two-player rooms, a deterministic client
match coordinator, supervised/reaped child processes, and launch/exit-backed room transitions.
+- One-use room invite codes, reconnect-safe revisioned room snapshots, fail-closed two-peer
+ compatibility preflights, and an idempotent synchronized launch barrier.
+- A community-alpha dashboard backed by authenticated, privacy-filtered, conflict-safe evidence
+ uploads and a bounded server-side campaign summary.
+- A capability-scoped native TCP tunnel primitive with bounded binary frames and a real loopback
+ relay integration test. It is separately ticketed from readiness probes and remains disabled as
+ an automatic gameplay route pending physical two-host validation.
- A truthful route gate: same-LAN host candidates may launch the RetroArch TCP alpha; UDP reflexive
and relay results are readiness-only until a native transport bridge exists.
- Strict playable-report verification that requires matching, well-formed native compatibility
@@ -65,3 +72,5 @@ Updated 2026-08-25.
- Production installer packaging/signing, friends/chat/rankings/replays, or a public MVP release.
- Ten two-machine LAN matches and a 20-person community alpha; these require external testers and
real Windows hosts. Use `docs/alpha/LAN_TEST.md` to collect that evidence.
+- Automatic selection of the native TCP tunnel as a gameplay route. ADR 0005 requires physical
+ two-host proof before enabling that fallback in the match coordinator.
diff --git a/docs/adr/0005-community-alpha-orchestration.md b/docs/adr/0005-community-alpha-orchestration.md
new file mode 100644
index 0000000..85647f9
--- /dev/null
+++ b/docs/adr/0005-community-alpha-orchestration.md
@@ -0,0 +1,31 @@
+# ADR 0005: Community-alpha orchestration and native tunnel boundary
+
+Status: accepted, 2026-08-25.
+
+## Context
+
+The experimental RetroArch path needed a repeatable two-person loop instead of manual timing and
+download-only reports. Reconnects also needed a durable authority for compatibility and launch
+state. A TCP-over-WebSocket path is technically possible, but an automated loopback test does not
+prove RetroArch behavior across two physical hosts.
+
+## Decision
+
+- The server owns one-use invite redemption, compatibility preflights, monotonic room snapshots,
+ and a single persisted `launch_at` barrier.
+- Both members must submit matching executable, core, and content fingerprints and confirm their
+ native port before the server issues a campaign launch grant.
+- Clients resume active rooms from durable snapshots and ignore stale revisions.
+- Evidence upload is authenticated, role-bound, size-bounded, privacy-filtered, idempotent for an
+ identical report, and rejects a conflicting report for the same room/user/kind.
+- Relay tickets carry a cryptographically signed capability. Readiness probes and native TCP
+ tunnel traffic cannot share a relay bucket or ticket.
+- The native TCP tunnel implementation is shipped as a gated capability, not selected by the
+ match coordinator until two-host evidence demonstrates safe RetroArch operation.
+
+## Consequences
+
+The alpha loop is deterministic and recoverable without claiming unearned network compatibility.
+Enabling the native tunnel later is a route-policy change backed by evidence, not a new transport
+implementation. Campaign summaries read only the newest 1,000 evidence records so the dashboard
+cannot grow into an unbounded request.
diff --git a/packages/networking/Cargo.toml b/packages/networking/Cargo.toml
index 179c8c6..15d1bfc 100644
--- a/packages/networking/Cargo.toml
+++ b/packages/networking/Cargo.toml
@@ -15,7 +15,7 @@ opencade-shared = { path = "../shared" }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
thiserror = "2.0.20"
-tokio = { version = "1.53.1", features = ["macros", "net", "rt", "sync", "time"] }
+tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
uuid = { version = "1.25.0", features = ["v4"] }
futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] }
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-native-roots"] }
diff --git a/packages/networking/src/lib.rs b/packages/networking/src/lib.rs
index 7d049e5..fc2d321 100644
--- a/packages/networking/src/lib.rs
+++ b/packages/networking/src/lib.rs
@@ -9,6 +9,7 @@ mod probe;
mod relay;
mod report;
mod stun;
+mod tcp_tunnel;
mod traversal;
pub use latency::LatencyMetrics;
@@ -27,6 +28,7 @@ pub use report::{
verify_playable_match_reports,
};
pub use stun::{NatMapping, StunObservation, discover_reflexive_address};
+pub use tcp_tunnel::{NATIVE_TUNNEL_FRAME_BYTES, run_native_tcp_tunnel};
pub use traversal::{HolePunchConfig, HolePunchReport, punch_hole};
pub const MAX_INPUT_BYTES: usize = 256;
pub const INPUT_QUEUE_CAPACITY: usize = 120;
diff --git a/packages/networking/src/relay.rs b/packages/networking/src/relay.rs
index 933a25a..3c02de1 100644
--- a/packages/networking/src/relay.rs
+++ b/packages/networking/src/relay.rs
@@ -21,6 +21,7 @@ impl RelayPeer {
.append_pair("room_id", &ticket.room_id)
.append_pair("user_id", &ticket.user_id)
.append_pair("expires_at", &ticket.expires_at.to_string())
+ .append_pair("capability", ticket.capability.as_str())
.append_pair("signature", &ticket.signature);
let (stream, _) = connect_async(url.as_str())
.await
diff --git a/packages/networking/src/tcp_tunnel.rs b/packages/networking/src/tcp_tunnel.rs
new file mode 100644
index 0000000..a2f7d7f
--- /dev/null
+++ b/packages/networking/src/tcp_tunnel.rs
@@ -0,0 +1,106 @@
+use futures_util::{SinkExt, StreamExt};
+use opencade_shared::RelayTicket;
+use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ net::TcpStream,
+};
+use tokio_tungstenite::{connect_async, tungstenite::Message};
+use url::Url;
+
+use crate::TransportError;
+
+pub const NATIVE_TUNNEL_FRAME_BYTES: usize = 16 * 1024;
+
+/// Bridge one already-authorized local TCP stream over the signed two-peer WebSocket relay.
+///
+/// This is intentionally distinct from the deterministic UDP readiness probe. Callers must obtain
+/// an explicit native-tunnel ticket and provide a loopback-side stream owned by the emulator
+/// adapter. Frames are bounded and WebSocket backpressure is awaited rather than buffered without
+/// limit.
+pub async fn run_native_tcp_tunnel(
+ local: TcpStream,
+ relay_url: &str,
+ ticket: &RelayTicket,
+) -> Result<(), TransportError> {
+ let mut url = Url::parse(relay_url)
+ .map_err(|_| TransportError::Relay("native tunnel URL is invalid".into()))?;
+ if !matches!(url.scheme(), "ws" | "wss") {
+ return Err(TransportError::Relay(
+ "native tunnel URL must use ws or wss".into(),
+ ));
+ }
+ url.query_pairs_mut()
+ .append_pair("room_id", &ticket.room_id)
+ .append_pair("user_id", &ticket.user_id)
+ .append_pair("expires_at", &ticket.expires_at.to_string())
+ .append_pair("capability", ticket.capability.as_str())
+ .append_pair("signature", &ticket.signature);
+ let (websocket, _) = connect_async(url.as_str())
+ .await
+ .map_err(|_| TransportError::Relay("native tunnel connection failed".into()))?;
+ let (mut ws_write, mut ws_read) = websocket.split();
+ let (mut tcp_read, mut tcp_write) = local.into_split();
+ let tcp_to_relay = async {
+ let mut buffer = vec![0_u8; NATIVE_TUNNEL_FRAME_BYTES];
+ loop {
+ let count = tcp_read
+ .read(&mut buffer)
+ .await
+ .map_err(|_| TransportError::Relay("native TCP read failed".into()))?;
+ if count == 0 {
+ ws_write
+ .send(Message::Close(None))
+ .await
+ .map_err(|_| TransportError::Relay("native tunnel close failed".into()))?;
+ return Ok::<(), TransportError>(());
+ }
+ ws_write
+ .send(Message::Binary(buffer[..count].to_vec().into()))
+ .await
+ .map_err(|_| TransportError::Relay("native tunnel send failed".into()))?;
+ }
+ };
+ let relay_to_tcp = async {
+ loop {
+ match ws_read.next().await {
+ Some(Ok(Message::Binary(payload)))
+ if payload.len() <= NATIVE_TUNNEL_FRAME_BYTES =>
+ {
+ tcp_write
+ .write_all(&payload)
+ .await
+ .map_err(|_| TransportError::Relay("native TCP write failed".into()))?;
+ }
+ Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {}
+ Some(Ok(Message::Close(_))) | None => return Ok::<(), TransportError>(()),
+ Some(Ok(Message::Binary(_))) => {
+ return Err(TransportError::Relay(
+ "native tunnel frame exceeds 16 KiB".into(),
+ ));
+ }
+ Some(Ok(Message::Text(_))) | Some(Ok(Message::Frame(_))) => {
+ return Err(TransportError::Relay(
+ "native tunnel received an invalid frame".into(),
+ ));
+ }
+ Some(Err(_)) => {
+ return Err(TransportError::Relay("native tunnel receive failed".into()));
+ }
+ }
+ }
+ };
+ tokio::select! {
+ result = tcp_to_relay => result,
+ result = relay_to_tcp => result,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tunnel_frames_are_strictly_bounded() {
+ assert_eq!(NATIVE_TUNNEL_FRAME_BYTES, 16 * 1024);
+ }
+}
diff --git a/packages/protocol/src/generated/LaunchBarrierPayload.ts b/packages/protocol/src/generated/LaunchBarrierPayload.ts
new file mode 100644
index 0000000..e6bdb03
--- /dev/null
+++ b/packages/protocol/src/generated/LaunchBarrierPayload.ts
@@ -0,0 +1,11 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Server-authoritative launch barrier shared by both room participants.
+ */
+export type LaunchBarrierPayload = {
+ room_id: string;
+ ready_count: number;
+ required_count: number;
+ launch_at: string | null;
+};
diff --git a/packages/protocol/src/generated/MatchPreflightPayload.ts b/packages/protocol/src/generated/MatchPreflightPayload.ts
new file mode 100644
index 0000000..c216a5a
--- /dev/null
+++ b/packages/protocol/src/generated/MatchPreflightPayload.ts
@@ -0,0 +1,11 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { MatchReportCompatibility } from "./MatchReportCompatibility";
+
+/**
+ * Privacy-safe compatibility handshake performed before either emulator is launched.
+ */
+export type MatchPreflightPayload = {
+ room_id: string;
+ compatibility: MatchReportCompatibility;
+ native_port_available: boolean;
+};
diff --git a/packages/protocol/src/generated/NativeRouteCapability.ts b/packages/protocol/src/generated/NativeRouteCapability.ts
new file mode 100644
index 0000000..fd29c01
--- /dev/null
+++ b/packages/protocol/src/generated/NativeRouteCapability.ts
@@ -0,0 +1,6 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Native route capabilities are explicit: a readiness probe never implies emulator traffic.
+ */
+export type NativeRouteCapability = "direct_lan" | "tcp_tunnel";
diff --git a/packages/protocol/src/generated/RoomInvitePayload.ts b/packages/protocol/src/generated/RoomInvitePayload.ts
new file mode 100644
index 0000000..94fa087
--- /dev/null
+++ b/packages/protocol/src/generated/RoomInvitePayload.ts
@@ -0,0 +1,6 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Short-lived room invite. Only the creator receives the secret code.
+ */
+export type RoomInvitePayload = { room_id: string; code: string; expires_at: string };
diff --git a/packages/protocol/src/generated/RoomSnapshotPayload.ts b/packages/protocol/src/generated/RoomSnapshotPayload.ts
new file mode 100644
index 0000000..ffa1cba
--- /dev/null
+++ b/packages/protocol/src/generated/RoomSnapshotPayload.ts
@@ -0,0 +1,16 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { LaunchBarrierPayload } from "./LaunchBarrierPayload";
+import type { NativeRouteCapability } from "./NativeRouteCapability";
+import type { RoomPayload } from "./RoomPayload";
+
+/**
+ * Durable room snapshot used after initial connection and every reconnect.
+ */
+export type RoomSnapshotPayload = {
+ room: RoomPayload;
+ revision: number;
+ preflight_count: number;
+ compatibility_matched: boolean;
+ barrier: LaunchBarrierPayload;
+ route: NativeRouteCapability;
+};
diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts
index 77cf2c3..b7c15e2 100644
--- a/packages/protocol/src/index.ts
+++ b/packages/protocol/src/index.ts
@@ -11,6 +11,11 @@ export type {
MatchReport,
MatchReportClient,
MatchReportCompatibility,
+ MatchPreflightPayload,
+ LaunchBarrierPayload,
+ NativeRouteCapability,
+ RoomInvitePayload,
+ RoomSnapshotPayload,
AlphaEvidenceKind,
AlphaFailureReport,
AlphaFailureStage,
diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs
index 9d6140a..5b5616e 100644
--- a/packages/protocol/src/lib.rs
+++ b/packages/protocol/src/lib.rs
@@ -270,6 +270,56 @@ pub struct MatchReportCompatibility {
pub content_sha256: String,
}
+/// Native route capabilities are explicit: a readiness probe never implies emulator traffic.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/NativeRouteCapability.ts")]
+#[serde(rename_all = "snake_case")]
+pub enum NativeRouteCapability {
+ DirectLan,
+ TcpTunnel,
+}
+
+/// Privacy-safe compatibility handshake performed before either emulator is launched.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/MatchPreflightPayload.ts")]
+pub struct MatchPreflightPayload {
+ pub room_id: String,
+ pub compatibility: MatchReportCompatibility,
+ pub native_port_available: bool,
+}
+
+/// Server-authoritative launch barrier shared by both room participants.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/LaunchBarrierPayload.ts")]
+pub struct LaunchBarrierPayload {
+ pub room_id: String,
+ pub ready_count: u8,
+ pub required_count: u8,
+ pub launch_at: Option>,
+}
+
+/// Durable room snapshot used after initial connection and every reconnect.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/RoomSnapshotPayload.ts")]
+pub struct RoomSnapshotPayload {
+ pub room: RoomPayload,
+ #[ts(type = "number")]
+ pub revision: i64,
+ pub preflight_count: u8,
+ pub compatibility_matched: bool,
+ pub barrier: LaunchBarrierPayload,
+ pub route: NativeRouteCapability,
+}
+
+/// Short-lived room invite. Only the creator receives the secret code.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/RoomInvitePayload.ts")]
+pub struct RoomInvitePayload {
+ pub room_id: String,
+ pub code: String,
+ pub expires_at: DateTime,
+}
+
/// Stable stages for privacy-minimized evidence from an abandoned alpha attempt.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../src/generated/AlphaFailureStage.ts")]
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index ddf6d1f..c02e406 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -7,6 +7,11 @@ import type { MatchProbeCompletedPayload } from "./generated/MatchProbeCompleted
import type { MatchReport } from "./generated/MatchReport.js";
import type { MatchReportClient } from "./generated/MatchReportClient.js";
import type { MatchReportCompatibility } from "./generated/MatchReportCompatibility.js";
+import type { MatchPreflightPayload } from "./generated/MatchPreflightPayload.js";
+import type { LaunchBarrierPayload } from "./generated/LaunchBarrierPayload.js";
+import type { NativeRouteCapability } from "./generated/NativeRouteCapability.js";
+import type { RoomInvitePayload } from "./generated/RoomInvitePayload.js";
+import type { RoomSnapshotPayload } from "./generated/RoomSnapshotPayload.js";
import type { AlphaEvidenceKind } from "./generated/AlphaEvidenceKind.js";
import type { AlphaFailureReport } from "./generated/AlphaFailureReport.js";
import type { AlphaFailureStage } from "./generated/AlphaFailureStage.js";
@@ -28,6 +33,11 @@ export type {
MatchReport,
MatchReportClient,
MatchReportCompatibility,
+ MatchPreflightPayload,
+ LaunchBarrierPayload,
+ NativeRouteCapability,
+ RoomInvitePayload,
+ RoomSnapshotPayload,
AlphaEvidenceKind,
AlphaFailureReport,
AlphaFailureStage,
@@ -71,6 +81,7 @@ export type EnvelopeType =
| "match.probe.completed"
| "match.probe.completed.relayed"
| "room.state"
+ | "room.snapshot"
| "connection.hello"
| "error"
| "ping"
@@ -93,6 +104,7 @@ const KNOWN_ENVELOPE_TYPES: ReadonlySet = new Set([
"match.probe.completed",
"match.probe.completed.relayed",
"room.state",
+ "room.snapshot",
"connection.hello",
"error",
"ping",
diff --git a/packages/shared/src/lib.rs b/packages/shared/src/lib.rs
index b286e50..9721840 100644
--- a/packages/shared/src/lib.rs
+++ b/packages/shared/src/lib.rs
@@ -4,11 +4,28 @@ use sha2::Sha256;
pub const MAX_RELAY_TICKET_LIFETIME_SECONDS: i64 = 300;
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RelayCapability {
+ Probe,
+ NativeTcpTunnel,
+}
+
+impl RelayCapability {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Probe => "probe",
+ Self::NativeTcpTunnel => "native_tcp_tunnel",
+ }
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayTicket {
pub room_id: String,
pub user_id: String,
pub expires_at: i64,
+ pub capability: RelayCapability,
pub signature: String,
}
@@ -32,13 +49,24 @@ impl RelayTicket {
room_id: &str,
user_id: &str,
expires_at: i64,
+ ) -> Result {
+ Self::issue_scoped(secret, room_id, user_id, expires_at, RelayCapability::Probe)
+ }
+
+ pub fn issue_scoped(
+ secret: &[u8],
+ room_id: &str,
+ user_id: &str,
+ expires_at: i64,
+ capability: RelayCapability,
) -> Result {
validate_inputs(secret, room_id, user_id)?;
- let signature = signature(secret, room_id, user_id, expires_at)?;
+ let signature = signature(secret, room_id, user_id, expires_at, capability)?;
Ok(Self {
room_id: room_id.to_owned(),
user_id: user_id.to_owned(),
expires_at,
+ capability,
signature: hex::encode(signature),
})
}
@@ -58,6 +86,7 @@ impl RelayTicket {
&self.room_id,
&self.user_id,
self.expires_at,
+ self.capability,
));
mac.verify_slice(&provided)
.map_err(|_| RelayTicketError::InvalidSignature)
@@ -83,17 +112,24 @@ fn signature(
room_id: &str,
user_id: &str,
expires_at: i64,
+ capability: RelayCapability,
) -> Result, RelayTicketError> {
let mut mac = relay_mac(secret)?;
- mac.update(&canonical_claims(room_id, user_id, expires_at));
+ mac.update(&canonical_claims(room_id, user_id, expires_at, capability));
Ok(mac.finalize().into_bytes().to_vec())
}
-fn canonical_claims(room_id: &str, user_id: &str, expires_at: i64) -> Vec {
+fn canonical_claims(
+ room_id: &str,
+ user_id: &str,
+ expires_at: i64,
+ capability: RelayCapability,
+) -> Vec {
format!(
- "{}:{room_id}:{}:{user_id}:{expires_at}",
+ "{}:{room_id}:{}:{user_id}:{expires_at}:{}",
room_id.len(),
- user_id.len()
+ user_id.len(),
+ capability.as_str()
)
.into_bytes()
}
@@ -128,4 +164,22 @@ mod tests {
Err(RelayTicketError::ExcessiveLifetime)
);
}
+
+ #[test]
+ fn capability_is_cryptographically_scoped() {
+ let mut ticket = RelayTicket::issue_scoped(
+ SECRET,
+ "room",
+ "user",
+ 1_120,
+ RelayCapability::NativeTcpTunnel,
+ )
+ .expect("ticket");
+ assert_eq!(ticket.verify(SECRET, 1_000), Ok(()));
+ ticket.capability = RelayCapability::Probe;
+ assert_eq!(
+ ticket.verify(SECRET, 1_000),
+ Err(RelayTicketError::InvalidSignature)
+ );
+ }
}
diff --git a/services/relay/Cargo.toml b/services/relay/Cargo.toml
index 4367dc3..3e8ea65 100644
--- a/services/relay/Cargo.toml
+++ b/services/relay/Cargo.toml
@@ -22,3 +22,4 @@ uuid = { version = "1.25.0", features = ["v4"] }
[dev-dependencies]
tokio-tungstenite = "0.30.0"
+opencade-networking = { path = "../../packages/networking" }
diff --git a/services/relay/src/main.rs b/services/relay/src/main.rs
index 49c0214..d2fb474 100644
--- a/services/relay/src/main.rs
+++ b/services/relay/src/main.rs
@@ -10,7 +10,7 @@ use axum::{
};
use dashmap::DashMap;
use futures::{SinkExt, StreamExt};
-use opencade_shared::RelayTicket;
+use opencade_shared::{RelayCapability, RelayTicket};
use serde_json::{Value, json};
use std::{
env,
@@ -140,8 +140,9 @@ async fn relay_ws_handler(
}
let room_id = ticket.room_id;
let user_id = ticket.user_id;
+ let capability = ticket.capability;
info!(room_id = %room_id, "authorized relay upgrade");
- ws.on_upgrade(move |socket| handle_socket(socket, state, room_id, user_id))
+ ws.on_upgrade(move |socket| handle_socket(socket, state, room_id, user_id, capability))
.into_response()
}
@@ -160,13 +161,20 @@ fn broadcast_message(state: &SharedState, room_id: &str, msg: Message, skip_id:
sent
}
-async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, user_id: String) {
+async fn handle_socket(
+ socket: WebSocket,
+ state: SharedState,
+ room_id: String,
+ user_id: String,
+ capability: RelayCapability,
+) {
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 room = state.rooms.entry(room_id.clone()).or_default();
+ let room = state.rooms.entry(room_key.clone()).or_default();
if room.len() >= 2 && !room.contains_key(&user_id) {
warn!(room_id = %room_id, "relay room is full");
return;
@@ -183,7 +191,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u
info!(
peer_id = %id,
room_id = %room_id,
- peers_in_room = state.rooms.get(&room_id).map(|r| r.len()).unwrap_or(0),
+ peers_in_room = state.rooms.get(&room_key).map(|r| r.len()).unwrap_or(0),
"peer inserted"
);
@@ -237,19 +245,23 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u
continue;
}
let sent =
- broadcast_message(&state, &room_id, Message::Text(text.clone()), id);
+ broadcast_message(&state, &room_key, Message::Text(text.clone()), id);
info!(peer_id = %id, room_id = %room_id, sent = sent, "broadcast envelope");
} else {
warn!(peer_id = %id, room_id = %room_id, "non-envelope relay text rejected");
}
}
Message::Binary(bin) => {
- if bin.len() > 64 * 1024 {
+ let maximum = match capability {
+ RelayCapability::Probe => 64 * 1024,
+ RelayCapability::NativeTcpTunnel => 16 * 1024,
+ };
+ if bin.len() > maximum {
warn!(peer_id = %id, room_id = %room_id, "oversized relay frame rejected");
continue;
}
let sent =
- broadcast_message(&state, &room_id, Message::Binary(bin.clone()), id);
+ broadcast_message(&state, &room_key, Message::Binary(bin.clone()), id);
info!(peer_id = %id, sent = sent, "broadcast binary");
}
Message::Ping(payload) => {
@@ -270,7 +282,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, u
}
}
- if let Some(room) = state.rooms.get(&room_id)
+ if let Some(room) = state.rooms.get(&room_key)
&& room
.get(&user_id)
.is_some_and(|peer| peer.connection_id == id)
@@ -345,6 +357,7 @@ async fn main() -> Result<(), Box> {
mod tests {
use super::*;
use futures::{SinkExt, StreamExt};
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_tungstenite::{connect_async, tungstenite::Message as ClientMessage};
const SECRET: &str = "relay-integration-secret-at-least-32-bytes";
@@ -362,8 +375,12 @@ mod tests {
fn ticket_url(address: SocketAddr, ticket: &RelayTicket) -> String {
format!(
- "ws://{address}/relay?room_id={}&user_id={}&expires_at={}&signature={}",
- ticket.room_id, ticket.user_id, ticket.expires_at, ticket.signature
+ "ws://{address}/relay?room_id={}&user_id={}&expires_at={}&capability={}&signature={}",
+ ticket.room_id,
+ ticket.user_id,
+ ticket.expires_at,
+ ticket.capability.as_str(),
+ ticket.signature
)
}
@@ -412,4 +429,79 @@ mod tests {
assert!(connect_async(ticket_url(address, &tampered)).await.is_err());
server.abort();
}
+
+ async fn tcp_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("TCP listener");
+ let address = listener.local_addr().expect("TCP address");
+ let (client, accepted) =
+ tokio::join!(tokio::net::TcpStream::connect(address), listener.accept());
+ (
+ client.expect("TCP connect"),
+ accepted.expect("TCP accept").0,
+ )
+ }
+
+ #[tokio::test]
+ async fn scoped_native_tunnel_carries_bounded_tcp_stream_on_loopback() {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("relay listener");
+ let address = listener.local_addr().expect("relay address");
+ let server = tokio::spawn(async move {
+ axum::serve(listener, build_relay_app(state()))
+ .await
+ .expect("relay server");
+ });
+ let now = i64::try_from(
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("clock")
+ .as_secs(),
+ )
+ .expect("timestamp");
+ let host_ticket = RelayTicket::issue_scoped(
+ SECRET.as_bytes(),
+ "native-room",
+ "host",
+ now + 120,
+ RelayCapability::NativeTcpTunnel,
+ )
+ .expect("host tunnel ticket");
+ let guest_ticket = RelayTicket::issue_scoped(
+ SECRET.as_bytes(),
+ "native-room",
+ "guest",
+ now + 120,
+ RelayCapability::NativeTcpTunnel,
+ )
+ .expect("guest tunnel ticket");
+ let relay_url = format!("ws://{address}/relay");
+ let (mut host_app, host_tunnel) = tcp_pair().await;
+ let (mut guest_app, guest_tunnel) = tcp_pair().await;
+ let host_url = relay_url.clone();
+ let host = tokio::spawn(async move {
+ opencade_networking::run_native_tcp_tunnel(host_tunnel, &host_url, &host_ticket).await
+ });
+ let guest = tokio::spawn(async move {
+ opencade_networking::run_native_tcp_tunnel(guest_tunnel, &relay_url, &guest_ticket)
+ .await
+ });
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+ let payload = vec![0x5a; 40 * 1024];
+ host_app.write_all(&payload).await.expect("TCP write");
+ let mut received = vec![0_u8; payload.len()];
+ tokio::time::timeout(
+ std::time::Duration::from_secs(2),
+ guest_app.read_exact(&mut received),
+ )
+ .await
+ .expect("tunnel timeout")
+ .expect("TCP read");
+ assert_eq!(received, payload);
+ host.abort();
+ guest.abort();
+ server.abort();
+ }
}
From a23584119525f2e31aaacc87f9c16ccbe2f90504 Mon Sep 17 00:00:00 2001
From: Oleksii
Date: Tue, 25 Aug 2026 19:07:35 -0300
Subject: [PATCH 2/7] feat: gate lobbies on match readiness
---
apps/client/src/components/MatchReadiness.tsx | 137 +++++++++++++++
apps/client/src/lib/readiness.test.ts | 50 ++++++
apps/client/src/lib/readiness.ts | 159 ++++++++++++++++++
apps/client/src/routes/Games.tsx | 18 +-
apps/client/src/styles.css | 128 ++++++++++++++
docs/IMPLEMENTATION_STATUS.md | 2 +
6 files changed, 491 insertions(+), 3 deletions(-)
create mode 100644 apps/client/src/components/MatchReadiness.tsx
create mode 100644 apps/client/src/lib/readiness.test.ts
create mode 100644 apps/client/src/lib/readiness.ts
diff --git a/apps/client/src/components/MatchReadiness.tsx b/apps/client/src/components/MatchReadiness.tsx
new file mode 100644
index 0000000..cf1ef15
--- /dev/null
+++ b/apps/client/src/components/MatchReadiness.tsx
@@ -0,0 +1,137 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import type { Game } from "../lib/api";
+import { isDesktopRuntime, retroarchPreflight, runNetworkTest } from "../lib/native";
+import { assessMatchReadiness, type ReadinessState } from "../lib/readiness";
+
+type Props = {
+ game: Game;
+ onBack: () => void;
+ onContinue: () => void;
+};
+
+export default function MatchReadiness({ game, onBack, onContinue }: Props) {
+ const desktop = isDesktopRuntime();
+ const [notice, setNotice] = useState("");
+ const preflight = useQuery({
+ queryKey: ["match-readiness", "preflight", game.id],
+ queryFn: () => retroarchPreflight(game.id),
+ enabled: desktop,
+ retry: false,
+ });
+ const network = useQuery({
+ queryKey: ["match-readiness", "network"],
+ queryFn: runNetworkTest,
+ enabled: desktop,
+ retry: false,
+ staleTime: 30_000,
+ });
+ const assessment = assessMatchReadiness({
+ desktop,
+ controlPlaneReady: true,
+ preflightStatus: queryStatus(desktop, preflight.status),
+ preflight: preflight.data,
+ networkStatus: queryStatus(desktop, network.status),
+ network: network.data,
+ });
+ const continueToLobby = () => {
+ if (assessment.canContinue) {
+ onContinue();
+ return;
+ }
+ setNotice("Resolve the blocked requirements before entering the lobby.");
+ };
+ const retry = async () => {
+ if (!desktop) {
+ setNotice("Open the desktop client to run native readiness checks.");
+ return;
+ }
+ setNotice("Running the readiness checks again…");
+ await Promise.all([preflight.refetch(), network.refetch()]);
+ setNotice("Readiness checks updated.");
+ };
+
+ return (
+
+
+ ← Game catalog
+
+
+
+
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) => (
+
+
+ {stateSymbol(check.state)}
+
+
+
{check.title}
+
{check.detail}
+
+
+ {stateLabel(check.state)}
+ {!check.required && " · advisory"}
+
+
+ ))}
+
+
+ {notice ||
+ (assessment.canContinue
+ ? "This machine is ready to enter the lobby."
+ : "Blocked checks include a specific recovery action.")}
+
+
+ void retry()}>
+ Run checks again
+
+
+ Enter lobby
+
+
+
+ );
+}
+
+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) => (
-
onSelect(game.id)}>
+ onSelect(game)}>
{game.name.slice(0, 2).toUpperCase()}
diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css
index 0f5a5cb..7a29db9 100644
--- a/apps/client/src/styles.css
+++ b/apps/client/src/styles.css
@@ -384,6 +384,113 @@ input {
.match-actions .secondary {
min-height: 44px;
}
+.readiness {
+ max-width: 960px;
+}
+.readiness-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 32px;
+ margin-bottom: 32px;
+}
+.readiness-intro {
+ max-width: 65ch;
+ margin: 16px 0 0;
+ color: #b4b5ba;
+ font-size: 16px;
+ line-height: 1.5;
+}
+.readiness-progress {
+ min-width: 176px;
+ color: #f4b942;
+ font-size: 12px;
+ line-height: 1.5;
+ text-align: right;
+}
+.readiness-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+.readiness-check {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 24px;
+ min-height: 96px;
+ padding: 24px;
+ background: #141518;
+ border-left: 4px solid #606166;
+}
+.readiness-check.ready {
+ border-color: #8bd49c;
+}
+.readiness-check.warning,
+.readiness-check.pending {
+ border-color: #f4b942;
+}
+.readiness-check.blocked {
+ border-color: #ff8e86;
+}
+.readiness-symbol {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ place-items: center;
+ background: #24252a;
+ color: #f4f0e8;
+ font-size: 24px;
+ font-weight: 800;
+}
+.readiness-copy h3 {
+ margin: 0 0 8px;
+ font-size: 24px;
+ font-weight: 800;
+ line-height: 1.25;
+}
+.readiness-copy p {
+ max-width: 65ch;
+ margin: 0;
+ color: #b4b5ba;
+ font-size: 12px;
+ line-height: 1.5;
+}
+.readiness-state {
+ color: #b4b5ba;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.5;
+ text-align: right;
+}
+.readiness-check.ready .readiness-state {
+ color: #8bd49c;
+}
+.readiness-check.warning .readiness-state,
+.readiness-check.pending .readiness-state {
+ color: #f4b942;
+}
+.readiness-check.blocked .readiness-state {
+ color: #ff8e86;
+}
+.readiness-notice {
+ min-height: 24px;
+ margin: 24px 0 16px;
+ color: #b4b5ba;
+ font-size: 16px;
+ line-height: 1.5;
+}
+.readiness-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 16px;
+}
+.readiness-actions button {
+ min-height: 48px;
+}
@media (max-width: 760px) {
.auth-layout {
grid-template-columns: 1fr;
@@ -403,6 +510,27 @@ input {
align-items: flex-start;
flex-direction: column;
}
+ .readiness-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 16px;
+ }
+ .readiness-progress {
+ text-align: left;
+ }
+ .readiness-check {
+ grid-template-columns: 48px 1fr;
+ gap: 16px;
+ padding: 16px;
+ }
+ .readiness-state {
+ grid-column: 2;
+ text-align: left;
+ }
+ .readiness-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
}
@media (prefers-reduced-motion: reduce) {
* {
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index e98bdb5..4b776e4 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -42,6 +42,8 @@ Updated 2026-08-25.
compatibility preflights, and an idempotent synchronized launch barrier.
- A community-alpha dashboard backed by authenticated, privacy-filtered, conflict-safe evidence
uploads and a bounded server-side campaign summary.
+- A reusable match-readiness gate that validates the desktop runtime, control plane, local game
+ runtime, native port, and advisory network fallback before a player enters a lobby.
- 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.
From fee479db0e8a5a3a0d8b8b58eea1e989b99225f1 Mon Sep 17 00:00:00 2001
From: Oleksii
Date: Tue, 25 Aug 2026 19:23:28 -0300
Subject: [PATCH 3/7] feat: add privacy-safe product telemetry
---
apps/client/src/components/MatchReadiness.tsx | 13 +-
.../src/components/TelemetryConsent.tsx | 45 +++
apps/client/src/lib/api.ts | 10 +
apps/client/src/lib/telemetry.test.ts | 46 +++
apps/client/src/lib/telemetry.ts | 80 +++++
apps/client/src/routes/Games.tsx | 12 +-
apps/client/src/routes/Lobby.tsx | 8 +-
apps/client/src/styles.css | 51 +++
.../migrations/007_product_telemetry.sql | 17 +
apps/server/src/lib.rs | 8 +
apps/server/src/routes/mod.rs | 1 +
apps/server/src/routes/telemetry.rs | 312 ++++++++++++++++++
apps/server/tests/api_flow.rs | 207 ++++++++++++
docs/IMPLEMENTATION_STATUS.md | 3 +
docs/PRODUCT_TELEMETRY.md | 44 +++
.../src/generated/ActivationSummaryPayload.ts | 16 +
.../src/generated/ProductEventName.ts | 7 +
.../src/generated/ProductEventPayload.ts | 14 +
.../src/generated/ReadinessBlockCount.ts | 4 +
.../src/generated/ReadinessCheckId.ts | 7 +
packages/protocol/src/index.ts | 5 +
packages/protocol/src/lib.rs | 80 +++++
packages/protocol/src/messages.ts | 10 +
23 files changed, 997 insertions(+), 3 deletions(-)
create mode 100644 apps/client/src/components/TelemetryConsent.tsx
create mode 100644 apps/client/src/lib/telemetry.test.ts
create mode 100644 apps/client/src/lib/telemetry.ts
create mode 100644 apps/server/migrations/007_product_telemetry.sql
create mode 100644 apps/server/src/routes/telemetry.rs
create mode 100644 docs/PRODUCT_TELEMETRY.md
create mode 100644 packages/protocol/src/generated/ActivationSummaryPayload.ts
create mode 100644 packages/protocol/src/generated/ProductEventName.ts
create mode 100644 packages/protocol/src/generated/ProductEventPayload.ts
create mode 100644 packages/protocol/src/generated/ReadinessBlockCount.ts
create mode 100644 packages/protocol/src/generated/ReadinessCheckId.ts
diff --git a/apps/client/src/components/MatchReadiness.tsx b/apps/client/src/components/MatchReadiness.tsx
index cf1ef15..ced08b8 100644
--- a/apps/client/src/components/MatchReadiness.tsx
+++ b/apps/client/src/components/MatchReadiness.tsx
@@ -3,14 +3,16 @@ import { useQuery } from "@tanstack/react-query";
import type { Game } from "../lib/api";
import { isDesktopRuntime, retroarchPreflight, runNetworkTest } from "../lib/native";
import { assessMatchReadiness, type ReadinessState } from "../lib/readiness";
+import { trackProductEvent } from "../lib/telemetry";
type Props = {
+ token: string;
game: Game;
onBack: () => void;
onContinue: () => void;
};
-export default function MatchReadiness({ game, onBack, onContinue }: Props) {
+export default function MatchReadiness({ token, game, onBack, onContinue }: Props) {
const desktop = isDesktopRuntime();
const [notice, setNotice] = useState("");
const preflight = useQuery({
@@ -36,9 +38,18 @@ export default function MatchReadiness({ game, onBack, onContinue }: Props) {
});
const continueToLobby = () => {
if (assessment.canContinue) {
+ void trackProductEvent(token, "readiness_completed", game.id).catch(() => undefined);
onContinue();
return;
}
+ const blockedChecks = assessment.checks
+ .filter((check) => check.required && check.state === "blocked")
+ .map((check) => check.id);
+ if (blockedChecks.length > 0) {
+ void trackProductEvent(token, "readiness_blocked", game.id, blockedChecks).catch(
+ () => undefined
+ );
+ }
setNotice("Resolve the blocked requirements before entering the lobby.");
};
const retry = async () => {
diff --git a/apps/client/src/components/TelemetryConsent.tsx b/apps/client/src/components/TelemetryConsent.tsx
new file mode 100644
index 0000000..d3925fd
--- /dev/null
+++ b/apps/client/src/components/TelemetryConsent.tsx
@@ -0,0 +1,45 @@
+import { useState } from "react";
+import { getTelemetryConsent, setTelemetryConsent } from "../lib/telemetry";
+
+export default function TelemetryConsent() {
+ const [consent, setConsent] = useState(() => getTelemetryConsent());
+ const choose = (next: boolean) => {
+ if (setTelemetryConsent(next)) setConsent(next);
+ };
+
+ return (
+
+
+
Privacy control
+
Anonymous product telemetry
+
+ Share game selection and readiness outcomes to help improve the match setup. OpenFight
+ never sends usernames, ROM paths, network endpoints, or error text through telemetry.
+
+
+
+ choose(true)}
+ >
+ Share outcomes
+
+ choose(false)}
+ >
+ Don’t share
+
+
+
+ {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 (
-
+
OF
OpenCade
@@ -175,7 +185,12 @@ export default function App() {
{connection}
{user.username}
- void logout()}>
+ void logout()}
+ >
Sign out
@@ -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])}
@@ -79,6 +77,15 @@ function GameCatalog({ games, onSelect }: { games: Game[]; onSelect: (game: Game
);
}
+function availabilityLabel(
+ query: { isPending: boolean; isError: boolean; data?: { available: boolean } } | undefined
+): string {
+ if (!query || query.isPending) return "checking local files…";
+ if (query.isError) return "scan failed — select to retry";
+ if (!query.data) return "scan unavailable";
+ return query.data.available ? "installed" : "ROM not detected";
+}
+
function StatusCard({ title, detail }: { title: string; detail: string }) {
return (
diff --git a/apps/client/src/routes/Lobby.tsx b/apps/client/src/routes/Lobby.tsx
index 20e8541..b181112 100644
--- a/apps/client/src/routes/Lobby.tsx
+++ b/apps/client/src/routes/Lobby.tsx
@@ -18,6 +18,8 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props)
const [lobbyRoom, setLobbyRoom] = useState(null);
const [inviteCode, setInviteCode] = useState("");
const [joinCode, setJoinCode] = useState("");
+ const [responsePending, setResponsePending] = useState(null);
+ const [responseError, setResponseError] = useState("");
const lobbyTracked = useRef(false);
useEffect(() => {
let active = true;
@@ -74,11 +76,19 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props)
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) => {
- const result = accept
- ? await api.acceptChallenge(token, item.id)
- : await api.declineChallenge(token, item.id);
- await queryClient.invalidateQueries({ queryKey: ["challenges"] });
- if (accept) onMatch(result.room_id);
+ setResponsePending(item.id);
+ setResponseError("");
+ try {
+ const result = accept
+ ? await api.acceptChallenge(token, item.id)
+ : await api.declineChallenge(token, item.id);
+ await queryClient.invalidateQueries({ queryKey: ["challenges"] });
+ if (accept) onMatch(result.room_id);
+ } catch (error) {
+ setResponseError(error instanceof Error ? error.message : "Challenge response failed");
+ } finally {
+ setResponsePending(null);
+ }
};
return (
@@ -121,6 +131,20 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props)
+ {(lobby.isError || incoming.isError || responseError) && (
+
+ {responseError || lobby.error?.message || incoming.error?.message}
+ {
+ void lobby.refetch();
+ void incoming.refetch();
+ }}
+ >
+ Retry lobby
+
+
+ )}
{pending.map((item) => (
@@ -128,10 +152,18 @@ export default function Lobby({ token, userId, gameId, onBack, onMatch }: Props)
Room {item.room_id.slice(0, 8)}
- void respond(item, false)}>
+ void respond(item, false)}
+ >
Decline
- void respond(item, true)}>
+ void respond(item, true)}
+ >
Accept
@@ -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 && (
+ void room.refetch()}>
+ Retry room status
+
+ )}
+ {(playableMatch.isError || coordinator.phase === "failed") && (
+ {
+ playableMatch.reset();
+ resetCoordinator();
+ }}
+ >
+ Retry match setup
+
+ )}
{completedRoom && probeReport && (
)}
-
- Return to games
+ {
+ if (
+ room.data?.state !== "finished" &&
+ !window.confirm("Leave this match and stop its native session?")
+ ) {
+ return;
+ }
+ setIsLeaving(true);
+ setLeaveError("");
+ void (async () => {
+ try {
+ let stopError: unknown;
+ if (playableMatch.data?.pid) {
+ try {
+ await stopGame(playableMatch.data.pid);
+ } catch (error) {
+ stopError = error;
+ }
+ }
+ if (
+ room.data &&
+ room.data.state !== "finished" &&
+ room.data.state !== "cancelled"
+ ) {
+ await api.cancelRoom(token, roomId);
+ }
+ if (stopError) throw stopError;
+ onDone();
+ } catch (error) {
+ setLeaveError(
+ error instanceof Error ? error.message : "Match could not be left safely"
+ );
+ } finally {
+ setIsLeaving(false);
+ }
+ })();
+ }}
+ >
+ {isLeaving
+ ? "Leaving match…"
+ : room.data?.state === "finished"
+ ? "Return to games"
+ : "Leave match"}
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) => (
-
+
{step}
))}
@@ -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 && (
+
+ Both players’ verified reports must arrive before the next-match receipt can be created.
+ Export or upload the missing report, then retry.
+
+ )}
+ {evidenceUpload.isError && (
+
+ Match evidence could not be uploaded. Retry the upload before creating an invite.
+
+ )}
+ {receiptCopied && (
+
+ Match receipt copied.
+
+ )}
+ {receiptCopyError && (
+
+ {receiptCopyError}
+
+ )}
{(probeError || coordinator.phase === "relay_probe_only") && (
)}
+ {coordinator.phase === "ready" &&
+ snapshot?.compatibility_matched &&
+ snapshot.controller_ready_count < 2 && (
+
+ Check controllers again
+
+ )}
{completedRoom && probeReport && (
)}
+ {completedRoom && probeReport && !receipt.data && (
+ receipt.mutate()}
+ >
+ {evidenceUpload.isPending
+ ? "Uploading match evidence…"
+ : receipt.isPending
+ ? "Creating next-match invite…"
+ : "Create next-match invite"}
+
+ )}
+ {evidenceUpload.isError && (
+ {
+ evidenceUpload.reset();
+ }}
+ >
+ Retry evidence upload
+
+ )}
+ {receipt.data && (
+ {
+ setReceiptCopyError("");
+ try {
+ if (!navigator.clipboard) throw new Error("Clipboard access is unavailable");
+ await navigator.clipboard.writeText(receiptText);
+ setReceiptCopied(true);
+ } catch {
+ setReceiptCopyError("Could not copy the receipt. Copy the invite code above.");
+ }
+ }}
+ >
+ Copy match receipt
+
+ )}
{failureRoom && participants && failureEvidence && (
>'native_route'),
+ (payload->'room'->>'game_id'),
+ room_id
+ );
+
+INSERT INTO games (id, name, emulator) VALUES
+ ('opencade_test', 'OpenCade Proof-of-Play Test', 'retroarch_test')
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO game_versions (game_id, version, is_default) VALUES
+ ('opencade_test', '1.0.0', true)
+ON CONFLICT (game_id, version) DO NOTHING;
diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs
index 297c277..568f4b9 100644
--- a/apps/server/src/config.rs
+++ b/apps/server/src/config.rs
@@ -22,6 +22,10 @@ pub enum ConfigError {
UnsafeProductionStun,
#[error("OPERATOR_TOKEN must contain at least 32 characters in production")]
WeakOperatorToken,
+ #[error("ROUTE_POLICY_MIN_ATTEMPTS must be between 3 and 1000")]
+ InvalidRoutePolicyMinimum,
+ #[error("OPENCADE_EVIDENCE_TCP_TUNNEL requires relay configuration")]
+ EvidenceTunnelWithoutRelay,
}
#[derive(Clone, PartialEq, Eq)]
@@ -37,6 +41,8 @@ pub struct Config {
pub relay_url: Option,
pub relay_secret: Option,
pub operator_token: String,
+ pub evidence_tcp_tunnel_enabled: bool,
+ pub route_policy_min_attempts: u32,
}
impl std::fmt::Debug for Config {
@@ -57,6 +63,11 @@ impl std::fmt::Debug for Config {
&self.relay_secret.as_ref().map(|_| ""),
)
.field("operator_token", &"")
+ .field(
+ "evidence_tcp_tunnel_enabled",
+ &self.evidence_tcp_tunnel_enabled,
+ )
+ .field("route_policy_min_attempts", &self.route_policy_min_attempts)
.finish()
}
}
@@ -90,6 +101,13 @@ impl Config {
if production && operator_token.len() < 32 {
return Err(ConfigError::WeakOperatorToken);
}
+ let evidence_tcp_tunnel_enabled = lookup("OPENCADE_EVIDENCE_TCP_TUNNEL")
+ .is_some_and(|value| value.eq_ignore_ascii_case("true"));
+ let route_policy_min_attempts = lookup("ROUTE_POLICY_MIN_ATTEMPTS")
+ .map(|value| value.parse::().ok())
+ .unwrap_or(Some(10))
+ .filter(|value| (3..=1000).contains(value))
+ .ok_or(ConfigError::InvalidRoutePolicyMinimum)?;
let allowed_origins = lookup("ALLOWED_ORIGINS")
.unwrap_or_else(|| {
@@ -131,6 +149,9 @@ impl Config {
{
return Err(ConfigError::WeakRelaySecret);
}
+ if evidence_tcp_tunnel_enabled && relay_url.is_none() {
+ return Err(ConfigError::EvidenceTunnelWithoutRelay);
+ }
if production {
let relay = relay_url
.as_deref()
@@ -155,6 +176,8 @@ impl Config {
relay_url,
relay_secret,
operator_token,
+ evidence_tcp_tunnel_enabled,
+ route_policy_min_attempts,
})
}
@@ -171,6 +194,8 @@ impl Config {
relay_url: None,
relay_secret: None,
operator_token: "test-operator-token-with-32-characters".into(),
+ evidence_tcp_tunnel_enabled: false,
+ route_policy_min_attempts: 10,
}
}
}
@@ -235,6 +260,8 @@ mod tests {
assert_eq!(config.stun_host, "stun.opencade.local");
assert_eq!(config.stun_port, 3478);
assert_eq!(config.relay_url, None);
+ assert!(!config.evidence_tcp_tunnel_enabled);
+ assert_eq!(config.route_policy_min_attempts, 10);
}
#[test]
@@ -265,6 +292,34 @@ mod tests {
);
}
+ #[test]
+ fn parses_and_bounds_the_evidence_route_policy() {
+ let config = load_config(&[
+ ("OPENCADE_EVIDENCE_TCP_TUNNEL", "true"),
+ ("ROUTE_POLICY_MIN_ATTEMPTS", "25"),
+ ("RELAY_URL", "wss://relay.example.com/relay"),
+ (
+ "RELAY_AUTH_SECRET",
+ "route-policy-relay-secret-at-least-32-bytes",
+ ),
+ ])
+ .expect("valid route policy");
+ assert!(config.evidence_tcp_tunnel_enabled);
+ assert_eq!(config.route_policy_min_attempts, 25);
+ assert_eq!(
+ load_config(&[("OPENCADE_EVIDENCE_TCP_TUNNEL", "true")]),
+ Err(ConfigError::EvidenceTunnelWithoutRelay)
+ );
+ assert_eq!(
+ load_config(&[("ROUTE_POLICY_MIN_ATTEMPTS", "2")]),
+ Err(ConfigError::InvalidRoutePolicyMinimum)
+ );
+ assert_eq!(
+ load_config(&[("ROUTE_POLICY_MIN_ATTEMPTS", "many")]),
+ Err(ConfigError::InvalidRoutePolicyMinimum)
+ );
+ }
+
#[test]
fn rejects_weak_production_secret() {
assert_eq!(
diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs
index 0ce1a9d..39be013 100644
--- a/apps/server/src/lib.rs
+++ b/apps/server/src/lib.rs
@@ -6,6 +6,7 @@ pub mod health;
pub mod lifecycle;
pub mod metrics;
pub mod room_state;
+pub mod route_policy;
pub mod routes;
pub mod state;
pub mod ws;
@@ -76,6 +77,10 @@ pub fn build_app(state: AppState) -> Router {
"/api/v1/rooms/{id}/ready",
post(routes::alpha::ready_to_launch),
)
+ .route(
+ "/api/v1/rooms/{id}/receipt",
+ post(routes::alpha::create_match_receipt),
+ )
.route(
"/api/v1/alpha/evidence",
post(routes::alpha_evidence::submit_evidence),
@@ -84,6 +89,10 @@ pub fn build_app(state: AppState) -> Router {
"/api/v1/alpha/campaign",
get(routes::alpha_evidence::campaign_summary),
)
+ .route(
+ "/api/v1/public/compatibility",
+ get(routes::alpha_evidence::public_compatibility),
+ )
.route(
"/api/v1/telemetry/events",
post(routes::telemetry::record_event),
diff --git a/apps/server/src/lifecycle.rs b/apps/server/src/lifecycle.rs
index ebe0c76..53e6d0d 100644
--- a/apps/server/src/lifecycle.rs
+++ b/apps/server/src/lifecycle.rs
@@ -5,6 +5,10 @@ use uuid::Uuid;
use crate::{error::AppError, state::AppState};
+const RECONCILE_BATCH_SIZE: i64 = 250;
+const TELEMETRY_RETENTION_DAYS: i32 = 90;
+const TELEMETRY_RETENTION_BATCH_SIZE: i64 = 1_000;
+
/// 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) {
@@ -20,12 +24,58 @@ pub fn spawn_reconciler(state: AppState) {
});
}
+/// Removes expired raw telemetry outside request handling. Each pass is bounded
+/// so retention cannot monopolize the database during a large backlog.
+pub fn spawn_telemetry_retention(state: AppState) {
+ tokio::spawn(async move {
+ let mut interval = tokio::time::interval(Duration::from_secs(60));
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ loop {
+ interval.tick().await;
+ let result = reconcile_telemetry_retention(&state).await;
+ match result {
+ Ok(removed) if removed > 0 => {
+ tracing::info!(removed, "expired telemetry removed");
+ }
+ Ok(_) => {}
+ Err(error) => tracing::error!(%error, "telemetry retention failed"),
+ }
+ }
+ });
+}
+
+pub async fn reconcile_telemetry_retention(state: &AppState) -> Result {
+ let result = sqlx::query(
+ "DELETE FROM product_events
+ WHERE event_id IN (
+ SELECT event_id FROM product_events
+ WHERE received_at < now() - make_interval(days => $1)
+ ORDER BY received_at
+ LIMIT $2
+ )",
+ )
+ .bind(TELEMETRY_RETENTION_DAYS)
+ .bind(TELEMETRY_RETENTION_BATCH_SIZE)
+ .execute(&state.pool)
+ .await?;
+ Ok(result.rows_affected())
+}
+
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()",
+ "WITH expired_challenges AS (
+ SELECT id FROM challenges
+ WHERE state = 'PENDING' AND expires_at <= now()
+ ORDER BY expires_at
+ LIMIT $1
+ FOR UPDATE SKIP LOCKED
+ )
+ UPDATE challenges AS challenge SET state = 'EXPIRED'
+ FROM expired_challenges
+ WHERE challenge.id = expired_challenges.id",
)
+ .bind(RECONCILE_BATCH_SIZE)
.execute(&mut *transaction)
.await?;
@@ -35,6 +85,7 @@ pub async fn reconcile_expired(state: &AppState) -> Result {
WHERE state IN ('WAITING', 'CHALLENGING', 'CONNECTING', 'PLAYING')
AND state_deadline_at <= now()
ORDER BY state_deadline_at
+ LIMIT $2
FOR UPDATE SKIP LOCKED
), updated_rooms AS (
UPDATE rooms AS room
@@ -51,12 +102,20 @@ pub async fn reconcile_expired(state: &AppState) -> Result {
UPDATE challenges AS challenge SET state = 'EXPIRED'
FROM expired
WHERE challenge.room_id = expired.id AND challenge.state = 'PENDING'
+ ), updated_matches AS (
+ UPDATE matches AS matched SET ended_at = COALESCE(matched.ended_at, now())
+ FROM expired WHERE matched.room_id = expired.id
+ ), updated_runtime AS (
+ UPDATE match_runtime_participants AS participant
+ SET ended_at = COALESCE(participant.ended_at, now())
+ FROM expired WHERE participant.room_id = expired.id
)
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" }))
+ .bind(RECONCILE_BATCH_SIZE)
.fetch_all(&mut *transaction)
.await?;
transaction.commit().await?;
diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs
index 5707983..569dc8c 100644
--- a/apps/server/src/main.rs
+++ b/apps/server/src/main.rs
@@ -1,6 +1,6 @@
use opencade_server::{AppState, Config, build_app, lifecycle, shutdown_signal};
use sqlx::postgres::PgPoolOptions;
-use std::net::SocketAddr;
+use std::{net::SocketAddr, time::Duration};
use tracing::info;
use tracing_subscriber::EnvFilter;
@@ -20,6 +20,9 @@ async fn main() -> Result<(), Box> {
let pool = PgPoolOptions::new()
.max_connections(10)
+ .acquire_timeout(Duration::from_secs(5))
+ .idle_timeout(Duration::from_secs(10 * 60))
+ .max_lifetime(Duration::from_secs(30 * 60))
.connect(&config.database_url)
.await?;
@@ -35,6 +38,7 @@ async fn main() -> Result<(), Box> {
let state = AppState::new(pool, config);
lifecycle::spawn_reconciler(state.clone());
+ lifecycle::spawn_telemetry_retention(state.clone());
axum::serve(listener, build_app(state))
.with_graceful_shutdown(shutdown_signal())
.await?;
diff --git a/apps/server/src/route_policy.rs b/apps/server/src/route_policy.rs
new file mode 100644
index 0000000..699e546
--- /dev/null
+++ b/apps/server/src/route_policy.rs
@@ -0,0 +1,141 @@
+use opencade_protocol::{NativeRouteCapability, NativeRoutePolicyPayload};
+use sqlx::{PgPool, Row};
+
+use crate::error::AppError;
+
+const REQUIRED_SUCCESS_PERCENT: u32 = 80;
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct RouteEvidence {
+ pub attempts: u32,
+ pub verified: u32,
+}
+
+#[must_use]
+pub fn decide_native_route(
+ tunnel_enabled: bool,
+ minimum_attempts: u32,
+ tunnel: RouteEvidence,
+) -> NativeRoutePolicyPayload {
+ let enough_samples = tunnel.attempts >= minimum_attempts;
+ let meets_success_gate = tunnel.attempts > 0
+ && tunnel.verified.saturating_mul(100)
+ >= tunnel.attempts.saturating_mul(REQUIRED_SUCCESS_PERCENT);
+ if tunnel_enabled && enough_samples && meets_success_gate {
+ return NativeRoutePolicyPayload {
+ route: NativeRouteCapability::TcpTunnel,
+ reason: "physical_evidence_gate_passed".into(),
+ evidence_attempts: tunnel.attempts,
+ evidence_verified: tunnel.verified,
+ };
+ }
+ NativeRoutePolicyPayload {
+ route: NativeRouteCapability::DirectLan,
+ reason: if !tunnel_enabled {
+ "tcp_tunnel_operator_disabled"
+ } else if !enough_samples {
+ "tcp_tunnel_evidence_insufficient"
+ } else {
+ "tcp_tunnel_success_gate_failed"
+ }
+ .into(),
+ evidence_attempts: tunnel.attempts,
+ evidence_verified: tunnel.verified,
+ }
+}
+
+pub async fn tcp_tunnel_evidence(pool: &PgPool, game_id: &str) -> Result {
+ let row = sqlx::query(
+ "WITH room_outcomes AS (
+ SELECT room_id,
+ COUNT(*) FILTER (WHERE kind = 'match') AS match_reports,
+ COUNT(*) FILTER (WHERE kind = 'attempt_failure') AS failures,
+ COUNT(DISTINCT payload->'compatibility')
+ FILTER (WHERE kind = 'match') AS compatibility_sets,
+ COUNT(DISTINCT role) FILTER (WHERE kind = 'match') AS role_sets,
+ COUNT(DISTINCT payload->'probe'->>'transcript_checksum')
+ FILTER (WHERE kind = 'match') AS transcript_sets,
+ COUNT(*) FILTER (
+ WHERE kind = 'match'
+ AND payload->'probe'->>'frames_received' = '60'
+ AND payload->'room'->>'state' = 'finished'
+ ) AS complete_reports
+ FROM alpha_evidence
+ WHERE payload->>'native_route' = 'tcp_tunnel'
+ AND payload->'room'->>'game_id' = $1
+ GROUP BY room_id
+ )
+ SELECT COUNT(*) AS attempts,
+ COUNT(*) FILTER (
+ WHERE match_reports = 2 AND failures = 0 AND compatibility_sets = 1
+ AND role_sets = 2 AND transcript_sets = 1 AND complete_reports = 2
+ ) AS verified
+ FROM room_outcomes",
+ )
+ .bind(game_id)
+ .fetch_one(pool)
+ .await?;
+ Ok(RouteEvidence {
+ attempts: u32::try_from(row.try_get::("attempts")?).unwrap_or(u32::MAX),
+ verified: u32::try_from(row.try_get::("verified")?).unwrap_or(u32::MAX),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn route_policy_fails_closed_without_operator_enablement() {
+ let decision = decide_native_route(
+ false,
+ 10,
+ RouteEvidence {
+ attempts: 10,
+ verified: 10,
+ },
+ );
+ assert_eq!(decision.route, NativeRouteCapability::DirectLan);
+ assert_eq!(decision.reason, "tcp_tunnel_operator_disabled");
+ }
+
+ #[test]
+ fn route_policy_requires_minimum_sample_and_eighty_percent_success() {
+ assert_eq!(
+ decide_native_route(
+ true,
+ 10,
+ RouteEvidence {
+ attempts: 9,
+ verified: 9
+ }
+ )
+ .route,
+ NativeRouteCapability::DirectLan
+ );
+ assert_eq!(
+ decide_native_route(
+ true,
+ 10,
+ RouteEvidence {
+ attempts: 10,
+ verified: 7
+ }
+ )
+ .route,
+ NativeRouteCapability::DirectLan
+ );
+ assert_eq!(
+ decide_native_route(
+ true,
+ 10,
+ RouteEvidence {
+ attempts: 10,
+ verified: 8
+ }
+ )
+ .route,
+ NativeRouteCapability::TcpTunnel
+ );
+ }
+}
diff --git a/apps/server/src/routes/alpha.rs b/apps/server/src/routes/alpha.rs
index 62558fc..05a1582 100644
--- a/apps/server/src/routes/alpha.rs
+++ b/apps/server/src/routes/alpha.rs
@@ -1,8 +1,9 @@
use axum::{Json, extract::Path, extract::State, http::StatusCode};
use chrono::{DateTime, Duration, Utc};
+use opencade_networking::verify_playable_match_reports;
use opencade_protocol::{
- Envelope, LaunchBarrierPayload, MatchPreflightPayload, MatchReportCompatibility,
- NativeRouteCapability, RoomInvitePayload, RoomSnapshotPayload,
+ Envelope, LaunchBarrierPayload, MatchPreflightPayload, MatchReceiptPayload, MatchReport,
+ MatchReportCompatibility, NativeRouteCapability, RoomInvitePayload, RoomSnapshotPayload,
};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -26,6 +27,11 @@ pub struct JoinInviteRequest {
code: String,
}
+#[derive(Debug, Deserialize)]
+pub struct MatchReceiptRequest {
+ idempotency_key: Uuid,
+}
+
pub async fn create_invite(
State(state): State,
user: AuthUser,
@@ -128,8 +134,14 @@ pub async fn join_invite(
return Err(AppError::Conflict("room is full".into()));
}
sqlx::query(
- "UPDATE rooms SET state = 'CANCELLED'
- WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING' AND id <> $3",
+ "WITH cancelled AS (
+ UPDATE rooms SET state = 'CANCELLED', state_deadline_at = NULL
+ WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING' AND id <> $3
+ RETURNING attempt_id
+ )
+ UPDATE match_attempts SET state = 'CANCELLED', deadline_at = NULL,
+ finished_at = COALESCE(finished_at, now())
+ WHERE attempt_id IN (SELECT attempt_id FROM cancelled) AND state = 'ACTIVE'",
)
.bind(user.id)
.bind(&game_id)
@@ -141,11 +153,21 @@ pub async fn join_invite(
.bind(user.id)
.execute(&mut *transaction)
.await?;
- sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1")
- .bind(room_id)
- .bind(to_database(&next))
- .execute(&mut *transaction)
- .await?;
+ sqlx::query(
+ "UPDATE rooms SET state = $2, state_deadline_at = now() + interval '2 minutes'
+ WHERE id = $1",
+ )
+ .bind(room_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) AND state = 'ACTIVE'",
+ )
+ .bind(room_id)
+ .execute(&mut *transaction)
+ .await?;
sqlx::query(
"UPDATE room_invites SET consumed_at = now(), consumed_by = $2 WHERE code_hash = $1",
)
@@ -173,17 +195,26 @@ pub async fn submit_preflight(
validate_compatibility(&payload.compatibility)?;
let mut transaction = state.pool.begin().await?;
let attempt_id = lock_active_attempt(&mut transaction, room_id, user.id).await?;
+ let expected_slot = expected_player_slot(&mut transaction, room_id, user.id).await?;
+ if payload.controller.player_slot != expected_slot {
+ return Err(AppError::BadRequest(format!(
+ "controller must be assigned to player slot {expected_slot}"
+ )));
+ }
sqlx::query("DELETE FROM room_launch_barriers WHERE room_id = $1")
.bind(room_id)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO match_preflights
- (room_id, user_id, compatibility, native_port_available, ready, updated_at, attempt_id)
- VALUES ($1, $2, $3, $4, FALSE, now(), $5)
+ (room_id, user_id, compatibility, native_port_available, controller_connected,
+ player_slot, ready, updated_at, attempt_id)
+ VALUES ($1, $2, $3, $4, $5, $6, FALSE, now(), $7)
ON CONFLICT (room_id, user_id) DO UPDATE SET
compatibility = EXCLUDED.compatibility,
native_port_available = EXCLUDED.native_port_available,
+ controller_connected = EXCLUDED.controller_connected,
+ player_slot = EXCLUDED.player_slot,
ready = FALSE,
attempt_id = EXCLUDED.attempt_id,
updated_at = now()",
@@ -192,6 +223,8 @@ pub async fn submit_preflight(
.bind(user.id)
.bind(json!(payload.compatibility))
.bind(payload.native_port_available)
+ .bind(payload.controller.connected)
+ .bind(i16::from(payload.controller.player_slot))
.bind(attempt_id)
.execute(&mut *transaction)
.await?;
@@ -203,7 +236,11 @@ pub async fn submit_preflight(
.bind(room_id)
.bind(attempt_id)
.bind(user.id)
- .bind(json!({ "native_port_available": payload.native_port_available }))
+ .bind(json!({
+ "native_port_available": payload.native_port_available,
+ "controller_connected": payload.controller.connected,
+ "player_slot": payload.controller.player_slot
+ }))
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
@@ -231,11 +268,11 @@ pub async fn ready_to_launch(
) -> Result>, AppError> {
let mut transaction = state.pool.begin().await?;
let attempt_id = lock_active_attempt(&mut transaction, room_id, user.id).await?;
- let (preflight_count, compatibility_matched) =
+ let (preflight_count, compatibility_matched, controllers_ready) =
preflight_status(&mut transaction, room_id, attempt_id).await?;
- if preflight_count != 2 || !compatibility_matched {
+ if preflight_count != 2 || !compatibility_matched || !controllers_ready {
return Err(AppError::Conflict(
- "both peers must pass matching compatibility preflight".into(),
+ "both peers must pass matching compatibility and controller preflight".into(),
));
}
sqlx::query(
@@ -289,6 +326,214 @@ pub async fn ready_to_launch(
Ok(Json(Envelope::new("match.launch.ready", json!(snapshot))))
}
+pub async fn create_match_receipt(
+ State(state): State,
+ user: AuthUser,
+ Path(source_room_id): Path,
+ Json(request): Json,
+) -> Result<(StatusCode, Json>), AppError> {
+ if let Some(row) = sqlx::query(
+ "SELECT command_type, response_payload FROM command_results
+ WHERE actor_id = $1 AND idempotency_key = $2",
+ )
+ .bind(user.id)
+ .bind(request.idempotency_key)
+ .fetch_optional(&state.pool)
+ .await?
+ {
+ if row.try_get::("command_type")? != "match.receipt.create" {
+ return Err(AppError::Conflict(
+ "idempotency key was already used for another command".into(),
+ ));
+ }
+ let payload: Value = row.try_get("response_payload")?;
+ return Ok((
+ StatusCode::OK,
+ Json(Envelope::new("match.receipt.created", payload)),
+ ));
+ }
+
+ let mut transaction = state.pool.begin().await?;
+ let game_id = sqlx::query_scalar::<_, String>(
+ "SELECT rooms.game_id
+ FROM rooms
+ JOIN room_members ON room_members.room_id = rooms.id AND room_members.user_id = $2
+ WHERE rooms.id = $1 AND rooms.state = 'FINISHED'
+ FOR UPDATE OF rooms",
+ )
+ .bind(source_room_id)
+ .bind(user.id)
+ .fetch_optional(&mut *transaction)
+ .await?
+ .ok_or_else(|| AppError::Conflict("finished room membership is required".into()))?;
+ if let Some(row) = sqlx::query(
+ "SELECT command_type, response_payload FROM command_results
+ WHERE actor_id = $1 AND idempotency_key = $2",
+ )
+ .bind(user.id)
+ .bind(request.idempotency_key)
+ .fetch_optional(&mut *transaction)
+ .await?
+ {
+ if row.try_get::("command_type")? != "match.receipt.create" {
+ return Err(AppError::Conflict(
+ "idempotency key was already used for another command".into(),
+ ));
+ }
+ let payload: Value = row.try_get("response_payload")?;
+ transaction.commit().await?;
+ return Ok((
+ StatusCode::OK,
+ Json(Envelope::new("match.receipt.created", payload)),
+ ));
+ }
+ let failures = sqlx::query_scalar::<_, i64>(
+ "SELECT COUNT(*) FROM alpha_evidence WHERE room_id = $1 AND kind = 'attempt_failure'",
+ )
+ .bind(source_room_id)
+ .fetch_one(&mut *transaction)
+ .await?;
+ let reports = sqlx::query_scalar::<_, Value>(
+ "SELECT payload FROM alpha_evidence
+ WHERE room_id = $1 AND kind = 'match' ORDER BY role",
+ )
+ .bind(source_room_id)
+ .fetch_all(&mut *transaction)
+ .await?;
+ if reports.len() != 2 || failures != 0 {
+ return Err(AppError::Conflict(
+ "a verified two-peer evidence pair is required before creating a receipt".into(),
+ ));
+ }
+ let first: MatchReport = serde_json::from_value(reports[0].clone())
+ .map_err(|_| AppError::Conflict("match evidence is not verifiable".into()))?;
+ let second: MatchReport = serde_json::from_value(reports[1].clone())
+ .map_err(|_| AppError::Conflict("match evidence is not verifiable".into()))?;
+ verify_playable_match_reports(&first, &second)
+ .map_err(|_| AppError::Conflict("match evidence pair did not verify".into()))?;
+ if first.native_route != second.native_route {
+ return Err(AppError::Conflict(
+ "match evidence route does not match".into(),
+ ));
+ }
+ let route = parse_native_route(first.native_route.map(|value| match value {
+ NativeRouteCapability::DirectLan => "direct_lan".to_string(),
+ NativeRouteCapability::TcpTunnel => "tcp_tunnel".to_string(),
+ }))?;
+
+ let existing_receipt = sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS(SELECT 1 FROM match_receipts WHERE source_room_id = $1 AND created_by = $2)",
+ )
+ .bind(source_room_id)
+ .bind(user.id)
+ .fetch_one(&mut *transaction)
+ .await?;
+ if existing_receipt {
+ return Err(AppError::Conflict(
+ "a continuation receipt already exists for this match".into(),
+ ));
+ }
+
+ sqlx::query(
+ "UPDATE rooms SET state = 'CANCELLED'
+ WHERE host_user_id = $1 AND game_id = $2 AND state = 'WAITING'",
+ )
+ .bind(user.id)
+ .bind(&game_id)
+ .execute(&mut *transaction)
+ .await?;
+ let next_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, attempt_id, state_deadline_at)
+ VALUES ($1, $2, $3, 'WAITING', 2, $4, now() + interval '24 hours')",
+ )
+ .bind(next_room_id)
+ .bind(&game_id)
+ .bind(user.id)
+ .bind(attempt_id)
+ .execute(&mut *transaction)
+ .await?;
+ sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES ($1, $2)")
+ .bind(next_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(next_room_id)
+ .execute(&mut *transaction)
+ .await?;
+
+ let code = Uuid::new_v4().simple().to_string()[..10].to_ascii_uppercase();
+ let expires_at = Utc::now() + Duration::minutes(INVITE_TTL_MINUTES);
+ sqlx::query(
+ "INSERT INTO room_invites (code_hash, room_id, created_by, expires_at)
+ VALUES ($1, $2, $3, $4)",
+ )
+ .bind(hash_token(&code))
+ .bind(next_room_id)
+ .bind(user.id)
+ .bind(expires_at)
+ .execute(&mut *transaction)
+ .await?;
+
+ let receipt_id = Uuid::new_v4();
+ sqlx::query(
+ "INSERT INTO match_receipts (id, source_room_id, created_by, next_room_id)
+ VALUES ($1, $2, $3, $4)",
+ )
+ .bind(receipt_id)
+ .bind(source_room_id)
+ .bind(user.id)
+ .bind(next_room_id)
+ .execute(&mut *transaction)
+ .await?;
+ let payload = MatchReceiptPayload {
+ receipt_id: receipt_id.to_string(),
+ game_id,
+ result: "verified".into(),
+ route,
+ compatibility_verified: true,
+ created_at: Utc::now(),
+ invite: RoomInvitePayload {
+ room_id: next_room_id.to_string(),
+ code,
+ expires_at,
+ },
+ };
+ let response_payload = json!(payload);
+ sqlx::query(
+ "INSERT INTO command_results
+ (actor_id, idempotency_key, command_type, response_type, response_payload)
+ VALUES ($1, $2, 'match.receipt.create', 'match.receipt.created', $3)",
+ )
+ .bind(user.id)
+ .bind(request.idempotency_key)
+ .bind(&response_payload)
+ .execute(&mut *transaction)
+ .await?;
+ transaction.commit().await?;
+ Ok((
+ StatusCode::CREATED,
+ Json(Envelope::new("match.receipt.created", response_payload)),
+ ))
+}
+
+fn parse_native_route(value: Option) -> Result {
+ match value.as_deref() {
+ Some("direct_lan") => Ok(NativeRouteCapability::DirectLan),
+ Some("tcp_tunnel") => Ok(NativeRouteCapability::TcpTunnel),
+ _ => Err(AppError::Conflict(
+ "match evidence does not contain one supported native route".into(),
+ )),
+ }
+}
+
async fn build_snapshot(
state: &AppState,
room_id: Uuid,
@@ -296,7 +541,7 @@ async fn build_snapshot(
) -> Result {
let room = room_payload(state, room_id, user_id).await?;
let rows = sqlx::query(
- "SELECT compatibility, native_port_available, ready
+ "SELECT compatibility, native_port_available, controller_connected, player_slot, ready
FROM match_preflights
WHERE room_id = $1
AND attempt_id = (SELECT attempt_id FROM rooms WHERE id = $1)
@@ -319,6 +564,13 @@ async fn build_snapshot(
.iter()
.filter(|row| row.try_get::("ready").unwrap_or(false))
.count();
+ let controller_ready_count = rows
+ .iter()
+ .filter(|row| {
+ row.try_get::("controller_connected")
+ .unwrap_or(false)
+ })
+ .count();
let launch_at = sqlx::query_scalar::<_, DateTime>(
"SELECT launch_at FROM room_launch_barriers
WHERE room_id = $1
@@ -333,18 +585,30 @@ async fn build_snapshot(
.bind(room_id)
.fetch_one(&state.pool)
.await?;
+ let tunnel_evidence = if state.config.evidence_tcp_tunnel_enabled {
+ crate::route_policy::tcp_tunnel_evidence(&state.pool, &room.game_id).await?
+ } else {
+ crate::route_policy::RouteEvidence::default()
+ };
+ let route_policy = crate::route_policy::decide_native_route(
+ state.config.evidence_tcp_tunnel_enabled,
+ state.config.route_policy_min_attempts,
+ tunnel_evidence,
+ );
Ok(RoomSnapshotPayload {
room,
revision,
preflight_count: u8::try_from(rows.len()).unwrap_or(u8::MAX),
compatibility_matched,
+ controller_ready_count: u8::try_from(controller_ready_count).unwrap_or(u8::MAX),
barrier: LaunchBarrierPayload {
room_id: room_id.to_string(),
ready_count: u8::try_from(ready_count).unwrap_or(u8::MAX),
required_count: 2,
launch_at,
},
- route: NativeRouteCapability::DirectLan,
+ route: route_policy.route,
+ route_policy,
})
}
@@ -387,9 +651,9 @@ async fn preflight_status(
transaction: &mut Transaction<'_, Postgres>,
room_id: Uuid,
attempt_id: Uuid,
-) -> Result<(usize, bool), AppError> {
+) -> Result<(usize, bool, bool), AppError> {
let rows = sqlx::query(
- "SELECT compatibility, native_port_available
+ "SELECT compatibility, native_port_available, controller_connected, player_slot
FROM match_preflights
WHERE room_id = $1 AND attempt_id = $2
ORDER BY user_id",
@@ -408,7 +672,29 @@ async fn preflight_status(
row.try_get::("native_port_available")
.unwrap_or(false)
});
- Ok((rows.len(), matched))
+ let controllers_ready = rows.len() == 2
+ && rows.iter().all(|row| {
+ row.try_get::("controller_connected")
+ .unwrap_or(false)
+ })
+ && rows
+ .iter()
+ .filter_map(|row| row.try_get::("player_slot").ok())
+ .collect::>()
+ == std::collections::BTreeSet::from([1, 2]);
+ Ok((rows.len(), matched, controllers_ready))
+}
+
+async fn expected_player_slot(
+ transaction: &mut Transaction<'_, Postgres>,
+ room_id: Uuid,
+ user_id: Uuid,
+) -> Result {
+ let host_id = sqlx::query_scalar::<_, Uuid>("SELECT host_user_id FROM rooms WHERE id = $1")
+ .bind(room_id)
+ .fetch_one(&mut **transaction)
+ .await?;
+ Ok(if host_id == user_id { 1 } else { 2 })
}
async fn notify_members(
@@ -448,7 +734,7 @@ fn validate_compatibility(value: &MatchReportCompatibility) -> Result<(), AppErr
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
};
- if value.adapter != "retroarch_fbneo"
+ if !matches!(value.adapter.as_str(), "retroarch_fbneo" | "retroarch_test")
|| value
.emulator_version
.as_ref()
diff --git a/apps/server/src/routes/alpha_evidence.rs b/apps/server/src/routes/alpha_evidence.rs
index 73e7f72..d54b05b 100644
--- a/apps/server/src/routes/alpha_evidence.rs
+++ b/apps/server/src/routes/alpha_evidence.rs
@@ -4,10 +4,14 @@ use axum::{
http::{HeaderMap, StatusCode},
};
use opencade_networking::summarize_campaign_evidence;
-use opencade_protocol::{AlphaFailureReport, Envelope, MatchReport, MatchReportRole};
+use opencade_protocol::{
+ AlphaFailureReport, Envelope, MatchReport, MatchReportRole, NativeRouteCapability,
+ PublicCompatibilityCohort, PublicCompatibilityPayload,
+};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
+use sqlx::Row;
use uuid::Uuid;
use crate::{authn::AuthUser, error::AppError, state::AppState};
@@ -38,12 +42,27 @@ pub async fn submit_evidence(
}
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 {
+ let context = evidence_room_context(&state, room_id, user.id).await?;
+ if role != context.role {
return Err(AppError::Forbidden(
"evidence role does not match room membership".into(),
));
}
+ if canonical
+ .get("room")
+ .and_then(|room| room.get("game_id"))
+ .and_then(Value::as_str)
+ != Some(context.game_id.as_str())
+ {
+ return Err(AppError::BadRequest(
+ "evidence game does not match the authoritative room".into(),
+ ));
+ }
+ if kind == "match" && context.state != "FINISHED" {
+ return Err(AppError::Conflict(
+ "successful match evidence requires an authoritative finished room".into(),
+ ));
+ }
let digest = hex::encode(Sha256::digest(&encoded));
let inserted = sqlx::query(
"INSERT INTO alpha_evidence (digest, room_id, submitted_by, role, kind, payload)
@@ -119,6 +138,104 @@ pub async fn campaign_summary(
Ok(Json(Envelope::new("alpha.campaign.summary", summary)))
}
+pub async fn public_compatibility(
+ State(state): State,
+) -> Result>, AppError> {
+ const MINIMUM_COHORT_SIZE: i64 = 3;
+ const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
+ let mut cache = state.public_compatibility_cache.write().await;
+ if let Some((created_at, payload)) = cache.as_ref()
+ && created_at.elapsed() < CACHE_TTL
+ {
+ return Ok(Json(Envelope::new(
+ "public.compatibility.summary",
+ json!(payload),
+ )));
+ }
+ let rows = sqlx::query(
+ "WITH room_status AS (
+ SELECT room_id,
+ COUNT(*) FILTER (WHERE kind = 'match') AS match_reports,
+ COUNT(*) FILTER (WHERE kind = 'attempt_failure') AS failures,
+ COUNT(DISTINCT payload->'compatibility')
+ FILTER (WHERE kind = 'match') AS compatibility_sets,
+ COUNT(DISTINCT payload->>'native_route')
+ FILTER (WHERE kind = 'match') AS route_sets,
+ COUNT(DISTINCT role) FILTER (WHERE kind = 'match') AS role_sets,
+ COUNT(DISTINCT payload->'probe'->>'transcript_checksum')
+ FILTER (WHERE kind = 'match') AS transcript_sets,
+ COUNT(*) FILTER (
+ WHERE kind = 'match'
+ AND payload->'probe'->>'frames_received' = '60'
+ AND payload->'room'->>'state' = 'finished'
+ ) AS complete_reports
+ FROM alpha_evidence
+ GROUP BY room_id
+ ), samples AS (
+ SELECT DISTINCT evidence.room_id,
+ evidence.payload->'room'->>'game_id' AS game_id,
+ evidence.payload->'client'->>'platform' AS platform,
+ evidence.payload->'compatibility'->>'adapter' AS adapter,
+ evidence.payload->'compatibility'->>'emulator_version' AS emulator_version,
+ evidence.payload->'probe'->>'transport' AS transport,
+ evidence.payload->>'native_route' AS native_route,
+ status.match_reports = 2 AND status.failures = 0
+ AND status.compatibility_sets = 1 AND status.route_sets = 1
+ AND status.role_sets = 2 AND status.transcript_sets = 1
+ AND status.complete_reports = 2 AS verified
+ FROM alpha_evidence AS evidence
+ JOIN room_status AS status USING (room_id)
+ WHERE evidence.kind = 'match'
+ AND evidence.payload ? 'compatibility'
+ AND evidence.payload ? 'native_route'
+ )
+ SELECT game_id, platform, adapter, emulator_version, transport, native_route,
+ COUNT(DISTINCT room_id) AS attempts,
+ COUNT(DISTINCT room_id) FILTER (WHERE verified) AS verified
+ FROM samples
+ WHERE game_id IS NOT NULL AND platform IS NOT NULL AND adapter IS NOT NULL
+ AND transport IS NOT NULL AND native_route IN ('direct_lan', 'tcp_tunnel')
+ GROUP BY game_id, platform, adapter, emulator_version, transport, native_route
+ HAVING COUNT(DISTINCT room_id) >= $1
+ ORDER BY game_id, platform, adapter, emulator_version, transport, native_route
+ LIMIT 500",
+ )
+ .bind(MINIMUM_COHORT_SIZE)
+ .fetch_all(&state.pool)
+ .await?;
+ let cohorts = rows
+ .into_iter()
+ .map(|row| -> Result {
+ let route: String = row.try_get("native_route")?;
+ Ok(PublicCompatibilityCohort {
+ game_id: row.try_get("game_id")?,
+ platform: row.try_get("platform")?,
+ adapter: row.try_get("adapter")?,
+ emulator_version: row.try_get("emulator_version")?,
+ transport: row.try_get("transport")?,
+ native_route: match route.as_str() {
+ "direct_lan" => NativeRouteCapability::DirectLan,
+ "tcp_tunnel" => NativeRouteCapability::TcpTunnel,
+ _ => return Err(AppError::Internal("invalid native route aggregate".into())),
+ },
+ attempts: u32::try_from(row.try_get::("attempts")?).unwrap_or(u32::MAX),
+ verified: u32::try_from(row.try_get::("verified")?).unwrap_or(u32::MAX),
+ })
+ })
+ .collect::, _>>()?;
+ let payload = PublicCompatibilityPayload {
+ schema_version: 1,
+ generated_at: chrono::Utc::now(),
+ minimum_cohort_size: u8::try_from(MINIMUM_COHORT_SIZE).unwrap_or(u8::MAX),
+ cohorts,
+ };
+ *cache = Some((std::time::Instant::now(), payload.clone()));
+ Ok(Json(Envelope::new(
+ "public.compatibility.summary",
+ json!(payload),
+ )))
+}
+
fn decode_evidence(
value: &Value,
) -> Result<(Uuid, MatchReportRole, &'static str, Value), AppError> {
@@ -173,13 +290,19 @@ async fn reject_conflicting_duplicate(
Ok(())
}
-async fn member_role(
+struct EvidenceRoomContext {
+ role: MatchReportRole,
+ game_id: String,
+ state: String,
+}
+
+async fn evidence_room_context(
state: &AppState,
room_id: Uuid,
user_id: Uuid,
-) -> Result {
- let host = sqlx::query_scalar::<_, Uuid>(
- "SELECT rooms.host_user_id FROM rooms
+) -> Result {
+ let row = sqlx::query(
+ "SELECT rooms.host_user_id, rooms.game_id, rooms.state FROM rooms
JOIN room_members ON room_members.room_id = rooms.id
WHERE rooms.id = $1 AND room_members.user_id = $2",
)
@@ -188,10 +311,15 @@ async fn member_role(
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::Forbidden("not a room member".into()))?;
- Ok(if host == user_id {
- MatchReportRole::Host
- } else {
- MatchReportRole::Guest
+ let host: Uuid = row.try_get("host_user_id")?;
+ Ok(EvidenceRoomContext {
+ role: if host == user_id {
+ MatchReportRole::Host
+ } else {
+ MatchReportRole::Guest
+ },
+ game_id: row.try_get("game_id")?,
+ state: row.try_get("state")?,
})
}
diff --git a/apps/server/src/routes/auth.rs b/apps/server/src/routes/auth.rs
index 2c2a768..e2dbd40 100644
--- a/apps/server/src/routes/auth.rs
+++ b/apps/server/src/routes/auth.rs
@@ -92,10 +92,10 @@ pub async fn register(
validate_email(email)?;
}
let rate_key = format!("register:{}", request.username.trim().to_ascii_lowercase());
- if !state.auth_rate_limiter.check(&rate_key)
- || !state
- .auth_rate_limiter
- .check_with_limit("register-global", 30)
+ if !state
+ .auth_rate_limiter
+ .check_with_limit("register-global", 30)
+ || !state.auth_rate_limiter.check(&rate_key)
{
return Err(AppError::RateLimited(
"too many account creation attempts; retry in one minute".into(),
@@ -176,10 +176,10 @@ pub async fn login(
return Err(AppError::BadRequest("identifier must not be empty".into()));
}
let rate_key = format!("login:{}", request.identifier.trim().to_ascii_lowercase());
- if !state.auth_rate_limiter.check(&rate_key)
- || !state
- .auth_rate_limiter
- .check_with_limit("login-global", 120)
+ if !state
+ .auth_rate_limiter
+ .check_with_limit("login-global", 120)
+ || !state.auth_rate_limiter.check(&rate_key)
{
return Err(AppError::RateLimited(
"too many sign-in attempts; retry in one minute".into(),
diff --git a/apps/server/src/routes/rooms.rs b/apps/server/src/routes/rooms.rs
index c21bc1d..c8fee1f 100644
--- a/apps/server/src/routes/rooms.rs
+++ b/apps/server/src/routes/rooms.rs
@@ -906,6 +906,17 @@ async fn change_room_state(
.bind(room_id)
.execute(&mut *transaction)
.await?;
+ sqlx::query("UPDATE matches SET ended_at = COALESCE(ended_at, now()) WHERE room_id = $1")
+ .bind(room_id)
+ .execute(&mut *transaction)
+ .await?;
+ sqlx::query(
+ "UPDATE match_runtime_participants
+ SET ended_at = COALESCE(ended_at, now()) WHERE room_id = $1",
+ )
+ .bind(room_id)
+ .execute(&mut *transaction)
+ .await?;
}
sqlx::query(
"INSERT INTO room_events (room_id, attempt_id, actor_id, event_type, payload)
diff --git a/apps/server/src/routes/telemetry.rs b/apps/server/src/routes/telemetry.rs
index 76dffe1..fb196b4 100644
--- a/apps/server/src/routes/telemetry.rs
+++ b/apps/server/src/routes/telemetry.rs
@@ -16,7 +16,6 @@ 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;
@@ -61,11 +60,6 @@ pub async fn record_event(
.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 {
diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs
index a51d27c..d921a1f 100644
--- a/apps/server/src/state.rs
+++ b/apps/server/src/state.rs
@@ -9,7 +9,7 @@ use std::sync::{Arc, atomic::AtomicU64};
use uuid::Uuid;
use axum::extract::ws::Message;
-use opencade_protocol::Envelope;
+use opencade_protocol::{Envelope, PublicCompatibilityPayload};
use serde_json::Value;
use crate::auth_rate_limit::AuthRateLimiter;
@@ -36,13 +36,12 @@ pub struct AppState {
/// In-memory registry of active WebSocket connections.
///
- /// Key: connection / user id (caller-defined, typically
- /// `request_id` or authenticated user id).
+ /// Key: authenticated user id.
/// Value: bounded sender for [`axum::extract::ws::Message`]s to that socket.
///
/// The map is wrapped in [`std::sync::Arc`] so `AppState::clone` is cheap.
pub ws_hub: std::sync::Arc<
- dashmap::DashMap>,
+ dashmap::DashMap>,
>,
/// Prometheus counters for `/metrics`.
pub metrics: Arc,
@@ -50,6 +49,10 @@ pub struct AppState {
pub auth_rate_limiter: Arc,
/// Bounds CPU-heavy Argon2 work so authentication bursts cannot starve Tokio workers.
pub password_semaphore: Arc,
+ /// Short-lived public aggregate cache prevents anonymous requests from repeatedly
+ /// scanning evidence JSON. The timestamp is monotonic and process-local by design.
+ pub public_compatibility_cache:
+ Arc>>,
}
impl AppState {
@@ -64,13 +67,14 @@ impl AppState {
metrics: Arc::new(Metrics::default()),
auth_rate_limiter: Arc::new(AuthRateLimiter::default()),
password_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
+ public_compatibility_cache: Arc::new(tokio::sync::RwLock::new(None)),
}
}
/// Best-effort delivery to an authenticated user's bounded WebSocket queue.
/// Offline users observe the same durable state through REST after reconnecting.
pub fn notify_user(&self, user_id: Uuid, message_type: &str, payload: Value) {
- let Some(target) = self.ws_hub.get(&user_id.to_string()) else {
+ let Some(target) = self.ws_hub.get(&user_id) else {
return;
};
let envelope = Envelope::new(message_type, payload);
@@ -130,7 +134,7 @@ mod tests {
// Verify that DashMap + Arc are the expected concrete types by
// checking that the field can be constructed independently.
let hub: std::sync::Arc<
- dashmap::DashMap>,
+ dashmap::DashMap>,
> = std::sync::Arc::new(dashmap::DashMap::new());
assert_eq!(hub.len(), 0);
// Suppress unused warning for PgPool import
diff --git a/apps/server/src/ws.rs b/apps/server/src/ws.rs
index a61a9d8..3d18232 100644
--- a/apps/server/src/ws.rs
+++ b/apps/server/src/ws.rs
@@ -6,11 +6,10 @@ use axum::{
http::{HeaderMap, header},
response::{IntoResponse, Response},
};
-use opencade_protocol::{
- Envelope, MatchEndpointPayload, MatchProbeCompletedPayload, PROTOCOL_VERSION,
- is_supported_version,
-};
-use serde_json::{Value, json};
+use futures_util::{SinkExt, StreamExt};
+use opencade_protocol::{BorrowedEnvelope, Envelope, PROTOCOL_VERSION, is_supported_version};
+use serde::Deserialize;
+use serde_json::{Value, json, value::RawValue};
use sqlx::Row;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
@@ -76,19 +75,27 @@ fn websocket_token(headers: &HeaderMap) -> Option<&str> {
.filter(|token| !token.is_empty())
}
-pub async fn handle_socket(mut socket: WebSocket, state: AppState, user: AuthUser) {
- let user_key = user.id.to_string();
+pub async fn handle_socket(socket: WebSocket, state: AppState, user: AuthUser) {
+ let user_key = user.id;
let (sender, mut receiver) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY);
let connection_sender = sender.clone();
- if let Some(previous) = state.ws_hub.insert(user_key.clone(), sender) {
+ if let Some(previous) = state.ws_hub.insert(user_key, sender) {
let _ = previous.try_send(Message::Close(Some(CloseFrame {
code: close_code::NORMAL,
reason: "replaced by a newer connection".into(),
})));
}
+ let (mut socket_sink, mut socket_stream) = socket.split();
+ let mut writer = tokio::spawn(async move {
+ while let Some(message) = receiver.recv().await {
+ socket_sink.send(message).await.map_err(|_| ())?;
+ }
+ Ok::<(), ()>(())
+ });
+
if send_envelope(
- &mut socket,
+ &connection_sender,
Envelope::new(
"connection.hello",
json!({ "user_id": user.id, "protocol_version": PROTOCOL_VERSION }),
@@ -98,6 +105,7 @@ pub async fn handle_socket(mut socket: WebSocket, state: AppState, user: AuthUse
.is_err()
{
state.ws_hub.remove(&user_key);
+ writer.abort();
return;
}
@@ -105,38 +113,30 @@ pub async fn handle_socket(mut socket: WebSocket, state: AppState, user: AuthUse
let mut rate_limiter = RateLimiter::default();
loop {
tokio::select! {
- outbound = receiver.recv() => {
- match outbound {
- Some(message) => {
- if socket.send(message).await.is_err() {
- break;
- }
- }
- None => break,
- }
- }
- inbound = socket.recv() => {
+ _ = &mut writer => break,
+ inbound = socket_stream.next() => {
match inbound {
Some(Ok(Message::Text(text))) => {
if !rate_limiter.allow(Instant::now()) {
- if send_error(&mut socket, "rate_limited", "message rate limit exceeded", None).await.is_err() {
+ if send_error(&connection_sender, "rate_limited", "message rate limit exceeded", None).await.is_err() {
break;
}
continue;
}
- if handle_text(&mut socket, &state, &user, &text).await.is_err() {
+ let original = Message::Text(text.clone());
+ if handle_text(&connection_sender, &state, &user, &text, &original).await.is_err() {
break;
}
}
Some(Ok(Message::Ping(payload))) => {
- if socket.send(Message::Pong(payload)).await.is_err() {
+ if connection_sender.send(Message::Pong(payload)).await.is_err() {
break;
}
}
Some(Ok(Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
Some(Ok(Message::Binary(_))) => {
- if send_error(&mut socket, "binary_not_supported", "binary frames are not accepted", None).await.is_err() {
+ if send_error(&connection_sender, "binary_not_supported", "binary frames are not accepted", None).await.is_err() {
break;
}
}
@@ -147,38 +147,49 @@ pub async fn handle_socket(mut socket: WebSocket, state: AppState, user: AuthUse
state.ws_hub.remove_if(&user_key, |_, current| {
current.same_channel(&connection_sender)
});
+ writer.abort();
info!(user_id = %user.id, "websocket disconnected");
}
async fn handle_text(
- socket: &mut WebSocket,
+ sender: &mpsc::Sender,
state: &AppState,
user: &AuthUser,
text: &str,
+ original: &Message,
) -> Result<(), ()> {
if text.len() > MAX_TEXT_BYTES {
- return send_error(socket, "payload_too_large", "message exceeds 16 KiB", None).await;
+ return send_error(sender, "payload_too_large", "message exceeds 16 KiB", None).await;
}
- let envelope = match serde_json::from_str::>(text) {
+ let envelope = match serde_json::from_str::>(text) {
Ok(envelope) => envelope,
Err(_) => {
- return send_error(socket, "bad_request", "invalid envelope", None).await;
+ return send_error(sender, "bad_request", "invalid envelope", None).await;
}
};
- if !is_supported_version(&envelope.version) {
+ if !is_supported_version(envelope.version) {
return send_error(
- socket,
+ sender,
"version_unsupported",
"unsupported protocol version",
- Some(&envelope.request_id),
+ Some(envelope.request_id),
+ )
+ .await;
+ }
+ if envelope.validate().is_err() {
+ return send_error(
+ sender,
+ "bad_request",
+ "invalid envelope",
+ Some(envelope.request_id),
)
.await;
}
- match envelope.msg_type.as_str() {
+ match envelope.msg_type {
"ping" | "connection.ping" => {
send_envelope(
- socket,
+ sender,
Envelope::reply("pong", envelope.request_id, json!({})),
)
.await
@@ -188,40 +199,33 @@ async fn handle_text(
| "signaling.candidate"
| "match.endpoint"
| "match.probe.completed" => {
- if envelope.msg_type == "match.endpoint" && validate_match_endpoint(&envelope).is_err()
- {
- return send_error(
- socket,
- "invalid_candidate",
- "match endpoint candidate is invalid",
- Some(&envelope.request_id),
- )
- .await;
- }
- if envelope.msg_type == "match.probe.completed"
- && validate_match_completion(&envelope).is_err()
- {
- return send_error(
- socket,
- "invalid_probe_report",
- "match probe completion is invalid",
- Some(&envelope.request_id),
- )
- .await;
- }
- if let Err(error) = relay_to_room_members(state, user.id, &envelope, text).await {
+ let room_id = match envelope.msg_type {
+ "match.endpoint" => validate_match_endpoint(envelope.payload)
+ .map_err(|_| ("invalid_candidate", "match endpoint candidate is invalid")),
+ "match.probe.completed" => validate_match_completion(envelope.payload)
+ .map_err(|_| ("invalid_probe_report", "match probe completion is invalid")),
+ _ => parse_room_id(envelope.payload)
+ .map_err(|_| ("bad_request", "payload must contain a valid room_id")),
+ };
+ let room_id = match room_id {
+ Ok(room_id) => room_id,
+ Err((code, message)) => {
+ return send_error(sender, code, message, Some(envelope.request_id)).await;
+ }
+ };
+ if let Err(error) = relay_to_room_members(state, user.id, room_id, original).await {
return send_error(
- socket,
+ sender,
error.code(),
error.message(),
- Some(&envelope.request_id),
+ Some(envelope.request_id),
)
.await;
}
send_envelope(
- socket,
+ sender,
Envelope::reply(
- match envelope.msg_type.as_str() {
+ match envelope.msg_type {
"match.endpoint" => "match.endpoint.relayed",
"match.probe.completed" => "match.probe.completed.relayed",
_ => "signaling.relayed",
@@ -234,19 +238,51 @@ async fn handle_text(
}
_ => {
send_error(
- socket,
+ sender,
"unknown_type",
"unknown message type",
- Some(&envelope.request_id),
+ Some(envelope.request_id),
)
.await
}
}
}
-fn validate_match_endpoint(envelope: &Envelope) -> Result<(), ()> {
- let candidate: MatchEndpointPayload =
- serde_json::from_value(envelope.payload.clone()).map_err(|_| ())?;
+#[derive(Deserialize)]
+struct RoomPayload<'a> {
+ #[serde(borrow)]
+ room_id: &'a str,
+}
+
+#[derive(Deserialize)]
+struct MatchEndpointPayloadRef<'a> {
+ #[serde(borrow)]
+ room_id: &'a str,
+ #[serde(borrow)]
+ endpoint: &'a str,
+ #[serde(default, borrow)]
+ reflexive_endpoint: Option<&'a str>,
+ #[serde(borrow)]
+ nonce: &'a str,
+}
+
+#[derive(Deserialize)]
+struct MatchProbeCompletedPayloadRef<'a> {
+ #[serde(borrow)]
+ room_id: &'a str,
+ frames_received: u32,
+ #[serde(borrow)]
+ transcript_checksum: &'a str,
+}
+
+fn parse_room_id(payload: &RawValue) -> Result {
+ let payload: RoomPayload<'_> = serde_json::from_str(payload.get()).map_err(|_| ())?;
+ Uuid::parse_str(payload.room_id).map_err(|_| ())
+}
+
+fn validate_match_endpoint(payload: &RawValue) -> Result {
+ let candidate: MatchEndpointPayloadRef<'_> =
+ serde_json::from_str(payload.get()).map_err(|_| ())?;
candidate
.endpoint
.parse::()
@@ -254,13 +290,13 @@ fn validate_match_endpoint(envelope: &Envelope) -> Result<(), ()> {
if let Some(reflexive) = candidate.reflexive_endpoint {
reflexive.parse::().map_err(|_| ())?;
}
- Uuid::parse_str(&candidate.nonce).map_err(|_| ())?;
- Ok(())
+ Uuid::parse_str(candidate.nonce).map_err(|_| ())?;
+ Uuid::parse_str(candidate.room_id).map_err(|_| ())
}
-fn validate_match_completion(envelope: &Envelope) -> Result<(), ()> {
- let completion: MatchProbeCompletedPayload =
- serde_json::from_value(envelope.payload.clone()).map_err(|_| ())?;
+fn validate_match_completion(payload: &RawValue) -> Result {
+ let completion: MatchProbeCompletedPayloadRef<'_> =
+ serde_json::from_str(payload.get()).map_err(|_| ())?;
if completion.frames_received == 0 || completion.frames_received > 10_000 {
return Err(());
}
@@ -272,59 +308,57 @@ fn validate_match_completion(envelope: &Envelope) -> Result<(), ()> {
{
return Err(());
}
- Ok(())
+ Uuid::parse_str(completion.room_id).map_err(|_| ())
}
async fn relay_to_room_members(
state: &AppState,
sender_id: Uuid,
- envelope: &Envelope,
- original_text: &str,
+ room_id: Uuid,
+ original: &Message,
) -> Result<(), RelayError> {
- let room_id = envelope
- .payload
- .get("room_id")
- .and_then(Value::as_str)
- .and_then(|value| Uuid::parse_str(value).ok())
- .ok_or(RelayError::InvalidRoomId)?;
- let rows = sqlx::query("SELECT user_id FROM room_members WHERE room_id = $1")
- .bind(room_id)
- .fetch_all(&state.pool)
- .await
- .map_err(|error| {
- warn!(%error, %room_id, "failed to load room members for signaling");
- RelayError::Database
- })?;
- let member_ids = rows
- .into_iter()
- .filter_map(|row| row.try_get::("user_id").ok())
- .collect::>();
- if !member_ids.contains(&sender_id) {
+ let row = sqlx::query(
+ r#"SELECT
+ EXISTS(
+ SELECT 1 FROM room_members
+ WHERE room_id = $1 AND user_id = $2
+ ) AS authorized,
+ (
+ SELECT user_id FROM room_members
+ WHERE room_id = $1 AND user_id <> $2
+ LIMIT 1
+ ) AS peer_id"#,
+ )
+ .bind(room_id)
+ .bind(sender_id)
+ .fetch_one(&state.pool)
+ .await
+ .map_err(|error| {
+ warn!(%error, %room_id, "failed to load room members for signaling");
+ RelayError::Database
+ })?;
+ let authorized = row
+ .try_get::("authorized")
+ .map_err(|_| RelayError::Database)?;
+ if !authorized {
return Err(RelayError::Forbidden);
}
-
- let mut delivered = false;
- for member_id in member_ids.into_iter().filter(|id| *id != sender_id) {
- if let Some(target) = state.ws_hub.get(&member_id.to_string()) {
- target
- .try_send(Message::Text(original_text.to_string().into()))
- .map_err(|error| {
- warn!(%error, user_id = %member_id, %room_id, "signaling queue unavailable");
- RelayError::PeerUnavailable
- })?;
- delivered = true;
- }
- }
- if delivered {
- Ok(())
- } else {
- Err(RelayError::PeerUnavailable)
- }
+ let peer_id = row
+ .try_get::, _>("peer_id")
+ .map_err(|_| RelayError::Database)?
+ .ok_or(RelayError::PeerUnavailable)?;
+ let target = state
+ .ws_hub
+ .get(&peer_id)
+ .ok_or(RelayError::PeerUnavailable)?;
+ target.try_send(original.clone()).map_err(|error| {
+ warn!(%error, user_id = %peer_id, %room_id, "signaling queue unavailable");
+ RelayError::PeerUnavailable
+ })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RelayError {
- InvalidRoomId,
Forbidden,
PeerUnavailable,
Database,
@@ -333,7 +367,6 @@ enum RelayError {
impl RelayError {
fn code(self) -> &'static str {
match self {
- Self::InvalidRoomId => "bad_request",
Self::Forbidden => "forbidden",
Self::PeerUnavailable => "peer_unavailable",
Self::Database => "internal_error",
@@ -342,7 +375,6 @@ impl RelayError {
fn message(self) -> &'static str {
match self {
- Self::InvalidRoomId => "payload must contain a valid room_id",
Self::Forbidden => "sender is not a room member",
Self::PeerUnavailable => "peer signaling connection is unavailable",
Self::Database => "unable to authorize signaling message",
@@ -351,7 +383,7 @@ impl RelayError {
}
async fn send_error(
- socket: &mut WebSocket,
+ sender: &mpsc::Sender,
code: &str,
message: &str,
request_id: Option<&str>,
@@ -364,12 +396,15 @@ async fn send_error(
),
None => Envelope::new("error", json!({ "code": code, "message": message })),
};
- send_envelope(socket, envelope).await
+ send_envelope(sender, envelope).await
}
-async fn send_envelope(socket: &mut WebSocket, envelope: Envelope) -> Result<(), ()> {
+async fn send_envelope(
+ sender: &mpsc::Sender,
+ envelope: Envelope,
+) -> Result<(), ()> {
let text = serde_json::to_string(&envelope).map_err(|_| ())?;
- socket
+ sender
.send(Message::Text(text.into()))
.await
.map_err(|_| ())
@@ -410,57 +445,62 @@ mod tests {
#[test]
fn match_endpoint_requires_a_socket_address_and_uuid_nonce() {
- let valid = Envelope::new(
- "match.endpoint",
+ let valid = RawValue::from_string(
json!({
"room_id": Uuid::new_v4(),
"endpoint": "192.168.1.20:42000",
"nonce": Uuid::new_v4()
- }),
- );
+ })
+ .to_string(),
+ )
+ .expect("raw payload");
assert!(validate_match_endpoint(&valid).is_ok());
- let invalid_endpoint = Envelope::new(
- "match.endpoint",
+ let invalid_endpoint = RawValue::from_string(
json!({
"room_id": Uuid::new_v4(),
"endpoint": "not-an-endpoint",
"nonce": Uuid::new_v4()
- }),
- );
+ })
+ .to_string(),
+ )
+ .expect("raw payload");
assert!(validate_match_endpoint(&invalid_endpoint).is_err());
- let invalid_nonce = Envelope::new(
- "match.endpoint",
+ let invalid_nonce = RawValue::from_string(
json!({
"room_id": Uuid::new_v4(),
"endpoint": "192.168.1.20:42000",
"nonce": "predictable"
- }),
- );
+ })
+ .to_string(),
+ )
+ .expect("raw payload");
assert!(validate_match_endpoint(&invalid_nonce).is_err());
}
#[test]
fn match_completion_requires_bounded_frames_and_a_checksum() {
- let valid = Envelope::new(
- "match.probe.completed",
+ let valid = RawValue::from_string(
json!({
"room_id": Uuid::new_v4(),
"frames_received": 60,
"transcript_checksum": "0376c2e852f4fd25"
- }),
- );
+ })
+ .to_string(),
+ )
+ .expect("raw payload");
assert!(validate_match_completion(&valid).is_ok());
- let invalid = Envelope::new(
- "match.probe.completed",
+ let invalid = RawValue::from_string(
json!({
"room_id": Uuid::new_v4(),
"frames_received": 0,
"transcript_checksum": "not-a-checksum"
- }),
- );
+ })
+ .to_string(),
+ )
+ .expect("raw payload");
assert!(validate_match_completion(&invalid).is_err());
}
}
diff --git a/apps/server/tests/api_flow.rs b/apps/server/tests/api_flow.rs
index 6326baa..cc1a02f 100644
--- a/apps/server/tests/api_flow.rs
+++ b/apps/server/tests/api_flow.rs
@@ -138,7 +138,7 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) {
let (games_status, games) =
request(&app, Method::GET, "/api/v1/games", Some(&host_token), None).await;
assert_eq!(games_status, StatusCode::OK);
- assert_eq!(games.payload["games"].as_array().map(Vec::len), Some(5));
+ assert_eq!(games.payload["games"].as_array().map(Vec::len), Some(6));
let (oversized_room_status, _) = request(
&app,
@@ -639,7 +639,7 @@ async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool
"core_sha256": "b".repeat(64),
"content_sha256": "c".repeat(64)
});
- for token in [&host_token, &guest_token] {
+ for (token, player_slot) in [(&host_token, 1), (&guest_token, 2)] {
let (status, _) = request(
&app,
Method::POST,
@@ -648,7 +648,11 @@ async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool
Some(json!({
"room_id": room_id,
"compatibility": compatibility,
- "native_port_available": true
+ "native_port_available": true,
+ "controller": {
+ "connected": true,
+ "player_slot": player_slot
+ }
})),
)
.await;
@@ -676,6 +680,7 @@ async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool
assert_eq!(snapshot_status, StatusCode::OK);
assert_eq!(snapshot.payload["preflight_count"], 2);
assert_eq!(snapshot.payload["compatibility_matched"], true);
+ assert_eq!(snapshot.payload["controller_ready_count"], 2);
assert_eq!(snapshot.payload["barrier"]["ready_count"], 2);
assert!(snapshot.payload["barrier"]["launch_at"].is_string());
let launch_at = snapshot.payload["barrier"]["launch_at"].clone();
@@ -771,6 +776,133 @@ async fn community_alpha_invite_preflight_barrier_and_evidence_flow(pool: PgPool
assert_eq!(campaign.payload["failed"], 1);
}
+#[sqlx::test]
+async fn verified_match_receipt_is_idempotent_and_preserves_public_privacy(pool: PgPool) {
+ sqlx::migrate!("./migrations")
+ .run(&pool)
+ .await
+ .expect("migrations should succeed");
+ let app = build_app(AppState::new(pool.clone(), Config::for_test()));
+ let host_token = register(&app, "receipt_host").await;
+ let guest_token = register(&app, "receipt_guest").await;
+ let next_guest_token = register(&app, "receipt_next_guest").await;
+ let (_, room) = request(
+ &app,
+ Method::POST,
+ "/api/v1/rooms",
+ Some(&host_token),
+ Some(json!({ "game_id": "opencade_test" })),
+ )
+ .await;
+ let room_id = room.payload["id"].as_str().expect("room id");
+ let (_, invite) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/invite"),
+ Some(&host_token),
+ None,
+ )
+ .await;
+ let (join_status, _) = request(
+ &app,
+ Method::POST,
+ "/api/v1/invites/join",
+ Some(&guest_token),
+ Some(json!({ "code": invite.payload["code"] })),
+ )
+ .await;
+ assert_eq!(join_status, StatusCode::OK);
+ sqlx::query("UPDATE rooms SET state = 'FINISHED' WHERE id = $1")
+ .bind(uuid::Uuid::parse_str(room_id).expect("room UUID"))
+ .execute(&pool)
+ .await
+ .expect("finish fixture room");
+
+ let compatibility = json!({
+ "adapter": "retroarch_test",
+ "emulator_version": "1.22.0",
+ "executable_sha256": "a".repeat(64),
+ "core_sha256": "b".repeat(64),
+ "content_sha256": "c".repeat(64)
+ });
+ for (token, role) in [(&host_token, "host"), (&guest_token, "guest")] {
+ let evidence = json!({
+ "schema_version": 1,
+ "exported_at": chrono::Utc::now(),
+ "room": { "id": room_id, "game_id": "opencade_test", "state": "finished" },
+ "probe": {
+ "role": role,
+ "transport": "direct_udp",
+ "frames_sent": 64,
+ "frames_received": 60,
+ "transcript_checksum": "0123456789abcdef",
+ "elapsed_ms": 240,
+ "nat": "mapped",
+ "candidate": "host",
+ "punch_attempts": 2
+ },
+ "client": { "platform": "windows", "user_agent": "integration-test" },
+ "compatibility": compatibility,
+ "native_route": "direct_lan"
+ });
+ let (status, _) = request(
+ &app,
+ Method::POST,
+ "/api/v1/alpha/evidence",
+ Some(token),
+ Some(json!({ "evidence": evidence })),
+ )
+ .await;
+ assert_eq!(status, StatusCode::CREATED);
+ }
+
+ let (public_status, public) = request(
+ &app,
+ Method::GET,
+ "/api/v1/public/compatibility",
+ None,
+ None,
+ )
+ .await;
+ assert_eq!(public_status, StatusCode::OK);
+ assert_eq!(public.payload["minimum_cohort_size"], 3);
+ assert_eq!(public.payload["cohorts"], json!([]));
+
+ let idempotency_key = uuid::Uuid::new_v4();
+ let (receipt_status, receipt) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/receipt"),
+ Some(&host_token),
+ Some(json!({ "idempotency_key": idempotency_key })),
+ )
+ .await;
+ assert_eq!(receipt_status, StatusCode::CREATED);
+ assert_eq!(receipt.payload["result"], "verified");
+ assert_eq!(receipt.payload["route"], "direct_lan");
+ let (replay_status, replay) = request(
+ &app,
+ Method::POST,
+ &format!("/api/v1/rooms/{room_id}/receipt"),
+ Some(&host_token),
+ Some(json!({ "idempotency_key": idempotency_key })),
+ )
+ .await;
+ assert_eq!(replay_status, StatusCode::OK);
+ assert_eq!(replay.payload["receipt_id"], receipt.payload["receipt_id"]);
+
+ let (next_join_status, next_room) = request(
+ &app,
+ Method::POST,
+ "/api/v1/invites/join",
+ Some(&next_guest_token),
+ Some(json!({ "code": receipt.payload["invite"]["code"] })),
+ )
+ .await;
+ assert_eq!(next_join_status, StatusCode::OK);
+ assert_eq!(next_room.payload["game_id"], "opencade_test");
+}
+
#[sqlx::test]
async fn logout_revokes_the_current_session(pool: PgPool) {
sqlx::migrate!("./migrations")
@@ -947,6 +1079,14 @@ async fn product_telemetry_is_private_idempotent_bounded_and_aggregated(pool: Pg
)
.await;
assert_eq!(status, StatusCode::CREATED);
+ let maintenance_state =
+ opencade_server::AppState::new(pool.clone(), opencade_server::Config::for_test());
+ assert_eq!(
+ opencade_server::lifecycle::reconcile_telemetry_retention(&maintenance_state)
+ .await
+ .expect("run telemetry retention"),
+ 1
+ );
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM product_events WHERE event_id = $1")
.bind(expired_id)
diff --git a/crates/proof-of-match/src/lib.rs b/crates/proof-of-match/src/lib.rs
index d5f110e..9fdf3bd 100644
--- a/crates/proof-of-match/src/lib.rs
+++ b/crates/proof-of-match/src/lib.rs
@@ -330,6 +330,7 @@ fn build_report(
// (FBNeo / RetroArch) go through `verify_playable_match_reports` instead, which
// requires `Some(_)` on both sides.
compatibility: None,
+ native_route: None,
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index e152183..156aa98 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -39,6 +39,8 @@ services:
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_EVIDENCE_TCP_TUNNEL: ${OPENCADE_EVIDENCE_TCP_TUNNEL:-false}
+ ROUTE_POLICY_MIN_ATTEMPTS: ${ROUTE_POLICY_MIN_ATTEMPTS:-10}
opencade-server:
build:
@@ -53,7 +55,7 @@ services:
ports:
- "8080:8080"
healthcheck:
- test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 5
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e53cf7d..011b544 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -303,7 +303,7 @@ Consequences:
and `opencade-relay` on `8081`; peers still prefer direct UDP.
- No Redis, no separate signaling service, and no message queue in MVP. Presence remains in the
control plane. Readiness frames use the probe capability; an isolated, bounded native TCP tunnel
- capability exists but remains route-policy gated pending physical validation.
+ capability exists and remains fail-closed behind ADR 0006's operator-plus-evidence route policy.
- The relay accepts only short-lived room-member tickets issued by the control plane and limits
rooms to two users and bounded frames. Capability-scoped tickets keep readiness probes isolated
from the experimental native TCP tunnel described in ADR 0005.
@@ -675,7 +675,8 @@ whose payload room matches the signed room; bounded binary probe frames remain o
Probe tickets carry only OpenCade readiness frames. Native-tunnel tickets use a separate signed
capability and bounded TCP-over-WebSocket framing; the match coordinator does not select that route
-until ADR 0005's physical validation gate passes. The relay is not TURN.
+unless ADR 0006's operator flag, minimum physical-attempt count, and 80% verified-pair gate all
+pass. The default route remains direct LAN. The relay is not TURN.
### 10.4 Latency — RTT / loss / jitter
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 5865550..a58719a 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,6 +1,6 @@
# Implementation status
-Updated 2026-08-25.
+Updated 2026-08-28.
## Implemented and automated
@@ -65,6 +65,16 @@ Updated 2026-08-25.
bin + `crates/proof-of-match/tests/proof_of_match.rs`).
- PostgreSQL, WebSocket, lifecycle, safe-launch, mock-match, proof-of-match, UDP, NAT, relay,
two-process, TypeScript, and MSRV checks in CI.
+- An original Apache-2.0 deterministic libretro test core plus inert `.ocade` content, built and
+ signed with the Windows alpha kit, for a complete Proof-of-Play path without ROMs, BIOS files,
+ firmware, or a third-party core.
+- Controller and deterministic player-slot preflight in the synchronized launch barrier.
+- Canonically verified, idempotent post-match receipts that create a fresh one-use same-game invite.
+- A k-anonymous public compatibility aggregate and static evidence map that never exposes raw
+ reports, identities, endpoints, paths, hashes, or invite data.
+- A per-game native route policy that fails closed to direct LAN and selects the TCP tunnel only
+ with relay configuration, explicit operator enablement, a bounded physical-attempt minimum, and
+ an 80% canonical verified-pair rate.
## Deliberately not claimed
@@ -77,5 +87,6 @@ Updated 2026-08-25.
- Production installer packaging/signing, friends/chat/rankings/replays, or a public MVP release.
- Ten two-machine LAN matches and a 20-person community alpha; these require external testers and
real Windows hosts. Use `docs/alpha/LAN_TEST.md` to collect that evidence.
-- Automatic selection of the native TCP tunnel as a gameplay route. ADR 0005 requires physical
- two-host proof before enabling that fallback in the match coordinator.
+- Evidence-qualified automatic TCP-tunnel selection has no physical two-host evidence yet. It is
+ implemented but operator-disabled by default under ADR 0006; enabling it without the documented
+ campaign would be an unsupported deployment claim.
diff --git a/docs/NEXT_IMPLEMENTATIONS.md b/docs/NEXT_IMPLEMENTATIONS.md
index e86b60a..b678566 100644
--- a/docs/NEXT_IMPLEMENTATIONS.md
+++ b/docs/NEXT_IMPLEMENTATIONS.md
@@ -1,381 +1,68 @@
-# OpenCade Next Implementations
+# OpenCade current priorities
-> Reviewed: 2026-08-23
-> Scope: `README.md`, `AGENTS.md`, `docs/ARCHITECTURE.md`, clean-room guardrails,
-> workspace code, migrations, CI, and local build/test results.
+> Reviewed: 2026-08-28. This is the active execution queue. Historical opportunity analyses remain
+> available in Git history and [`OPPORTUNITIES_2026-08-28.md`](OPPORTUNITIES_2026-08-28.md).
-> **Implementation update (2026-08-23):** the automated Proof-of-Match plan below has been
-> implemented. See `docs/IMPLEMENTATION_STATUS.md` for the verified result and the remaining
-> real-hardware/NAT validation work. The original review is retained as the decision record.
+## Verified baseline
-> **Traversal update (2026-08-24):** RFC 8489 discovery, bounded nonce-bound hole punching, and
-> automated 8-of-10 campaign aggregation are now implemented. Physical Windows evidence remains
-> the gate; cone/symmetric classification and relay fallback remain deferred. The sequencing below
-> is retained as the original pre-evidence plan.
+OpenCade has an authenticated room/challenge flow, versioned signaling, bounded UDP/relay probes,
+RetroArch launch authorization, deterministic proof-of-match evidence, controller preflight,
+privacy-safe compatibility aggregates, continuation receipts, and a no-ROM libretro test core.
+Rust workspace tests, PostgreSQL integration tests, desktop tests, TypeScript tests, Clippy,
+dependency audits, production builds, Compose validation, and workflow linting pass locally.
-> **Proof-of-Play update (2026-08-24):** ADR 0003, the user-supplied RetroArch/FBNeo-core
-> native-process adapter, compatibility fingerprints, signed relay tickets, bounded relay fallback,
-> runtime-configurable Windows alpha executable, and automated tests are implemented. The next gate
-> is execution, not another subsystem: run the physical two-Windows campaign in
-> `docs/alpha/RETROARCH_TEST.md` and `docs/alpha/LAN_TEST.md` without fabricating results.
+Physical two-host Windows/NAT evidence remains an external product gate. Do not claim broad
+compatibility or Internet playability from loopback tests.
-> **Campaign-kit update (2026-08-24):** CI now produces a flat, checksummed Windows alpha kit. Its
-> shared PowerShell entrypoint packages and verifies the artifact in CI, then verifies API
-> health/readiness, the user-supplied RetroArch layout, optional STUN syntax, and runtime launch
-> configuration on tester machines. The remaining gate is still physical execution and paired
-> report collection.
+## P0 — deterministic vertical alpha certification
-> **Evidence-integrity update (2026-08-24):** abandoned rooms now export a distinct redacted
-> failure-evidence record with an enum stage and validated stable code. Campaign aggregation counts
-> unique successful or failed room IDs, separates direct/relay rows, and rejects conflicting
-> success/failure evidence. This removes survivorship bias from the 8-of-10 gate.
+Build one Windows CI command that launches two packaged clients with the deterministic no-ROM core,
+drives the real API/signaling/preflight/barrier/native-launch path, uploads both reports, and verifies
+a continuation receipt.
-> **Native-lifecycle hardening (2026-08-24):** opportunities scoring above 20 are implemented in
-> ADR 0004: server-derived one-use launch authorization, two-participant launch/exit state,
-> supervised processes, a deterministic client coordinator, strict compatibility evidence,
-> authentication throttling/timing equalization, secure desktop token storage, and a narrowed CSP.
-> Relay and reflexive UDP probes are deliberately readiness-only for the RetroArch TCP alpha.
+Exit criteria:
-## Recommendation: build the executable Proof of Match
+- observable readiness conditions; no timing-only sleeps;
+- real packaged binaries and test core, with no ROM/emulator assets;
+- one repeatable loopback happy path blocks release regressions;
+- fewer than ten new files and a documented local reproduction command.
-The next implementation should be one narrow, deterministic vertical slice:
+Kill criterion: re-scope if the first deterministic loopback path exceeds seven engineering days.
+Do not add NAT simulation or a new test framework before the loopback slice works.
-> Two authenticated users select one game, challenge and accept, establish a transport session,
-> start two mock emulator adapters with the same match descriptor, exchange deterministic input
-> frames, transition the room to `FINISHED`, and preserve a correlated log for the whole run.
+## P1 — protocol evolution and typed boundaries
-This is the keystone because it forces the protocol, room state machine, authentication, WebSocket
-hub, client orchestration, networking boundary, and emulator adapter boundary to become one real
-system. It also tests the largest unresolved assumption: how an established peer session reaches
-the emulator's actual netplay/input loop.
+- Decide the backward-compatible path from protocol `"1.0"` after required controller/snapshot
+ fields were added. Add old-client/new-server and new-client/old-server contract tests.
+- Generate a message registry mapping name, payload, direction, and version from Rust. Use it for
+ TypeScript unions, client sends, fixtures, exports, and reference documentation.
+- Add runtime decoders to remaining HTTP and Tauri IPC boundaries; launch-critical room snapshots
+ are already validated.
-Do not implement TURN, friends, reports, replay, multiple adapters, or polished route coverage
-before this slice works on a LAN with a mock adapter and has a documented path to a real adapter.
+## P1 — production topology and release safety
-## What is actually implemented
+- Explicitly support one control-plane and one relay replica until cross-instance delivery,
+ ticket replay protection, session logout, and room affinity are designed.
+- Publish immutable server/relay images with digests, SBOMs, and attestations. Until then, container
+ deployment means building and pinning the audited Git tag.
+- Test migrations from the previous released schema with representative data and lock budgets.
+- Split database credentials into a DDL migrator role and least-privilege runtime role.
+- Replace cumulative-only latency metrics with bounded route histograms and explicit privacy
+ suppression state.
-The workspace has a useful M0/M1 foundation, but it is not yet an executable M1 product.
+## P2 — measured performance leverage
-| Area | Evidence in code | Assessment |
-| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
-| Build baseline | Rust formatting passes; Clippy passes; 39 Rust tests pass; TS packages build; 17 Vitest tests pass; client builds; Compose config parses | Healthy foundation |
-| Protocol | Rust envelope and payloads, TS mirror, generated TS files | Useful, but the handwritten TS and generated types can drift |
-| Database | Users/sessions and game/room migrations with five seeded games | Schema exists, but no migration path is run by the server/container |
-| Server | More complete handlers exist under `src/routes/` and `src/ws.rs` | Runtime bypasses them and routes duplicate stubs declared in `main.rs` |
-| Auth | Argon2 and hashed opaque-session implementation exists | Not wired; runtime returns `stub-jwt-token`; no `AuthUser` extractor |
-| Rooms | Handler code and protocol states exist | Queries do not match migrations (`slug`, UUID game ids, `host_id`, `guest_id`, lowercase states) |
-| WebSocket | Envelope validation exists in `src/ws.rs` | Not wired; runtime endpoint is an unauthenticated echo service |
-| Client | Vite/Tauri shell and a placeholder Games component | `App.tsx` does not route to Games; no API client, WS client, auth, or session flow |
-| Emulator | Minimal trait and TOML game loader | FBNeo adapter is empty; no safe process runtime; no match/network context reaches the adapter |
-| Networking | Placeholder crate | No direct transport, hole punching, latency, or relay implementation |
-| Desktop security | Tauri allowlist exists | `shell-open` is enabled and CSP is null, contradicting the repository guardrails |
-| Relay/docs | README and architecture describe a relay service and broad route tree | `services/relay`, most routes, protocol docs, and integration tests do not exist |
+- Cache executable/core/content fingerprints by canonical path, size, modification time, and cache
+ schema version. Keep launch-time integrity checks and require an 80% warm-preflight improvement.
+- Move high-risk SQL to checked `sqlx` macros with committed offline metadata.
+- Split the large room/alpha route modules and Match screen only around measured lifecycle and
+ rendering boundaries; preserve transactions and protocol behavior.
-### The critical architecture gap
+## Physical alpha gate
-The design currently stops at two disconnected facts:
+Follow [`alpha/RETROARCH_TEST.md`](alpha/RETROARCH_TEST.md) and [`alpha/LAN_TEST.md`](alpha/LAN_TEST.md).
+The decision threshold remains at least 8 verified attempts from 10 independent room attempts,
+with failures retained rather than discarded.
-1. `packages/networking` should establish direct UDP or a relay path.
-2. `EmulatorAdapter::launch` starts a process with a ROM path.
-
-There is no contract for passing peer endpoints, role, session identity, input delay, transport
-handles, or synchronized input frames into the emulator. WebRTC-style SDP signaling does not by
-itself connect a native arcade emulator to rollback netplay, and a WebSocket signaling relay is not
-automatically a game-input relay.
-
-This must be resolved before NAT traversal or UI breadth. Otherwise the project can produce a
-working lobby and launcher without producing netplay.
-
-## Quick opportunities scan
-
-### Existing assets
-
-- **Product:** coherent clean-room architecture and a compiling Rust/TypeScript monorepo.
-- **Content:** authoritative architecture, contribution rules, guardrails, and an active Discord
- invitation.
-- **Audience/distribution:** the Discord community is the only evidenced channel; its size and
- activity are not measured in the repository.
-- **Technology:** Axum/Postgres skeleton, versioned protocol, Tauri shell, game definitions, adapter
- trait, Docker and CI.
-- **Data:** five game definitions and no product telemetry or completed-match data.
-- **Revenue/pricing:** none evidenced; pricing work is premature for an open-source pre-alpha.
-
-### Top three combinations
-
-#### 1. Proof-of-Match contract — 23/25
-
-- **Combination:** protocol + room/auth handlers + client shell + adapter interface, connected by
- one executable scenario.
-- **Tier:** T1, combinatorial.
-- **Effort:** 4–6 focused weeks for one developer.
-- **Impact:** converts many independent skeletons into one falsifiable product claim.
-- **First step:** write the control-plane/data-plane and netplay-adapter ADR described in Gate 0.
-
-#### 2. Runtime truth consolidation — 21/25
-
-- **Combination:** existing production-intent modules + migrations + current test suite, connected
- through one `build_app` composition root.
-- **Tier:** T1, combinatorial.
-- **Effort:** 3–5 days.
-- **Impact:** replaces false-positive stub behavior with testable failures and makes later work land
- on the actual runtime.
-- **First step:** delete the duplicate handlers/types in `main.rs` and route through the modules.
-
-#### 3. Community alpha loop — 17/25
-
-- **Combination:** Discord + deterministic two-peer harness + correlated diagnostics.
-- **Tier:** T3, channel leverage.
-- **Effort:** 2–3 days after Proof of Match.
-- **Impact:** turns community interest into reproducible compatibility and connectivity evidence.
-- **First step:** publish a two-machine alpha script and a structured match report template.
-
-### Bottleneck flip
-
-- **Current bottleneck:** no executable path proves that matchmaking becomes a synchronized game.
-- **If removed:** every subsequent feature can be judged by completed-match rate rather than files,
- routes, or milestone labels.
-- **Removal:** make `proof_of_match` the top-level integration test and alpha demo.
-
-### Pricing test
-
-Not applicable. OpenCade has no evidenced monetization or active usage baseline. The useful test is
-adoption: can 20 community testers complete 10 cross-machine matches with at least 80% successful
-connection-and-launch attempts? Monetization should be reconsidered only after that signal exists.
-
-## Strategic opportunity scan
-
-### Grove: forces
-
-- **Technology:** Rust/Tauri and public networking standards make a small, self-hostable control
- plane feasible without a large operations team.
-- **Customer:** fighting-game communities value low-latency matches and durable community ownership,
- but repository evidence does not yet quantify demand.
-- **Competition:** proprietary incumbent behavior creates room for transparency and self-hosting;
- competitor timing is not assessed here and should not be invented from code.
-- **Complementors:** external emulators, public rollback literature, Docker, and community-operated
- servers reduce how much OpenCade must own.
-- **Constraint:** emulator integration is the underpriced force. Matchmaking is conventional;
- producing a legal, maintainable netplay seam is the hard differentiator.
-
-### Thiel: the secret
-
-Most netplay-platform roadmaps treat lobby, auth, signaling, NAT, and emulator launch as separable
-milestones. The non-consensus truth is that the product is the seam between them. A deterministic,
-contract-tested match kernel is more valuable than broad but disconnected feature coverage.
-
-### Yeo: the keystone
-
-`proof_of_match` integration contract
-→ one canonical protocol
-→ one enforceable room state machine
-→ a real transport-to-adapter boundary
-→ reproducible diagnostics
-→ community alpha evidence
-→ safe expansion to NAT/relay and additional adapters.
-
-### Jobs: category definition
-
-“OpenCade is the self-hostable, clean-room arcade netplay stack whose entire match lifecycle is
-executable and auditable.”
-
-This makes feature-count comparison less important. To make the statement true, say **no** to route
-breadth and emulator breadth until one complete match is reproducible.
-
-### Naval: leverage audit
-
-- **Permissionless:** code, documentation, CI, and community distribution require no enterprise
- sales or proprietary service dependency.
-- **Specific knowledge:** the valuable intersection is Rust systems work, desktop security,
- networking, emulator process integration, and clean-room discipline.
-- **Compounding asset:** each adapter and transport can reuse the same match contract and test kit.
-- **Leverage warning:** maintaining a custom TURN-like relay or emulator fork too early creates
- ongoing labor. Prefer public protocols and externally managed emulator binaries behind contracts.
-
-### Bezos: regret test
-
-The larger regret is spending months completing peripheral milestones before discovering that the
-chosen emulator cannot consume the negotiated session. The feasibility spike is a reversible,
-low-cost decision; take it now.
-
-### Quantitative sizing and affordable loss
-
-Repository evidence is insufficient for honest TAM, revenue EV, or competitor-timing estimates.
-Do not fabricate them.
-
-- **Reachable 90-day validation segment:** the existing Discord community.
-- **SOM proxy:** 20 alpha participants and 10 completed cross-machine matches.
-- **Product success threshold:** ≥80% connection-and-launch success on the supported LAN test matrix.
-- **Engineering budget:** six focused weeks, approximately 120–180 hours.
-- **Cash affordable loss:** local hardware plus less than US$100 of hosted test infrastructure.
-- **Optionality:** real FBNeo support, other adapters, community relay nodes, contributor test kits,
- and eventually managed hosting.
-
-### Opportunity score
-
-| Dimension | Score | Reason |
-| ----------------------- | --------: | ----------------------------------------------------------------------------------- |
-| 10X force | 3/5 | Strong technical leverage; demand and market timing are unmeasured |
-| Secret quality | 4/5 | The transport-to-emulator seam is specific and currently missing |
-| Keystone leverage | 5/5 | One contract forces six subsystems to converge |
-| Lollapalooza | 3/5 | Code, standards, community, and clean-room positioning align; usage proof is absent |
-| Category ownership | 3/5 | Auditable self-hosted netplay is distinctive but externally unvalidated |
-| Permissionless leverage | 5/5 | Built primarily with code, docs, CI, and community |
-| Regret asymmetry | 4/5 | Cheap to test now; expensive to discover late |
-| Timing window | 3/5 | Open, but no evidence that it is closing within 6–18 months |
-| **Total** | **30/40** | Strong asymmetric implementation bet; start this week |
-
-## Six-week implementation plan
-
-### Gate 0 — prove the netplay seam (days 1–3)
-
-Write an ADR, using public specifications and the clean-room process, that separates:
-
-- **Control plane:** auth, presence, challenge, room state, endpoint negotiation.
-- **Data plane:** latency-sensitive game inputs carried direct or through an explicit relay.
-- **Adapter boundary:** how an established `MatchDescriptor` reaches the emulator.
-
-The initial contract should carry at least:
-
-```rust
-struct MatchDescriptor {
- room_id: String,
- game_id: String,
- local_user_id: String,
- peer_user_id: String,
- role: PeerRole,
- transport: TransportKind,
- local_endpoint: SocketAddr,
- peer_endpoint: SocketAddr,
- input_delay_frames: u8,
-}
-```
-
-The exact API may change in the ADR, but the information cannot disappear. Implement a
-`MockAdapter` and deterministic two-peer input exchange before claiming FBNeo support.
-
-**Exit gate:** a documented, legally usable path exists for a real emulator to consume match
-parameters. If FBNeo cannot do so through a public interface or clean-room-designed bridge, change
-the product claim or select a different externally managed emulator before continuing.
-
-### Phase 1 — make the runtime truthful (week 1)
-
-1. Replace `main.rs` duplicates with a small composition root and a reusable `build_app(state)`.
-2. Wire `config`, `error`, `state`, `routes`, and `ws` modules into the actual router.
-3. Choose the canonical schema already documented: text game ids, `host_user_id`, `room_members`,
- and one uppercase-at-rest/lowercase-on-wire state representation.
-4. Update all SQL to that schema; never turn database errors into successful seeded/stub responses.
-5. Add `--migrate` and make the container run migrations before serving.
-6. Make `/health` liveness-only and `/ready` verify Postgres without returning raw DB errors.
-7. Remove Tauri `shell-open`, set a restrictive CSP, and enforce production session-secret checks.
-8. Add CI checks for `pnpm -r test`, typecheck, `docker compose config`, and Rust 1.98 MSRV.
-
-**Exit:** a clean database migrates, five real games are returned, bad DB state fails visibly, and
-router integration tests exercise the same app that `main` serves.
-
-### Phase 2 — two-user control plane (weeks 2–3)
-
-1. Make Rust protocol types authoritative; generate TS bindings in CI and fail on diff.
-2. Split signaling into message-specific payloads instead of one optional-field bag.
-3. Add `AuthUser` extraction from hashed opaque sessions; protect REST and WebSocket endpoints.
-4. Key the WS hub by authenticated user id, use bounded queues, and reject duplicate/stale sockets
- deterministically.
-5. Implement a pure, unit-tested room transition function and persist transitions transactionally.
-6. Add durable or explicitly in-memory challenges with create/accept/decline/cancel ownership rules.
-7. Relay session negotiation only to authenticated room members; cap payload size and rate.
-8. Preserve incoming `request_id` in acknowledgements and correlated errors.
-9. Add a Postgres-backed integration test with two users:
- register → login → challenge → accept → negotiate → `CONNECTING` → finish/cancel.
-
-**Exit:** no runtime stubs, no unauthenticated room mutation, and one automated two-user control-plane
-test passes against Postgres.
-
-### Phase 3 — minimum client path (week 4)
-
-Implement only the routes needed by Proof of Match:
-
-1. Login/register.
-2. Games list with local availability status.
-3. One game lobby with presence and challenge action.
-4. Incoming challenge acceptance.
-5. Connecting/playing/finished state screen.
-
-Add a typed REST client, authenticated WS client, reconnect backoff, request correlation, and minimal
-session state. Do not build Friends, Servers, or a design system yet.
-
-**Exit:** two client instances complete the control-plane flow without manual HTTP/WS tools.
-
-### Phase 4 — safe native execution and mock match (week 5)
-
-1. Expand `emulator-sdk` with `DetectedEmulator`, `ValidationReport`, `MatchDescriptor`, and a
- testable process-launch abstraction.
-2. Keep paths as `PathBuf`/`OsString`; canonicalize the binary and ROM; verify both are below
- configured roots; never use a shell.
-3. Implement Tauri commands for scan, validate, launch, stop, and diagnostics.
-4. Implement the FBNeo adapter only to the extent proven by Gate 0.
-5. Use tiny fixtures and a mock executable in automated tests—never ROMs or emulator binaries.
-6. Connect two mock adapters through the data-plane contract and exchange deterministic frames.
-
-**Exit:** a path containing spaces launches safely in tests, traversal is rejected, and two mock
-adapters complete a deterministic session from the same room descriptor.
-
-### Phase 5 — LAN alpha and evidence loop (week 6)
-
-1. Run on two Windows machines on the same LAN before implementing STUN or relay.
-2. Add structured correlation fields: `request_id`, `room_id`, `user_id`, `adapter`, transport,
- transition, and failure code—never tokens or full user paths.
-3. Add a one-click redacted match report/export.
-4. Publish one supported-game alpha script to Discord and collect structured outcomes.
-5. Record success rate, setup time, connection time, launch failures, and disconnect reasons.
-
-**Exit:** 10 completed cross-machine matches or enough structured failures to invalidate a core
-assumption. Only then schedule hole punching, STUN, and relay fallback.
-
-## Explicitly deferred
-
-- `services/relay` and TURN-like infrastructure.
-- Symmetric-NAT support and packet-loss tuning.
-- Friends, general chat, reports, bans, spectating, replay, rankings, and matchmaking queues.
-- Flycast/Snes9x or a broad game catalog.
-- Installer signing, auto-update, production metrics, and UI redesign.
-- Monetization or hosted-service pricing.
-
-These are not rejected. They are downstream of a completed match and should be prioritized using
-observed alpha failure rates.
-
-## Pre-mortem: it is six months later and this failed
-
-| Cause of failure | Mitigation now |
-| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
-| FBNeo can launch a ROM but cannot consume OpenCade's session/input transport | Gate 0 before further product work; use a mock adapter and require a documented real-adapter path |
-| Stub success responses hide schema and state-machine failures | Remove fallbacks; test the composed router against migrated Postgres |
-| WebSocket signaling is mistaken for a suitable rollback data plane | Separate control/data contracts and measure the data plane independently |
-| Protocol copies drift across Rust, generated TS, and handwritten TS | One schema/type source plus a generation-diff CI gate |
-| Scope expands into relay, social features, and extra emulators before one match works | Enforce the deferred list and the phase exit gates |
-
-## Kill and re-scope criteria
-
-- **End of day 3:** no credible real-emulator netplay seam → stop using “rollback netplay” as an
- implemented promise and resolve emulator strategy first.
-- **End of week 1:** the composed server cannot migrate and serve real data → stop feature work and
- finish runtime consolidation.
-- **End of week 3:** two authenticated clients cannot complete challenge and session negotiation in
- an automated test → do not start native integration.
-- **End of week 5:** mock adapters cannot complete deterministic frame exchange → do not start NAT
- traversal or relay work.
-- **End of week 6:** fewer than 10 completed LAN matches, or below 80% connection-and-launch success
- without a concentrated fixable cause → re-scope the architecture before adding features.
-
-## Execution commitment
-
-- **Wish:** a repeatable two-player match from login to clean shutdown.
-- **Outcome:** future adapters and transports plug into one audited contract.
-- **Obstacle:** the repository rewards visible breadth—new crates, routes, and milestones—before
- proving the hidden emulator/netplay seam.
-- **Plan:** if a task does not increase the probability or observability of `proof_of_match`, defer it
- until the six-week gate is passed.
-
-**Ship this week:** runtime truth consolidation plus the netplay-seam ADR and mock contract.
-
-**Build this quarter:** the executable Proof of Match and a 20-person community alpha.
-**Kill criteria:** no real-emulator seam by day 3, no automated two-user control plane by week 3, or
-no deterministic mock data-plane match by week 5.
+Do not expand into friends, broad emulator coverage, or polished discovery until certification and
+physical evidence demonstrate a reliable completed-match loop.
diff --git a/docs/OPPORTUNITIES_2026-08-28.md b/docs/OPPORTUNITIES_2026-08-28.md
new file mode 100644
index 0000000..7104883
--- /dev/null
+++ b/docs/OPPORTUNITIES_2026-08-28.md
@@ -0,0 +1,308 @@
+# OpenCade opportunity portfolio — 2026-08-28
+
+> Status: original decision record. Opportunities 1–5 (all scores 20+) were implemented on
+> 2026-08-28; their real-world outcome signals remain unmeasured until physical alpha sessions run.
+>
+> Scope: 20 opportunities not present in the previous Proof-of-Match implementation plan. The
+> portfolio is intentionally wider than the recommendation; only the top three should be calibrated
+> now, and only one strategic bet should receive focused execution.
+
+Implementation landed in ADR 0006, migration 009, the `opencade_test` game/core fixture, controller
+launch preflight, canonical post-match receipts, the evidence-gated per-game route policy, and the
+privacy-thresholded `/api/v1/public/compatibility` aggregate plus static compatibility site.
+
+## The one move
+
+Build a **No-ROM Proof-of-Play Kit**: an original, deterministic, redistributable libretro test core
+and harmless test content, packaged with the existing alpha doctor, invite flow, compatibility
+fingerprints, paired-report verifier, and campaign summary.
+
+This is the smallest structural move that removes the alpha's largest coordination burden. It lets
+testers exercise the real RetroArch native-process/netplay boundary without sourcing arcade content,
+agreeing on a ROM set, or debugging game-specific behavior first. It does **not** prove FBNeo game
+compatibility; it separates “does OpenCade orchestrate physical native netplay?” from “does this
+specific external core and content work?”
+
+The public libretro API is documented as a lightweight, permissively usable interface, while
+RetroArch netplay documents determinism, serialization, and identical core/content as core
+compatibility constraints. The implementation must be original and independently reviewed against
+the repository's Apache-2.0 dependency policy; RetroArch remains a user-supplied GPLv3 external
+process. See the official [libretro core-development documentation](https://docs.libretro.com/development/cores/developing-cores/),
+[RetroArch netplay documentation](https://docs.libretro.com/development/retroarch/netplay/), and
+[Libretro license matrix](https://docs.libretro.com/development/licenses/).
+
+## Quick opportunities scan
+
+### Asset inventory
+
+- **Product:** an executable Proof-of-Match control plane and experimental RetroArch Proof-of-Play
+ boundary.
+- **Content:** architecture, clean-room guardrails, five game definitions, alpha procedures, report
+ templates, and a flat Windows campaign kit.
+- **Audience/distribution:** a Discord community and GitHub contributors; repository evidence does
+ not quantify either audience.
+- **Technology:** authenticated rooms, one-use invites, synchronized launch, safe native process
+ execution, direct UDP/STUN probing, capability-scoped relay/tunnel primitives, compatibility
+ fingerprints, privacy-safe evidence, telemetry, and CI-built Windows artifacts.
+- **Data:** structured activation blockers and match/failure reports, but no committed physical
+ two-host evidence.
+- **Revenue:** donations are possible through Buy Me a Coffee; no usage or conversion baseline makes
+ pricing work premature.
+
+### Top three combinations
+
+#### 1. No-ROM Proof-of-Play Kit — 24/25
+
+- **Combination:** native-process adapter × alpha kit × match evidence × an original deterministic
+ libretro test core.
+- **Tier:** T1, combinatorial.
+- **Effort:** 3–5 focused weeks.
+- **Expected impact:** remove three major test variables—ROM provenance, content mismatch, and
+ game-specific behavior—and target a 3× increase in doctor-to-completed-test conversion.
+- **First step:** write an ADR specifying the minimal core behavior, license boundary, deterministic
+ state, two-player input visualization, content identity, and explicit non-claim about FBNeo.
+
+#### 2. Public evidence-derived compatibility map — 22/25
+
+- **Combination:** privacy-safe reports × campaign aggregator × GitHub Pages × community trust.
+- **Tier:** T1, combinatorial.
+- **Effort:** 1–2 weeks after the first physical evidence exists.
+- **Expected impact:** turn every accepted test into reusable setup guidance, contributor priorities,
+ and proof that claims are earned rather than advertised.
+- **First step:** define a k-anonymous aggregate schema with no room, user, path, endpoint, or raw
+ fingerprint disclosure.
+
+#### 3. Invite-to-match receipt loop — 21/25
+
+- **Combination:** one-use invites × synchronized launch × paired reports × Discord distribution.
+- **Tier:** T1/T3, combinatorial channel leverage.
+- **Effort:** 1–2 weeks after one physical Proof-of-Play pass.
+- **Expected impact:** make each successful test recruit the next tester and reduce manual alpha
+ coordination; target 25% of completed tests producing a second accepted invite.
+- **First step:** design a privacy-safe receipt containing only game definition, route class,
+ compatibility status, result, and an expiring invite—not identity or network data.
+
+### Bottleneck flip
+
+- **Current bottleneck:** physical Proof-of-Play requires two Windows hosts plus matching external
+ emulator/core/content before the product's orchestration can be isolated and tested.
+- **10× move if removed:** a tester can validate the native netplay seam without copyrighted game
+ content, creating evidence that compounds into compatibility guidance and alpha recruitment.
+- **How to remove it:** ship the original deterministic test core as a bounded conformance fixture,
+ then run the existing 10-room campaign against it before expanding game coverage.
+
+### Pricing test
+
+Not applicable. Do not add subscriptions, hosted tiers, or paid matchmaking before repeated physical
+play is demonstrated. The 90-day value test is positioning and adoption: 20 unique testers, 10
+accepted physical sessions, at least 8 verified pairs, and at least 3 independent Windows hardware
+profiles.
+
+## Twenty new opportunities
+
+Scores use the quick combination matrix: asset strength, connection clarity, sub-two-week effort,
+impact, and defensibility, each from 1–5. A lower effort score does not mean an idea is bad; it means
+it should not displace the keystone now. “90-day signal” is the first falsifiable result, not a
+forecast.
+
+| # | Opportunity | Tier | Score | Solo scope | 90-day signal | Say no to |
+| --: | --------------------------------------------- | ----- | ----: | ---------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| 1 | **No-ROM Proof-of-Play Kit** | T1 | 24/25 | 3–5 weeks | 8/10 verified physical test-core sessions | More emulator adapters before this campaign |
+| 2 | **Public evidence-derived compatibility map** | T1 | 22/25 | 1–2 weeks | Three hardware/network cohorts shown without small-cohort leakage | Publishing raw reports or fingerprints |
+| 3 | **Invite-to-match receipt loop** | T1/T3 | 21/25 | 1–2 weeks | 25% of verified sessions create one accepted follow-on invite | General-purpose social feeds and chat |
+| 4 | **Evidence-guided route policy** | T1 | 20/25 | 2–4 weeks | Route choice beats the fixed policy on a held-out campaign without false-positive playable routes | “AI NAT detection” or automatic tunnel enablement without evidence |
+| 5 | **Controller and player-slot preflight** | T1 | 20/25 | 1–2 weeks | Zero campaign failures caused by missing/wrong controller assignment | Controller remapping as a full settings product |
+| 6 | **One-command local alpha lab** | T1 | 19/25 | 1–2 weeks | A clean Windows host reaches a two-client local rehearsal in under 15 minutes | Production installer polish before physical validation |
+| 7 | **Compatibility fingerprint clinic** | T1 | 19/25 | 1 week | Mismatch diagnostics resolve 80% of preflight failures without exposing paths/hashes | A downloadable core/ROM catalog |
+| 8 | **Signed build provenance and SBOM** | T2 | 19/25 | 1 week | Every alpha binary and container has a verifiable CI provenance record | Paying for code signing before alpha retention exists |
+| 9 | **Alpha blocker-to-issue bridge** | T1 | 18/25 | 1–2 weeks | Repeated, k-anonymous blocker clusters generate actionable maintainer summaries | Auto-opening public issues containing user evidence |
+| 10 | **Game-definition contribution doctor** | T1 | 18/25 | 2 weeks | Five independently submitted definitions pass schema/license review | Bulk-importing proprietary manifests |
+| 11 | **Portable redacted support bundle** | T1 | 18/25 | 1–2 weeks | Median alpha triage requires no follow-up request for local logs | Collecting arbitrary logs, paths, or machine identifiers |
+| 12 | **Alpha match-night orchestrator** | T3 | 18/25 | 1 week | Ten scheduled pairs produce at least eight complete evidence pairs | Recurring events before one dry run succeeds |
+| 13 | **Self-host readiness scorecard** | T1 | 17/25 | 1–2 weeks | Three external operators pass an automated deploy/readiness checklist | A hosted commercial control plane now |
+| 14 | **Relay sponsor receipt** | T1/T3 | 17/25 | 1–2 weeks | Donations are attributed to measured relay uptime/capacity without paywalling play | Premium relay priority or selling user/network data |
+| 15 | **PT-BR alpha onboarding** | T3 | 17/25 | 1 week | Five Brazilian testers complete the doctor without English assistance | Broad localization before one region converts |
+| 16 | **Accessibility-first readiness mode** | T1 | 16/25 | 2–3 weeks | Keyboard-only and screen-reader users complete the preflight script | A visual redesign unrelated to task completion |
+| 17 | **Community relay qualification kit** | T6 | 16/25 | 3–5 weeks | Two external nodes pass capability, abuse, latency, and revocation tests | An open federation before ticket abuse controls are proven |
+| 18 | **LAN-party appliance profile** | T4 | 16/25 | 2–4 weeks | One organizer runs five local matches from a documented private deployment | Accountless internet play or bypassing authentication |
+| 19 | **Spectator feasibility spike** | T1 | 15/25 | 2 weeks | A public-interface-only prototype proves a read-only participant can join without changing player state | Shipping spectator UX before playable native evidence |
+| 20 | **Tournament desk pilot** | T4 | 14/25 | 3–5 weeks | One eight-player community bracket completes with auditable match receipts | Building brackets, rankings, and moderation as a platform |
+
+Relevant implementation surfaces are documented by their owners: GitHub supports
+[artifact build-provenance and SBOM attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations),
+and a custom Actions workflow can publish a
+[static GitHub Pages site](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site).
+These capabilities make opportunities 2 and 8 inexpensive to test; they are not evidence of user
+demand.
+
+### Calibration rule
+
+The table is a search space, not a backlog. Calibrate items 1–7, then apply an optimal-stopping
+rule: commit to the first later candidate that clearly beats the current 24/25 leader. Do not run
+20 workstreams. Items 17–20 are options gated by physical evidence, not near-term commitments.
+
+## Strategic opportunity scan
+
+### Grove: forces
+
+- **Technology:** the repository already has almost all orchestration and evidence primitives; an
+ original test core connects them to a real native netplay process at low marginal cost.
+- **Customer behavior:** early testers need a trustworthy “does my setup work?” path before they
+ need a broad game catalog. This is a hypothesis to measure, not a claimed market fact.
+- **Complementors:** RetroArch exposes public netplay behavior, including TCP hosting and relay
+ operation, but identical deterministic core/content remains required. OpenCade can make those
+ variables controlled for conformance testing.
+- **Distribution:** GitHub artifacts and Discord can distribute one identical fixture without a
+ proprietary download service.
+- **Regulation/licensing:** a deliberately original, non-game fixture avoids distributing ROMs and
+ separates the external GPLv3 process from Apache-2.0 OpenCade code. A license review is still a
+ release gate.
+- **Timing window:** open but not demonstrated to be closing. Urgency comes from project sequencing,
+ not a fabricated market deadline.
+
+### Thiel: the secret
+
+Most emulator-netplay projects treat game breadth as proof of product value. The contrarian truth is
+that, before breadth, a **boring deterministic conformance fixture** is more valuable: it makes
+network, orchestration, process, controller, and evidence failures independently diagnosable.
+
+### Yeo: the keystone move
+
+Original deterministic libretro fixture
+→ identical legal test inputs on every machine
+→ lower alpha setup variance
+→ more completed physical sessions
+→ trustworthy route/process evidence
+→ public aggregate compatibility guidance
+→ more testers and contributors
+→ safer expansion to FBNeo games, tunnels, spectators, and community relays.
+
+This is one reusable system fixture, not a campaign of unrelated features.
+
+### Munger/Klein: pre-mortem hard gate
+
+Assume it is February 2027 and the bet failed completely:
+
+1. **The fixture passes but proves nothing about real games.** Mitigation: state the non-claim in the
+ UI/report and require a separate FBNeo compatibility campaign.
+2. **The core becomes a new emulator project.** Mitigation: freeze scope to deterministic video,
+ two controller states, serialization, checksums, and clean exit; reject audio polish/gameplay.
+3. **License boundaries are ambiguous.** Mitigation: independently review the header/source license,
+ keep RetroArch external and user-supplied, generate an SBOM, and block release on uncertainty.
+4. **Testers still cannot coordinate two machines.** Mitigation: pair the fixture with one expiring
+ invite, synchronized launch, a local rehearsal, and a single match-night dry run.
+5. **Evidence collection leaks or overclaims.** Mitigation: reuse the closed report schema, require
+ paired verification, suppress small cohorts, and publish aggregates only.
+
+Affordable loss: cap the experiment at 5 weeks, 120 focused hours, and US$100 of infrastructure. If
+the fixture cannot reach a real two-host session inside that budget, stop rather than expanding it.
+
+### Jobs: category definition
+
+> OpenCade is the only clean-room arcade-netplay project with a redistributable, deterministic
+> Proof-of-Play conformance kit and evidence-derived compatibility claims.
+
+This makes “number of supported games” irrelevant during the alpha. Say **no** to friends, chat,
+rankings, replays, tournaments, additional adapters, and automatic internet tunnel routing until the
+conformance campaign passes.
+
+### Naval: leverage audit
+
+- **Permissionless:** original code, CI artifacts, documentation, and community distribution.
+- **Specific knowledge:** Rust/Tauri security, native process orchestration, networking, evidence
+ design, and clean-room discipline are already encoded in the repository.
+- **Wealth/compounding asset:** this is an open-source trust and testing asset rather than immediate
+ revenue. Each adapter, route, and release can reuse it.
+- **Long-term game:** contributors can submit verified compatibility evidence repeatedly; the value
+ grows without storing private player data.
+
+### Bezos: regret test
+
+The greater regret is adding visible product breadth, then discovering the physical native seam is
+unreliable and impossible to diagnose. The five-week fixture is reversible; months of feature work
+on an unproven seam is not. A day-one entrant would control the test variables before promising a
+catalog.
+
+### Quantitative sizing
+
+Revenue TAM/SAM/SOM is not supportable from repository evidence and must not be invented. Use a
+bottom-up validation funnel instead:
+
+- **TAM proxy:** everyone reachable through the current Discord/GitHub channels who has two Windows
+ hosts or can pair with another tester; count unknown until recruitment starts.
+- **SAM proxy:** 20 consented testers in one 90-day alpha cohort.
+- **SOM proxy:** 10 physical sessions, 8 verified report pairs, and 3 distinct hardware profiles.
+- **Success probability:** intentionally unestimated until a five-pair recruitment dry run provides
+ a base rate.
+- **Payoff proxy:** physical Proof-of-Play evidence plus reuse in every future adapter/route test.
+- **Failure cost:** at most 120 hours and US$100.
+- **Optionality:** compatibility map, route policy, contributor kit, community relays, spectator
+ qualification, and later game campaigns.
+
+### Moat assessment
+
+- **Evidence/process moat:** medium durability, starts with the first verified campaigns and grows
+ through consistent privacy-safe reports.
+- **Distribution/trust moat:** medium durability if claims remain narrower and more auditable than
+ alternatives.
+- **Technical moat:** low by itself; the fixture is copyable. The compound of fixture + verifier +
+ compatibility corpus + clean-room reputation is harder to copy.
+- **Copy risk:** high for the fixture alone (weeks), medium for the evidence flywheel (months).
+
+### Opportunity score
+
+| Dimension | Score | Reason |
+| ----------------------- | --------: | ------------------------------------------------------------------- |
+| 10X force | 4/5 | Connects implemented systems and removes several alpha variables |
+| Secret quality | 4/5 | Conformance before catalog breadth is specific and falsifiable |
+| Keystone leverage | 5/5 | Produces at least six downstream effects |
+| Lollapalooza | 4/5 | Alpha kit, native adapter, reports, CI, and community channel align |
+| Category ownership | 4/5 | Evidence-first clean-room conformance is distinctive if shipped |
+| Permissionless leverage | 5/5 | Code, CI, docs, and community; no proprietary content required |
+| Regret asymmetry | 4/5 | Cheap to bound now and expensive to discover the seam late |
+| Timing window | 3/5 | Sequencing is urgent; external market closure is unproven |
+| **Total** | **33/40** | Strong asymmetric bet; begin this week after the hard gates |
+
+### Four-week execution bridge
+
+1. **Week 1 — ADR and spike:** specify the minimal core, compile a blank original core against the
+ public API, complete license review, and demonstrate deterministic local serialization.
+2. **Week 2 — observable two-player fixture:** render frame number and both controller states,
+ checksum serialized state, and make core/content identity reproducible.
+3. **Week 3 — alpha integration:** add doctor checks and safe adapter configuration; emit existing
+ compatibility and match evidence without expanding the report schema unnecessarily.
+4. **Week 4 — physical campaign:** run one local rehearsal, then five paired sessions. If they pass,
+ continue to the 10-session gate and only then start the public compatibility aggregate.
+
+WOOP:
+
+- **Wish:** make physical native netplay independently reproducible.
+- **Outcome:** a new tester reaches a paired, verified session without sourcing a ROM.
+- **Obstacle:** the fixture expands into a game/core project instead of remaining a test instrument.
+- **Plan:** if a proposed feature does not isolate a compatibility, input, serialization, or lifecycle
+ failure, reject it from the fixture.
+
+Implementation intention: **when the ADR and license review pass, reserve four focused weeks for the
+fixture and pause all ungated M5–M7 breadth.**
+
+### Kill criteria
+
+Kill or re-scope the bet if any condition occurs:
+
+- no legally clear original-core boundary after the week-one review;
+- no deterministic serialize/unserialize loop by the end of week two;
+- no two-host native session by the end of week four;
+- fewer than 3 of the first 5 recruited pairs complete the doctor-to-report flow;
+- the fixture requires modifying or redistributing RetroArch, FBNeo, ROMs, BIOS files, or other
+ third-party binaries;
+- work exceeds 120 hours before the first verified physical pair.
+
+## Decision
+
+**Ship this week:** the No-ROM Proof-of-Play ADR and blank deterministic-core spike.
+
+**Build this quarter:** the no-ROM conformance kit, followed only after verified evidence by the
+public compatibility map and invite-to-match receipt loop.
+
+**Kill criteria:** stop at the first failed legal boundary, deterministic-state gate, four-week
+physical gate, or 120-hour affordable-loss cap.
diff --git a/docs/PRODUCT_TELEMETRY.md b/docs/PRODUCT_TELEMETRY.md
index aba8ec3..088e041 100644
--- a/docs/PRODUCT_TELEMETRY.md
+++ b/docs/PRODUCT_TELEMETRY.md
@@ -1,6 +1,6 @@
# Product telemetry
-OpenFight product telemetry measures whether players can move from choosing a game to entering its
+OpenCade 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.
@@ -26,7 +26,8 @@ typed blocker list. There is no arbitrary-properties field.
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.
+- Raw events older than 90 days are removed by a bounded background maintenance pass. A failed pass
+ is logged and retried without delaying event 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.
@@ -41,4 +42,6 @@ typed blocker list. There is no arbitrary-properties field.
`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.
+The endpoint requires both a user bearer token and the operator token. Rates are `0` when there are
+no selected sessions; the current payload does not yet distinguish privacy suppression from a
+genuine zero and must not be used alone for demand decisions.
diff --git a/docs/adr/0006-evidence-backed-proof-of-play-portfolio.md b/docs/adr/0006-evidence-backed-proof-of-play-portfolio.md
new file mode 100644
index 0000000..9bafc42
--- /dev/null
+++ b/docs/adr/0006-evidence-backed-proof-of-play-portfolio.md
@@ -0,0 +1,37 @@
+# ADR 0006: Evidence-backed Proof-of-Play portfolio
+
+Status: accepted, 2026-08-28.
+
+## Context
+
+The community alpha still depended on user-supplied ROM content for its first playable proof, lost
+momentum after a verified match, and exposed no privacy-safe view of accumulated compatibility
+evidence. The native TCP tunnel existed, but promoting it without a physical-host evidence gate
+would turn an implementation claim into an unsupported compatibility claim. Controller ambiguity
+also remained outside the launch barrier.
+
+## Decision
+
+- Ship an original Apache-2.0 libretro conformance fixture and inert `.ocade` content. It is a
+ deterministic two-input test surface, not an emulator or game, and contains no third-party code,
+ media, firmware, BIOS, or ROM data.
+- Package, checksum, sign, and explicitly install that fixture through the alpha kit. Never download
+ or silently replace an existing core or content file.
+- Treat a connected controller and the server-assigned player slot as required preflight evidence.
+ Host is player 1 and guest is player 2; both must pass before the launch barrier opens.
+- Permit the server to select the native TCP tunnel only when an operator enables the policy and the
+ same game cohort reaches both the configured physical-attempt minimum and an 80%
+ verified-pair rate. Relay configuration is mandatory. The default remains direct LAN and fails
+ closed.
+- Produce an idempotent post-match receipt only after the canonical two-report verifier passes. The
+ receipt creates a fresh one-use, short-lived invite for the same game.
+- Publish only aggregate compatibility cohorts containing at least three distinct rooms. Never
+ publish raw reports, identities, endpoints, paths, hashes, or invite data.
+
+## Consequences
+
+Contributors can exercise the complete install-to-netplay loop without legally sensitive content.
+Route promotion is reversible configuration backed by the same evidence users generate. The public
+map communicates demonstrated cohorts rather than a static promise, while the receipt makes a
+successful match the start of another match. The fixture proves OpenCade orchestration and libretro
+netplay integration only; it does not prove compatibility with FBNeo games or user-supplied content.
diff --git a/docs/alpha/RETROARCH_TEST.md b/docs/alpha/RETROARCH_TEST.md
index 19a7edc..c6069c7 100644
--- a/docs/alpha/RETROARCH_TEST.md
+++ b/docs/alpha/RETROARCH_TEST.md
@@ -1,8 +1,9 @@
# RetroArch Proof-of-Play alpha
-This procedure tests the experimental native-process adapter from ADR 0003. It requires two real
-Windows machines and user-supplied emulator files. Never commit, upload, or attach emulator binaries,
-cores, BIOS files, or ROMs to OpenCade reports.
+This procedure tests the experimental native-process adapter from ADRs 0003 and 0006. It requires
+two real Windows machines and a user-supplied RetroArch executable. The alpha kit supplies an
+original, deterministic OpenCade test core and inert test content, so the primary Proof-of-Play does
+not require a ROM, BIOS, firmware, or third-party core.
## Get the alpha artifact
@@ -26,15 +27,22 @@ C:\OpenCadeAlpha\retroarch\
├── retroarch.exe
├── VERSION.txt # exact RetroArch version, one line
├── cores\
-│ └── fbneo_libretro.dll
+│ └── opencade_test_libretro.dll
└── ROMs\
- ├── neogeo.zip # when required by the selected game
- └── sfiii3.zip # user-supplied content
+ └── opencade_test.ocade
```
-Use the same RetroArch version, FBNeo core, and exact content on both machines. OpenCade computes
-SHA-256 fingerprints locally and includes only those hashes—not paths or content—in an exported
-report.
+Install the checksummed fixture from the alpha-kit directory on both machines:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode InstallTestFixture `
+ -RetroArchRoot "C:\OpenCadeAlpha\retroarch"
+```
+
+The installer refuses to overwrite a different file. Use the same RetroArch version and exact
+fixture on both machines. OpenCade computes SHA-256 fingerprints locally and includes only those
+hashes—not paths or content—in an exported report. FBNeo testing with user-supplied content remains
+an optional, separate compatibility campaign.
## Start the clients
@@ -56,19 +64,20 @@ public internet.
## Scenario
-1. Register two different OpenCade users and select the same game.
+1. Register two different OpenCade users and select **OpenCade Proof-of-Play Test**.
2. Challenge and accept until both clients reach the match screen.
3. Wait for `Direct UDP verified` with a same-LAN host candidate and matching 60-frame transcript
checksums. Relay and reflexive routes are readiness-only and keep native launch disabled.
-4. Select **Launch playable alpha** on the host first, then on the guest.
-5. Confirm both clients display the same executable, core, and content hash prefixes.
-6. Confirm RetroArch establishes netplay and player two can provide gameplay input.
-7. End the session cleanly. Export one report from each OpenCade client after the native process has
+4. Connect one controller to each machine. Confirm host is assigned player 1 and guest player 2.
+5. Select **Launch playable alpha** on the host first, then on the guest.
+6. Confirm both clients display the same executable, core, and content hash prefixes.
+7. Confirm RetroArch establishes netplay and both colored player panels react to their controllers.
+8. End the session cleanly. Export one report from each OpenCade client after the native process has
launched so the compatibility fingerprints are included.
-8. Run `opencade-match-verify --require-compatibility host.json guest.json`. Missing or mismatched
+9. Run `opencade-match-verify --require-compatibility host.json guest.json`. Missing or mismatched
adapter fingerprints, transport, transcript, room, game, or roles must fail closed.
-9. If RetroArch launch fails, export failure evidence before leaving the match screen. The report
- records `native_launch_failed`, but never the local launch error or path.
+10. If RetroArch launch fails, export failure evidence before leaving the match screen. The report
+ records `native_launch_failed`, but never the local launch error or path.
## Pass criteria
diff --git a/docs/operations/RUNBOOK.md b/docs/operations/RUNBOOK.md
index 80da280..be24bd3 100644
--- a/docs/operations/RUNBOOK.md
+++ b/docs/operations/RUNBOOK.md
@@ -5,7 +5,9 @@ OpenCade separates schema migration from application startup. Production configu
## 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`.
+2. Build server and relay images from the audited release tag and pin their resulting digests.
+ The current release workflow publishes a signed Windows kit but does not yet publish container
+ images; never deploy `latest` or imply registry artifacts exist.
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.
@@ -13,6 +15,11 @@ OpenCade separates schema migration from application startup. Production configu
Docker Compose implements steps 3–4 with the `migrate` service and `service_completed_successfully` dependency.
+Before tagging, classify every migration as backward compatible or incompatible, record its tested
+upgrade duration and lock budget, and state whether application rollback is safe. The current local
+Compose topology uses one database owner for migration and runtime; distinct least-privilege roles
+remain a production release requirement.
+
## 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.
@@ -38,6 +45,12 @@ Run this drill before the first public release and quarterly afterward. Record a
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.
+The current duration metric is cumulative and cannot prove the p95 latency target. Do not derive a
+p95 from it; add route-level histogram buckets before enforcing latency SLOs. The server pool is
+bounded to 10 connections with a 5-second acquisition timeout, and the public compatibility
+aggregate is cached per process for 30 seconds. Those are implementation defaults, not horizontal
+scaling guarantees.
+
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.
diff --git a/fixtures/libretro/opencade-test-core/opencade_test.ocade b/fixtures/libretro/opencade-test-core/opencade_test.ocade
new file mode 100644
index 0000000..cc5f75f
--- /dev/null
+++ b/fixtures/libretro/opencade-test-core/opencade_test.ocade
@@ -0,0 +1 @@
+OPENCADE-TEST-1
diff --git a/fixtures/libretro/opencade-test-core/opencade_test_core.c b/fixtures/libretro/opencade-test-core/opencade_test_core.c
new file mode 100644
index 0000000..723546b
--- /dev/null
+++ b/fixtures/libretro/opencade-test-core/opencade_test_core.c
@@ -0,0 +1,236 @@
+/*
+ * OpenCade deterministic libretro conformance fixture.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Original test-only implementation against the public libretro API surface. It is not an
+ * emulator and contains no third-party game code or media.
+ */
+
+#include
+#include
+#include
+#include
+
+#if defined(_WIN32)
+#define OCADE_EXPORT __declspec(dllexport)
+#else
+#define OCADE_EXPORT __attribute__((visibility("default")))
+#endif
+
+#define RETRO_API_VERSION 1
+#define RETRO_ENVIRONMENT_SET_PIXEL_FORMAT 10
+#define RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME 18
+#define RETRO_PIXEL_FORMAT_XRGB8888 1
+#define RETRO_DEVICE_JOYPAD 1
+#define RETRO_DEVICE_ID_JOYPAD_B 0
+#define RETRO_DEVICE_ID_JOYPAD_START 3
+#define RETRO_DEVICE_ID_JOYPAD_UP 4
+#define RETRO_DEVICE_ID_JOYPAD_DOWN 5
+#define RETRO_DEVICE_ID_JOYPAD_LEFT 6
+#define RETRO_DEVICE_ID_JOYPAD_RIGHT 7
+#define OCADE_WIDTH 320
+#define OCADE_HEIGHT 240
+
+struct retro_game_info {
+ const char *path;
+ const void *data;
+ size_t size;
+ const char *meta;
+};
+
+struct retro_system_info {
+ const char *library_name;
+ const char *library_version;
+ const char *valid_extensions;
+ bool need_fullpath;
+ bool block_extract;
+};
+
+struct retro_game_geometry {
+ unsigned base_width;
+ unsigned base_height;
+ unsigned max_width;
+ unsigned max_height;
+ float aspect_ratio;
+};
+
+struct retro_system_timing {
+ double fps;
+ double sample_rate;
+};
+
+struct retro_system_av_info {
+ struct retro_game_geometry geometry;
+ struct retro_system_timing timing;
+};
+
+typedef bool (*retro_environment_t)(unsigned command, void *data);
+typedef void (*retro_video_refresh_t)(const void *data, unsigned width, unsigned height, size_t pitch);
+typedef void (*retro_audio_sample_t)(int16_t left, int16_t right);
+typedef size_t (*retro_audio_sample_batch_t)(const int16_t *data, size_t frames);
+typedef void (*retro_input_poll_t)(void);
+typedef int16_t (*retro_input_state_t)(unsigned port, unsigned device, unsigned index, unsigned id);
+
+struct ocade_state {
+ uint64_t frame;
+ uint16_t input[2];
+};
+
+static retro_environment_t environment_cb;
+static retro_video_refresh_t video_cb;
+static retro_audio_sample_t audio_cb;
+static retro_audio_sample_batch_t audio_batch_cb;
+static retro_input_poll_t input_poll_cb;
+static retro_input_state_t input_state_cb;
+static struct ocade_state state;
+static uint32_t pixels[OCADE_WIDTH * OCADE_HEIGHT];
+
+static uint16_t read_pad(unsigned port) {
+ const unsigned ids[] = {
+ RETRO_DEVICE_ID_JOYPAD_B, RETRO_DEVICE_ID_JOYPAD_START, RETRO_DEVICE_ID_JOYPAD_UP,
+ RETRO_DEVICE_ID_JOYPAD_DOWN, RETRO_DEVICE_ID_JOYPAD_LEFT, RETRO_DEVICE_ID_JOYPAD_RIGHT
+ };
+ uint16_t result = 0;
+ unsigned index;
+ if (input_state_cb == NULL) {
+ return 0;
+ }
+ for (index = 0; index < sizeof(ids) / sizeof(ids[0]); ++index) {
+ if (input_state_cb(port, RETRO_DEVICE_JOYPAD, 0, ids[index]) != 0) {
+ result |= (uint16_t)(1u << index);
+ }
+ }
+ return result;
+}
+
+static void render_half(unsigned start_x, unsigned end_x, uint16_t input, uint32_t base) {
+ unsigned x;
+ unsigned y;
+ uint32_t active = input == 0 ? base : (base ^ 0x003f3f3fu);
+ for (y = 0; y < OCADE_HEIGHT; ++y) {
+ for (x = start_x; x < end_x; ++x) {
+ uint32_t grid = ((x / 16u) + (y / 16u) + (unsigned)(state.frame / 30u)) & 1u;
+ pixels[y * OCADE_WIDTH + x] = active + (grid != 0u ? 0x00101010u : 0u);
+ }
+ }
+}
+
+OCADE_EXPORT unsigned retro_api_version(void) { return RETRO_API_VERSION; }
+
+OCADE_EXPORT void retro_set_environment(retro_environment_t callback) {
+ bool support_no_game = true;
+ environment_cb = callback;
+ if (environment_cb != NULL) {
+ environment_cb(RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, &support_no_game);
+ }
+}
+
+OCADE_EXPORT void retro_set_video_refresh(retro_video_refresh_t callback) { video_cb = callback; }
+OCADE_EXPORT void retro_set_audio_sample(retro_audio_sample_t callback) { audio_cb = callback; }
+OCADE_EXPORT void retro_set_audio_sample_batch(retro_audio_sample_batch_t callback) {
+ audio_batch_cb = callback;
+}
+OCADE_EXPORT void retro_set_input_poll(retro_input_poll_t callback) { input_poll_cb = callback; }
+OCADE_EXPORT void retro_set_input_state(retro_input_state_t callback) { input_state_cb = callback; }
+
+OCADE_EXPORT void retro_init(void) {
+ unsigned pixel_format = RETRO_PIXEL_FORMAT_XRGB8888;
+ memset(&state, 0, sizeof(state));
+ memset(pixels, 0, sizeof(pixels));
+ if (environment_cb != NULL) {
+ environment_cb(RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, &pixel_format);
+ }
+}
+
+OCADE_EXPORT void retro_deinit(void) {}
+
+OCADE_EXPORT void retro_get_system_info(struct retro_system_info *info) {
+ if (info == NULL) {
+ return;
+ }
+ info->library_name = "OpenCade Test Core";
+ info->library_version = "1.0.0";
+ info->valid_extensions = "ocade";
+ info->need_fullpath = false;
+ info->block_extract = false;
+}
+
+OCADE_EXPORT void retro_get_system_av_info(struct retro_system_av_info *info) {
+ if (info == NULL) {
+ return;
+ }
+ info->geometry.base_width = OCADE_WIDTH;
+ info->geometry.base_height = OCADE_HEIGHT;
+ info->geometry.max_width = OCADE_WIDTH;
+ info->geometry.max_height = OCADE_HEIGHT;
+ info->geometry.aspect_ratio = 4.0f / 3.0f;
+ info->timing.fps = 60.0;
+ info->timing.sample_rate = 48000.0;
+}
+
+OCADE_EXPORT void retro_set_controller_port_device(unsigned port, unsigned device) {
+ (void)port;
+ (void)device;
+}
+
+OCADE_EXPORT void retro_reset(void) { memset(&state, 0, sizeof(state)); }
+
+OCADE_EXPORT void retro_run(void) {
+ if (input_poll_cb != NULL) {
+ input_poll_cb();
+ }
+ state.input[0] = read_pad(0);
+ state.input[1] = read_pad(1);
+ state.frame += 1;
+ render_half(0, OCADE_WIDTH / 2, state.input[0], 0x002050a0u);
+ render_half(OCADE_WIDTH / 2, OCADE_WIDTH, state.input[1], 0x00a05020u);
+ if (video_cb != NULL) {
+ video_cb(pixels, OCADE_WIDTH, OCADE_HEIGHT, OCADE_WIDTH * sizeof(uint32_t));
+ }
+}
+
+OCADE_EXPORT size_t retro_serialize_size(void) { return sizeof(state); }
+
+OCADE_EXPORT bool retro_serialize(void *data, size_t size) {
+ if (data == NULL || size < sizeof(state)) {
+ return false;
+ }
+ memcpy(data, &state, sizeof(state));
+ return true;
+}
+
+OCADE_EXPORT bool retro_unserialize(const void *data, size_t size) {
+ if (data == NULL || size != sizeof(state)) {
+ return false;
+ }
+ memcpy(&state, data, sizeof(state));
+ return true;
+}
+
+OCADE_EXPORT bool retro_load_game(const struct retro_game_info *game) {
+ static const char expected[] = "OPENCADE-TEST-1\n";
+ if (game != NULL && game->data != NULL) {
+ if (game->size != sizeof(expected) - 1u || memcmp(game->data, expected, sizeof(expected) - 1u) != 0) {
+ return false;
+ }
+ }
+ retro_reset();
+ return true;
+}
+
+OCADE_EXPORT void retro_unload_game(void) {}
+OCADE_EXPORT unsigned retro_get_region(void) { return 0; }
+OCADE_EXPORT void *retro_get_memory_data(unsigned id) { (void)id; return NULL; }
+OCADE_EXPORT size_t retro_get_memory_size(unsigned id) { (void)id; return 0; }
+OCADE_EXPORT bool retro_load_game_special(unsigned type, const struct retro_game_info *info, size_t count) {
+ (void)type;
+ (void)info;
+ (void)count;
+ return false;
+}
+OCADE_EXPORT void retro_cheat_reset(void) {}
+OCADE_EXPORT void retro_cheat_set(unsigned index, bool enabled, const char *code) {
+ (void)index;
+ (void)enabled;
+ (void)code;
+}
diff --git a/packages/game-definitions/games/opencade_test.toml b/packages/game-definitions/games/opencade_test.toml
new file mode 100644
index 0000000..251cd3c
--- /dev/null
+++ b/packages/game-definitions/games/opencade_test.toml
@@ -0,0 +1,14 @@
+schema_version = 1
+id = "opencade_test"
+name = "OpenCade Proof-of-Play Test"
+emulator = "retroarch_test"
+
+[launch]
+args = ["-L", "opencade_test_libretro", "{rom}"]
+
+[validation]
+required_files = ["opencade_test.ocade"]
+
+[metadata]
+developer = "OpenCade Contributors"
+players = 2
diff --git a/packages/game-definitions/schema/game.schema.json b/packages/game-definitions/schema/game.schema.json
index 833406e..5f5569b 100644
--- a/packages/game-definitions/schema/game.schema.json
+++ b/packages/game-definitions/schema/game.schema.json
@@ -23,7 +23,7 @@
},
"emulator": {
"type": "string",
- "enum": ["fbneo", "flycast", "snes9x"],
+ "enum": ["fbneo", "flycast", "snes9x", "retroarch_test"],
"description": "Emulator adapter id"
},
"launch": {
diff --git a/packages/game-definitions/src/index.ts b/packages/game-definitions/src/index.ts
index 50e74e0..d70d3c6 100644
--- a/packages/game-definitions/src/index.ts
+++ b/packages/game-definitions/src/index.ts
@@ -2,7 +2,7 @@ export type GameDefinition = {
schema_version: 1;
id: string;
name: string;
- emulator: "fbneo" | "flycast" | "snes9x";
+ emulator: "fbneo" | "flycast" | "snes9x" | "retroarch_test";
launch: { args: string[] };
validation?: { required_files?: string[]; bios?: string };
metadata?: { year?: number; developer?: string; players?: number };
@@ -12,7 +12,7 @@ export function validateGameDefinition(def: GameDefinition): string | null {
if (def.schema_version !== 1) return `unsupported schema_version ${def.schema_version}`;
if (!/^[a-z0-9_-]{3,20}$/.test(def.id)) return `id '${def.id}' must match ^[a-z0-9_-]{3,20}$`;
if (!def.name || def.name.trim().length === 0) return "name must not be empty";
- const allowed = ["fbneo", "flycast", "snes9x"] as const;
+ const allowed = ["fbneo", "flycast", "snes9x", "retroarch_test"] as const;
if (!(allowed as readonly string[]).includes(def.emulator))
return `emulator '${def.emulator}' must be one of ${allowed.join(", ")}`;
if (!def.launch || !Array.isArray(def.launch.args) || def.launch.args.length === 0)
@@ -26,5 +26,5 @@ export function renderArgs(def: GameDefinition, romPath: string): string[] {
return def.launch.args.map((a) => a.replaceAll("{rom}", romPath));
}
-export const GAME_IDS = ["kof98", "sfiii3", "garou", "kof2002", "mvc2"] as const;
+export const GAME_IDS = ["kof98", "sfiii3", "garou", "kof2002", "mvc2", "opencade_test"] as const;
export type GameId = (typeof GAME_IDS)[number];
diff --git a/packages/game-definitions/src/loader.rs b/packages/game-definitions/src/loader.rs
index 9a2011a..bc2b06f 100644
--- a/packages/game-definitions/src/loader.rs
+++ b/packages/game-definitions/src/loader.rs
@@ -81,7 +81,7 @@ impl GameDefinition {
message: "name must not be empty".to_string(),
});
}
- let allowed_emulators = ["fbneo", "flycast", "snes9x"];
+ let allowed_emulators = ["fbneo", "flycast", "snes9x", "retroarch_test"];
if !allowed_emulators.contains(&self.emulator.as_str()) {
return Err(GameDefError::Validation {
path: path.to_string(),
diff --git a/packages/networking/src/bin/opencade-match-probe.rs b/packages/networking/src/bin/opencade-match-probe.rs
index b7605f0..4db04e4 100644
--- a/packages/networking/src/bin/opencade-match-probe.rs
+++ b/packages/networking/src/bin/opencade-match-probe.rs
@@ -133,6 +133,7 @@ async fn main() -> Result<(), Box> {
user_agent: format!("opencade-match-probe/{}", env!("CARGO_PKG_VERSION")),
},
compatibility: None,
+ native_route: None,
};
println!("{}", serde_json::to_string(&report)?);
Ok(())
diff --git a/packages/networking/src/report.rs b/packages/networking/src/report.rs
index a4b24d5..c1dd355 100644
--- a/packages/networking/src/report.rs
+++ b/packages/networking/src/report.rs
@@ -527,6 +527,7 @@ mod tests {
user_agent: "opencade-test".into(),
},
compatibility: None,
+ native_route: None,
}
}
@@ -551,6 +552,7 @@ mod tests {
platform: "windows".into(),
user_agent: "opencade-test".into(),
},
+ native_route: None,
}
}
diff --git a/packages/networking/tests/report_verifier.rs b/packages/networking/tests/report_verifier.rs
index 80b1287..5ee9747 100644
--- a/packages/networking/tests/report_verifier.rs
+++ b/packages/networking/tests/report_verifier.rs
@@ -37,6 +37,7 @@ fn report(role: MatchReportRole) -> MatchReport {
user_agent: "opencade-test".into(),
},
compatibility: None,
+ native_route: None,
}
}
@@ -61,6 +62,7 @@ fn failure_report(room_id: String) -> AlphaFailureReport {
platform: "windows".into(),
user_agent: "opencade-test".into(),
},
+ native_route: None,
}
}
diff --git a/packages/protocol/Cargo.toml b/packages/protocol/Cargo.toml
index a5881aa..a3a6909 100644
--- a/packages/protocol/Cargo.toml
+++ b/packages/protocol/Cargo.toml
@@ -8,7 +8,7 @@ repository = "https://github.com/opencade/opencade"
rust-version = "1.98"
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
-serde_json = "1.0.151"
+serde_json = { version = "1.0.151", features = ["raw_value"] }
chrono = { version = "0.4.45", features = ["serde"] }
uuid = { version = "1.25.0", features = ["v4", "serde"] }
ts-rs = { version = "12.0.1", features = ["serde-compat", "chrono-impl", "uuid-impl"] }
diff --git a/packages/protocol/examples/throughput.rs b/packages/protocol/examples/throughput.rs
new file mode 100644
index 0000000..48723c1
--- /dev/null
+++ b/packages/protocol/examples/throughput.rs
@@ -0,0 +1,47 @@
+use std::{hint::black_box, time::Instant};
+
+use opencade_protocol::{BorrowedEnvelope, Envelope, MatchEndpointPayload, NatMappingState};
+
+const ITERATIONS: usize = 500_000;
+
+fn main() {
+ let envelope = Envelope::new(
+ "match.endpoint",
+ MatchEndpointPayload {
+ room_id: "fb3a171c-82e7-4f27-a130-68cff7085737".into(),
+ endpoint: "192.168.1.20:42000".into(),
+ reflexive_endpoint: Some("203.0.113.9:52000".into()),
+ nat: NatMappingState::Mapped,
+ nonce: "8a1110d5-8dd2-4ad2-9c88-ad9768bc4905".into(),
+ },
+ );
+ let encoded = serde_json::to_string(&envelope).expect("serialize fixture");
+
+ let started = Instant::now();
+ for _ in 0..ITERATIONS {
+ let parsed: Envelope =
+ serde_json::from_str(black_box(&encoded)).expect("parse fixture");
+ let payload: MatchEndpointPayload =
+ serde_json::from_value(black_box(parsed.payload.clone())).expect("type payload");
+ black_box(payload);
+ }
+ let elapsed = started.elapsed();
+ let per_second = ITERATIONS as f64 / elapsed.as_secs_f64();
+ println!(
+ "legacy envelope parse + cloned typed payload: {per_second:.0} msg/s ({:.0} ns/msg)",
+ elapsed.as_nanos() as f64 / ITERATIONS as f64
+ );
+
+ let started = Instant::now();
+ for _ in 0..ITERATIONS {
+ let parsed = BorrowedEnvelope::parse(black_box(&encoded)).expect("parse borrowed fixture");
+ let payload: MatchEndpointPayload = parsed.payload_as().expect("type borrowed payload");
+ black_box(payload);
+ }
+ let elapsed = started.elapsed();
+ let per_second = ITERATIONS as f64 / elapsed.as_secs_f64();
+ println!(
+ "borrowed envelope + typed payload: {per_second:.0} msg/s ({:.0} ns/msg)",
+ elapsed.as_nanos() as f64 / ITERATIONS as f64
+ );
+}
diff --git a/packages/protocol/src/envelope.ts b/packages/protocol/src/envelope.ts
index 95b873d..c65d437 100644
--- a/packages/protocol/src/envelope.ts
+++ b/packages/protocol/src/envelope.ts
@@ -54,29 +54,36 @@ export function validateEnvelope(
export function parseEnvelope(raw: string): 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)
- ) {
+ if (typeof decoded !== "object" || decoded === null) {
throw new Error("invalid envelope shape: missing required fields");
}
- const parsed = decoded as Envelope;
+ const parsed = decoded as Record;
+ const type = parsed.type;
+ const version = parsed.version;
+ const requestId = parsed.request_id;
+ const timestamp = parsed.timestamp;
if (
- typeof parsed.type !== "string" ||
- typeof parsed.version !== "string" ||
- typeof parsed.request_id !== "string" ||
- typeof parsed.timestamp !== "string" ||
+ typeof type !== "string" ||
+ typeof version !== "string" ||
+ typeof requestId !== "string" ||
+ typeof timestamp !== "string" ||
parsed.payload === undefined
) {
throw new Error("invalid envelope shape: missing required fields");
}
- const validation = validateEnvelope(parsed);
- if (!validation.ok) throw new Error(validation.error);
+ // Validate inline on this hot path to avoid allocating validateEnvelope's result object.
+ if (!isSupportedVersion(version)) {
+ throw new Error(`unsupported version: ${version}`);
+ }
+ if (type.trim().length === 0) {
+ throw new Error("type must not be empty");
+ }
+ if (requestId.length === 0) {
+ throw new Error("request_id must not be empty");
+ }
+ if (Number.isNaN(Date.parse(timestamp))) {
+ throw new Error("timestamp must be a valid ISO-8601 string");
+ }
return parsed as Envelope;
}
diff --git a/packages/protocol/src/generated/ActivationSummaryPayload.ts b/packages/protocol/src/generated/ActivationSummaryPayload.ts
index ef02f40..40c13be 100644
--- a/packages/protocol/src/generated/ActivationSummaryPayload.ts
+++ b/packages/protocol/src/generated/ActivationSummaryPayload.ts
@@ -4,16 +4,4 @@ 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 eb3b1ca..6fea9ce 100644
--- a/packages/protocol/src/generated/AlphaFailureReport.ts
+++ b/packages/protocol/src/generated/AlphaFailureReport.ts
@@ -5,18 +5,9 @@ import type { MatchReportClient } from "./MatchReportClient";
import type { MatchReportRole } from "./MatchReportRole";
import type { MatchReportRoom } from "./MatchReportRoom";
import type { MatchReportTransport } from "./MatchReportTransport";
+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;
- 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 f32a9cd..d047934 100644
--- a/packages/protocol/src/generated/AlphaFailureStage.ts
+++ b/packages/protocol/src/generated/AlphaFailureStage.ts
@@ -3,11 +3,4 @@
/**
* 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 4c340c5..2520840 100644
--- a/packages/protocol/src/generated/ChallengePayload.ts
+++ b/packages/protocol/src/generated/ChallengePayload.ts
@@ -4,11 +4,4 @@ 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 5b12881..bcc124c 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
new file mode 100644
index 0000000..d304abb
--- /dev/null
+++ b/packages/protocol/src/generated/ControllerPreflightPayload.ts
@@ -0,0 +1,6 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Local controller observation and deterministic player assignment for native netplay.
+ */
+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 e6bdb03..f5542e9 100644
--- a/packages/protocol/src/generated/LaunchBarrierPayload.ts
+++ b/packages/protocol/src/generated/LaunchBarrierPayload.ts
@@ -3,9 +3,4 @@
/**
* 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 d1eacc2..67bb257 100644
--- a/packages/protocol/src/generated/MatchEndpointPayload.ts
+++ b/packages/protocol/src/generated/MatchEndpointPayload.ts
@@ -4,10 +4,4 @@ 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 c216a5a..72aacbf 100644
--- a/packages/protocol/src/generated/MatchPreflightPayload.ts
+++ b/packages/protocol/src/generated/MatchPreflightPayload.ts
@@ -1,11 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ControllerPreflightPayload } from "./ControllerPreflightPayload";
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;
-};
+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 92fd040..33596fb 100644
--- a/packages/protocol/src/generated/MatchProbeCompletedPayload.ts
+++ b/packages/protocol/src/generated/MatchProbeCompletedPayload.ts
@@ -3,8 +3,4 @@
/**
* 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
new file mode 100644
index 0000000..bb63675
--- /dev/null
+++ b/packages/protocol/src/generated/MatchReceiptPayload.ts
@@ -0,0 +1,8 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { NativeRouteCapability } from "./NativeRouteCapability";
+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, };
diff --git a/packages/protocol/src/generated/MatchReport.ts b/packages/protocol/src/generated/MatchReport.ts
index 844d9b1..93d00de 100644
--- a/packages/protocol/src/generated/MatchReport.ts
+++ b/packages/protocol/src/generated/MatchReport.ts
@@ -3,15 +3,9 @@ import type { MatchReportClient } from "./MatchReportClient";
import type { MatchReportCompatibility } from "./MatchReportCompatibility";
import type { MatchReportProbe } from "./MatchReportProbe";
import type { MatchReportRoom } from "./MatchReportRoom";
+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;
-};
+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 ec4099a..58c44e7 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 0e55622..57502a9 100644
--- a/packages/protocol/src/generated/MatchReportCompatibility.ts
+++ b/packages/protocol/src/generated/MatchReportCompatibility.ts
@@ -3,10 +3,4 @@
/**
* 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 9a74243..a2ee8e7 100644
--- a/packages/protocol/src/generated/MatchReportProbe.ts
+++ b/packages/protocol/src/generated/MatchReportProbe.ts
@@ -7,14 +7,4 @@ 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 b16a26f..8d3bf4a 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
new file mode 100644
index 0000000..6b0de37
--- /dev/null
+++ b/packages/protocol/src/generated/NativeRoutePolicyPayload.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.
+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, };
diff --git a/packages/protocol/src/generated/PresencePayload.ts b/packages/protocol/src/generated/PresencePayload.ts
index d640596..a533baa 100644
--- a/packages/protocol/src/generated/PresencePayload.ts
+++ b/packages/protocol/src/generated/PresencePayload.ts
@@ -3,10 +3,4 @@
/**
* 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 174e20c..945f703 100644
--- a/packages/protocol/src/generated/ProductEventName.ts
+++ b/packages/protocol/src/generated/ProductEventName.ts
@@ -3,10 +3,4 @@
/**
* 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 f89c956..318746b 100644
--- a/packages/protocol/src/generated/ProductEventPayload.ts
+++ b/packages/protocol/src/generated/ProductEventPayload.ts
@@ -5,10 +5,4 @@ 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
new file mode 100644
index 0000000..6c796a4
--- /dev/null
+++ b/packages/protocol/src/generated/PublicCompatibilityCohort.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.
+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, };
diff --git a/packages/protocol/src/generated/PublicCompatibilityPayload.ts b/packages/protocol/src/generated/PublicCompatibilityPayload.ts
new file mode 100644
index 0000000..edfa942
--- /dev/null
+++ b/packages/protocol/src/generated/PublicCompatibilityPayload.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.
+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, };
diff --git a/packages/protocol/src/generated/ReadinessBlockCount.ts b/packages/protocol/src/generated/ReadinessBlockCount.ts
index 04d017c..c7b25bb 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 af94dc4..138bd1b 100644
--- a/packages/protocol/src/generated/ReadinessCheckId.ts
+++ b/packages/protocol/src/generated/ReadinessCheckId.ts
@@ -3,5 +3,4 @@
/**
* 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 94fa087..69679a8 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 ea01842..dda9de4 100644
--- a/packages/protocol/src/generated/RoomPayload.ts
+++ b/packages/protocol/src/generated/RoomPayload.ts
@@ -4,10 +4,4 @@ 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 ffa1cba..c9eedc9 100644
--- a/packages/protocol/src/generated/RoomSnapshotPayload.ts
+++ b/packages/protocol/src/generated/RoomSnapshotPayload.ts
@@ -1,16 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LaunchBarrierPayload } from "./LaunchBarrierPayload";
import type { NativeRouteCapability } from "./NativeRouteCapability";
+import type { NativeRoutePolicyPayload } from "./NativeRoutePolicyPayload";
import type { RoomPayload } from "./RoomPayload";
/**
* Durable room snapshot used after initial connection and every reconnect.
*/
-export type RoomSnapshotPayload = {
- room: RoomPayload;
- revision: number;
- preflight_count: number;
- compatibility_matched: boolean;
- barrier: LaunchBarrierPayload;
- route: NativeRouteCapability;
-};
+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 528d39e..f24a382 100644
--- a/packages/protocol/src/generated/RoomState.ts
+++ b/packages/protocol/src/generated/RoomState.ts
@@ -6,5 +6,4 @@
* 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 c63b2a1..d748db1 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 8e3d1ac..a43ec7e 100644
--- a/packages/protocol/src/generated/SessionCandidatePayload.ts
+++ b/packages/protocol/src/generated/SessionCandidatePayload.ts
@@ -3,9 +3,4 @@
/**
* 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 f4c3b6e..98aff87 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, };
diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts
index f2e399a..199b91c 100644
--- a/packages/protocol/src/index.ts
+++ b/packages/protocol/src/index.ts
@@ -12,6 +12,11 @@ export type {
MatchReportClient,
MatchReportCompatibility,
MatchPreflightPayload,
+ ControllerPreflightPayload,
+ MatchReceiptPayload,
+ NativeRoutePolicyPayload,
+ PublicCompatibilityCohort,
+ PublicCompatibilityPayload,
LaunchBarrierPayload,
NativeRouteCapability,
RoomInvitePayload,
diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs
index da23e7c..0890329 100644
--- a/packages/protocol/src/lib.rs
+++ b/packages/protocol/src/lib.rs
@@ -9,6 +9,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+use serde_json::value::RawValue;
use ts_rs::TS;
use uuid::Uuid;
@@ -27,6 +28,53 @@ pub const ALPHA_FAILURE_REPORT_SCHEMA_VERSION: u8 = 1;
pub fn is_supported_version(v: &str) -> bool {
v == "1.0" || v == "1"
}
+
+/// Allocation-free view of an inbound wire envelope.
+///
+/// The payload remains raw JSON until dispatch identifies its concrete type. This avoids
+/// constructing a generic [`serde_json::Value`] tree and cloning it before typed validation.
+#[derive(Debug, Deserialize)]
+pub struct BorrowedEnvelope<'a> {
+ #[serde(rename = "type", borrow)]
+ pub msg_type: &'a str,
+ #[serde(borrow)]
+ pub version: &'a str,
+ #[serde(borrow)]
+ pub request_id: &'a str,
+ #[serde(borrow)]
+ pub timestamp: &'a str,
+ #[serde(borrow)]
+ pub payload: &'a RawValue,
+}
+
+impl<'a> BorrowedEnvelope<'a> {
+ /// Parses and validates the common envelope fields while borrowing from `raw`.
+ pub fn parse(raw: &'a str) -> Result {
+ let envelope: Self = serde_json::from_str(raw).map_err(|_| "invalid envelope")?;
+ envelope.validate()?;
+ Ok(envelope)
+ }
+
+ /// Validates common fields after a caller has decoded the borrowed envelope.
+ pub fn validate(&self) -> Result<(), &'static str> {
+ if !is_supported_version(self.version) {
+ return Err("unsupported protocol version");
+ }
+ if self.msg_type.trim().is_empty() || self.msg_type.len() > 64 {
+ return Err("invalid message type");
+ }
+ if self.request_id.is_empty() || self.request_id.len() > 128 {
+ return Err("invalid request id");
+ }
+ DateTime::parse_from_rfc3339(self.timestamp).map_err(|_| "invalid timestamp")?;
+ Ok(())
+ }
+
+ /// Deserializes the payload exactly once after message dispatch chooses its type.
+ pub fn payload_as>(&self) -> Result {
+ serde_json::from_str(self.payload.get())
+ }
+}
/// Versioned envelope wrapping every protocol message.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../src/generated/Envelope.ts", concrete(T = String))]
@@ -191,7 +239,7 @@ pub enum NatMappingState {
}
/// Candidate selected by the bounded direct-UDP traversal attempt.
-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, TS)]
#[ts(export, export_to = "../src/generated/MatchCandidateKind.ts")]
#[serde(rename_all = "snake_case")]
pub enum MatchCandidateKind {
@@ -279,6 +327,14 @@ pub enum NativeRouteCapability {
TcpTunnel,
}
+/// Local controller observation and deterministic player assignment for native netplay.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/ControllerPreflightPayload.ts")]
+pub struct ControllerPreflightPayload {
+ pub connected: bool,
+ pub player_slot: u8,
+}
+
/// Privacy-safe compatibility handshake performed before either emulator is launched.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../src/generated/MatchPreflightPayload.ts")]
@@ -286,6 +342,17 @@ pub struct MatchPreflightPayload {
pub room_id: String,
pub compatibility: MatchReportCompatibility,
pub native_port_available: bool,
+ pub controller: ControllerPreflightPayload,
+}
+
+/// Evidence-backed route decision. Insufficient samples always preserve the direct-LAN policy.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/NativeRoutePolicyPayload.ts")]
+pub struct NativeRoutePolicyPayload {
+ pub route: NativeRouteCapability,
+ pub reason: String,
+ pub evidence_attempts: u32,
+ pub evidence_verified: u32,
}
/// Server-authoritative launch barrier shared by both room participants.
@@ -307,8 +374,10 @@ pub struct RoomSnapshotPayload {
pub revision: i64,
pub preflight_count: u8,
pub compatibility_matched: bool,
+ pub controller_ready_count: u8,
pub barrier: LaunchBarrierPayload,
pub route: NativeRouteCapability,
+ pub route_policy: NativeRoutePolicyPayload,
}
/// Short-lived room invite. Only the creator receives the secret code.
@@ -320,6 +389,19 @@ pub struct RoomInvitePayload {
pub expires_at: DateTime,
}
+/// Privacy-safe proof of a completed match paired with a fresh, expiring continuation invite.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/MatchReceiptPayload.ts")]
+pub struct MatchReceiptPayload {
+ pub receipt_id: String,
+ pub game_id: String,
+ pub result: String,
+ pub route: NativeRouteCapability,
+ pub compatibility_verified: bool,
+ pub created_at: DateTime,
+ pub invite: RoomInvitePayload,
+}
+
/// Stable stages for privacy-minimized evidence from an abandoned alpha attempt.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../src/generated/AlphaFailureStage.ts")]
@@ -354,6 +436,8 @@ pub struct AlphaFailureReport {
pub stage: AlphaFailureStage,
pub error_code: String,
pub transport: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub native_route: Option,
pub client: MatchReportClient,
}
@@ -368,6 +452,32 @@ pub struct MatchReport {
pub client: MatchReportClient,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub native_route: Option,
+}
+
+/// A k-anonymous compatibility cohort suitable for unauthenticated publication.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/PublicCompatibilityCohort.ts")]
+pub struct PublicCompatibilityCohort {
+ pub game_id: String,
+ pub platform: String,
+ pub adapter: String,
+ pub emulator_version: Option,
+ pub transport: String,
+ pub native_route: NativeRouteCapability,
+ pub attempts: u32,
+ pub verified: u32,
+}
+
+/// Public aggregate contract. Cohorts below `minimum_cohort_size` are omitted server-side.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[ts(export, export_to = "../src/generated/PublicCompatibilityPayload.ts")]
+pub struct PublicCompatibilityPayload {
+ pub schema_version: u8,
+ pub generated_at: DateTime,
+ pub minimum_cohort_size: u8,
+ pub cohorts: Vec,
}
/// Room lifecycle states. Serialized as `snake_case` strings.
/// ARCHITECTURE.md §9 DB stores WAITING/READY/PLAYING/FINISHED/CANCELLED (upper);
@@ -736,6 +846,30 @@ mod tests {
assert!(uuid::Uuid::parse_str(&env.request_id).is_ok());
}
+ #[test]
+ fn borrowed_envelope_validates_without_materializing_payload() {
+ let raw = r#"{"type":"match.endpoint","version":"1.0","request_id":"request-1","timestamp":"2026-08-28T12:00:00Z","payload":{"room_id":"fb3a171c-82e7-4f27-a130-68cff7085737"}}"#;
+ let envelope = BorrowedEnvelope::parse(raw).expect("valid borrowed envelope");
+ assert_eq!(envelope.msg_type, "match.endpoint");
+ let payload: serde_json::Value = envelope.payload_as().expect("typed payload");
+ assert_eq!(payload["room_id"], "fb3a171c-82e7-4f27-a130-68cff7085737");
+ }
+
+ #[test]
+ fn borrowed_envelope_rejects_invalid_common_fields() {
+ let invalid_timestamp = r#"{"type":"ping","version":"1.0","request_id":"request-1","timestamp":"not-a-date","payload":{}}"#;
+ assert_eq!(
+ BorrowedEnvelope::parse(invalid_timestamp).unwrap_err(),
+ "invalid timestamp"
+ );
+
+ let whitespace_type = r#"{"type":" ","version":"1.0","request_id":"request-1","timestamp":"2026-08-28T12:00:00Z","payload":{}}"#;
+ assert_eq!(
+ BorrowedEnvelope::parse(whitespace_type).unwrap_err(),
+ "invalid message type"
+ );
+ }
+
#[test]
fn reply_preserves_request_id() {
let reply = Envelope::reply("pong", "request-123", json!({}));
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index 237c253..bfc8176 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -8,6 +8,11 @@ import type { MatchReport } from "./generated/MatchReport.js";
import type { MatchReportClient } from "./generated/MatchReportClient.js";
import type { MatchReportCompatibility } from "./generated/MatchReportCompatibility.js";
import type { MatchPreflightPayload } from "./generated/MatchPreflightPayload.js";
+import type { ControllerPreflightPayload } from "./generated/ControllerPreflightPayload.js";
+import type { MatchReceiptPayload } from "./generated/MatchReceiptPayload.js";
+import type { NativeRoutePolicyPayload } from "./generated/NativeRoutePolicyPayload.js";
+import type { PublicCompatibilityCohort } from "./generated/PublicCompatibilityCohort.js";
+import type { PublicCompatibilityPayload } from "./generated/PublicCompatibilityPayload.js";
import type { LaunchBarrierPayload } from "./generated/LaunchBarrierPayload.js";
import type { NativeRouteCapability } from "./generated/NativeRouteCapability.js";
import type { RoomInvitePayload } from "./generated/RoomInvitePayload.js";
@@ -39,6 +44,11 @@ export type {
MatchReportClient,
MatchReportCompatibility,
MatchPreflightPayload,
+ ControllerPreflightPayload,
+ MatchReceiptPayload,
+ NativeRoutePolicyPayload,
+ PublicCompatibilityCohort,
+ PublicCompatibilityPayload,
LaunchBarrierPayload,
NativeRouteCapability,
RoomInvitePayload,
diff --git a/scripts/alpha/Build-TestCore.ps1 b/scripts/alpha/Build-TestCore.ps1
new file mode 100644
index 0000000..172868d
--- /dev/null
+++ b/scripts/alpha/Build-TestCore.ps1
@@ -0,0 +1,29 @@
+[CmdletBinding()]
+param(
+ [string]$OutputDirectory = "fixtures\libretro\opencade-test-core\build"
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
+$source = Join-Path $repositoryRoot "fixtures\libretro\opencade-test-core\opencade_test_core.c"
+$output = Join-Path $repositoryRoot $OutputDirectory
+New-Item -ItemType Directory -Path $output -Force | Out-Null
+
+$compiler = Get-Command cl.exe -ErrorAction SilentlyContinue
+if (-not $compiler) {
+ throw "Microsoft C compiler cl.exe is required to build the OpenCade test core"
+}
+
+Push-Location $output
+try {
+ & $compiler.Source /nologo /LD /O2 /W4 /WX /TC $source /link /OUT:opencade_test_libretro.dll
+ if ($LASTEXITCODE -ne 0) {
+ throw "OpenCade test core compilation failed with code $LASTEXITCODE"
+ }
+} finally {
+ Pop-Location
+}
+
+Write-Host "Built original OpenCade test core at $output\opencade_test_libretro.dll"
diff --git a/scripts/alpha/OpenCade-Alpha.ps1 b/scripts/alpha/OpenCade-Alpha.ps1
index f897926..bd6b8ec 100644
--- a/scripts/alpha/OpenCade-Alpha.ps1
+++ b/scripts/alpha/OpenCade-Alpha.ps1
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
- [ValidateSet("Package", "Verify", "Doctor", "Launch")]
+ [ValidateSet("Package", "Verify", "InstallTestFixture", "Doctor", "Launch")]
[string]$Mode = "Doctor",
[string]$KitRoot = ".",
[string]$ApiUrl,
@@ -16,6 +16,8 @@ $requiredKitFiles = @(
"opencade-match-probe.exe",
"opencade-match-verify.exe",
"opencade-alpha-summary.exe",
+ "opencade_test_libretro.dll",
+ "opencade_test.ocade",
"OpenCade-Alpha.ps1",
"README.txt",
"RETROARCH_TEST.md",
@@ -53,6 +55,8 @@ function Package-Kit {
"opencade-match-probe.exe" = "target\release\opencade-match-probe.exe"
"opencade-match-verify.exe" = "target\release\opencade-match-verify.exe"
"opencade-alpha-summary.exe" = "target\release\opencade-alpha-summary.exe"
+ "opencade_test_libretro.dll" = "fixtures\libretro\opencade-test-core\build\opencade_test_libretro.dll"
+ "opencade_test.ocade" = "fixtures\libretro\opencade-test-core\opencade_test.ocade"
"OpenCade-Alpha.ps1" = "scripts\alpha\OpenCade-Alpha.ps1"
"README.txt" = "scripts\alpha\README.txt"
"RETROARCH_TEST.md" = "docs\alpha\RETROARCH_TEST.md"
@@ -159,7 +163,7 @@ function Invoke-Doctor {
throw "-RetroArchRoot is required for Doctor and Launch modes"
}
$retroRoot = (Resolve-Path -LiteralPath $RetroArchRoot).Path
- foreach ($relativePath in @("retroarch.exe", "cores\fbneo_libretro.dll", "VERSION.txt", "ROMs")) {
+ foreach ($relativePath in @("retroarch.exe", "cores\opencade_test_libretro.dll", "VERSION.txt", "ROMs\opencade_test.ocade")) {
if (-not (Test-Path -LiteralPath (Join-Path $retroRoot $relativePath))) {
throw "RetroArch layout is incomplete: $relativePath is missing"
}
@@ -177,6 +181,36 @@ function Invoke-Doctor {
Write-Host "Alpha doctor passed. Reports directory: $reports"
}
+function Install-TestFixture {
+ param([string]$Root)
+
+ Verify-Kit -Root $Root
+ if ([string]::IsNullOrWhiteSpace($RetroArchRoot)) {
+ throw "-RetroArchRoot is required for InstallTestFixture mode"
+ }
+ $retroRoot = (Resolve-Path -LiteralPath $RetroArchRoot).Path
+ $destinations = @{
+ "opencade_test_libretro.dll" = "cores\opencade_test_libretro.dll"
+ "opencade_test.ocade" = "ROMs\opencade_test.ocade"
+ }
+ foreach ($sourceName in $destinations.Keys) {
+ $source = Join-Path $Root $sourceName
+ $destination = Join-Path $retroRoot $destinations[$sourceName]
+ $parent = Split-Path -Parent $destination
+ New-Item -ItemType Directory -Path $parent -Force | Out-Null
+ if (Test-Path -LiteralPath $destination -PathType Leaf) {
+ $sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
+ $destinationHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash
+ if ($sourceHash -ne $destinationHash) {
+ throw "A different $sourceName already exists. Remove it explicitly before installing the verified fixture."
+ }
+ continue
+ }
+ Copy-Item -LiteralPath $source -Destination $destination
+ }
+ Write-Host "Installed the verified OpenCade test fixture below $retroRoot"
+}
+
$resolvedKitRoot = Resolve-KitRoot -Path $KitRoot
switch ($Mode) {
@@ -187,6 +221,9 @@ switch ($Mode) {
"Verify" {
Verify-Kit -Root $resolvedKitRoot
}
+ "InstallTestFixture" {
+ Install-TestFixture -Root $resolvedKitRoot
+ }
"Doctor" {
Invoke-Doctor -Root $resolvedKitRoot
}
diff --git a/scripts/alpha/README.txt b/scripts/alpha/README.txt
index 0ec0c2f..2f9a028 100644
--- a/scripts/alpha/README.txt
+++ b/scripts/alpha/README.txt
@@ -1,13 +1,17 @@
OpenCade Windows Alpha Kit
==========================
-This is an unsigned test build. It contains no emulator, core, BIOS, or ROM content.
+This is an unsigned test build. It contains no emulator, BIOS, or ROM content. It includes one
+original Apache-2.0 deterministic libretro test core and harmless OpenCade test content.
1. Verify the downloaded kit:
powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode Verify
-2. Prepare the user-supplied RetroArch layout described in RETROARCH_TEST.md.
+2. Prepare user-supplied RetroArch, then explicitly install the verified no-ROM test fixture:
+
+ powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode InstallTestFixture `
+ -RetroArchRoot C:\OpenCadeAlpha\retroarch
3. Validate the machine, server, and RetroArch layout:
diff --git a/services/relay/src/main.rs b/services/relay/src/main.rs
index 9749bdb..4b274cc 100644
--- a/services/relay/src/main.rs
+++ b/services/relay/src/main.rs
@@ -10,8 +10,10 @@ use axum::{
};
use dashmap::DashMap;
use futures::{SinkExt, StreamExt};
+use opencade_protocol::{BorrowedEnvelope, is_supported_version};
use opencade_shared::{RelayCapability, RelayTicket};
-use serde_json::{Value, json};
+use serde::Deserialize;
+use serde_json::json;
use std::{
env,
net::SocketAddr,
@@ -23,7 +25,11 @@ use tracing::{debug, info, warn};
use tracing_subscriber::EnvFilter;
use uuid::Uuid;
-use opencade_protocol::{Envelope, is_supported_version};
+#[derive(Deserialize)]
+struct RelayPayloadRef<'a> {
+ #[serde(borrow)]
+ room_id: &'a str,
+}
#[derive(Clone)]
struct Config {
@@ -90,6 +96,25 @@ fn build_relay_app(state: SharedState) -> Router {
.with_state(state)
}
+fn spawn_ticket_cleanup(state: SharedState) {
+ tokio::spawn(async move {
+ let mut interval = tokio::time::interval(Duration::from_secs(30));
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ loop {
+ interval.tick().await;
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .and_then(|duration| i64::try_from(duration.as_secs()).ok());
+ if let Some(now) = now {
+ state
+ .used_tickets
+ .retain(|_, expires_at| *expires_at >= now);
+ }
+ }
+ });
+}
+
fn init_tracing(rust_log: &str) {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(rust_log));
let is_prod = env::var("OPENCADE_ENV")
@@ -140,9 +165,6 @@ 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)
@@ -255,22 +277,21 @@ async fn handle_socket(
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(),
+ text.len(),
) {
warn!(peer_id = %id, room_id = %room_id, "relay rate limit exceeded");
break;
}
- if text_str.len() > 64 * 1024 {
+ if text.len() > 64 * 1024 {
warn!(peer_id = %id, room_id = %room_id, "oversized relay text rejected");
continue;
}
- if let Ok(envelope) = serde_json::from_str::>(&text_str) {
- if !is_supported_version(&envelope.version) {
+ if let Ok(envelope) = serde_json::from_str::>(&text) {
+ if !is_supported_version(envelope.version) {
warn!(peer_id = %id, version = %envelope.version, "unsupported version");
let err = json!({
"type": "error",
@@ -293,9 +314,8 @@ async fn handle_socket(
continue;
}
let envelope_room = envelope
- .payload
- .get("room_id")
- .and_then(|v| v.as_str())
+ .payload_as::>()
+ .map(|payload| payload.room_id)
.unwrap_or_default();
if envelope_room != room_id {
warn!(peer_id = %id, room_id = %room_id, "cross-room relay rejected");
@@ -355,15 +375,7 @@ async fn handle_socket(
{
room.remove(&user_id);
}
- let empty_rooms: Vec = state
- .rooms
- .iter()
- .filter(|e| e.is_empty())
- .map(|e| e.key().clone())
- .collect();
- for room_id in empty_rooms {
- state.rooms.remove(&room_id);
- }
+ state.rooms.remove_if(&room_key, |_, room| room.is_empty());
send_task.abort();
info!(peer_id = %id, "relay peer disconnected");
@@ -425,6 +437,7 @@ async fn main() -> Result<(), Box> {
used_tickets: DashMap::new(),
});
+ spawn_ticket_cleanup(Arc::clone(&state));
let app = build_relay_app(state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
diff --git a/site/compatibility/index.html b/site/compatibility/index.html
new file mode 100644
index 0000000..b0a218b
--- /dev/null
+++ b/site/compatibility/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ OpenCade Compatibility Evidence
+
+
+
+
+
+ OpenCade public evidence
+ Compatibility map
+
+ Only cohorts with at least three independent rooms appear. Raw reports, player identities,
+ paths, endpoints, and fingerprints are never published.
+
+
+
+
+
Verified cohorts
+ Refresh evidence
+
+ Loading compatibility evidence…
+
+
+
+
+
+
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 };