diff --git a/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts b/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts new file mode 100644 index 0000000000..b72d9c3ff5 --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../draft-adapter", () => ({ + DraftAdapter: vi.fn().mockImplementation(function () { + return {}; + }), +})); + +vi.mock("../../services/draftPersistence", () => ({ + saveDraftHostSession: vi.fn().mockResolvedValue(undefined), + clearDraftHostSession: vi.fn(), +})); + +import { generateP2pDraftCode, P2PDraftHost } from "../p2p-draft-host"; +import type { DraftPlayerView } from "../draft-adapter"; +import { saveDraftHostSession } from "../../services/draftPersistence"; +import { + resolveP2pBackupEndpoint, + wsUrlToHttpOrigin, +} from "../../config/multiplayerServer"; + +describe("P2P draft backup contract", () => { + it("generateP2pDraftCode matches the server 6-char uppercase contract", () => { + const code = generateP2pDraftCode(() => new Uint8Array([0, 25, 26, 35, 1, 10])); + expect(code).toBe("AZ09BK"); + expect(code).toMatch(/^[A-Z0-9]{6}$/); + }); + + it("wsUrlToHttpOrigin strips /ws for the backup HTTP base", () => { + expect(wsUrlToHttpOrigin("wss://lobby.phase-rs.dev/ws")).toBe( + "https://lobby.phase-rs.dev", + ); + expect(wsUrlToHttpOrigin("ws://127.0.0.1:9374/ws")).toBe("http://127.0.0.1:9374"); + expect(resolveP2pBackupEndpoint("wss://lobby.phase-rs.dev/ws")).toBe( + "https://lobby.phase-rs.dev", + ); + }); +}); + +describe("P2PDraftHost server backup", () => { + const BACKUP_URL = "https://backup.example"; + const draftingView = { + status: "Drafting", + pick_number: 1, + seats: [ + { seat_index: 0, is_bot: false, display_name: "Host", picks: [] }, + { seat_index: 1, is_bot: true, display_name: "Bot 1", picks: [] }, + ], + current_pack: [], + pairings: [], + current_round: 1, + } as unknown as DraftPlayerView; + + let fetchMock: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + warnSpy.mockRestore(); + vi.clearAllMocks(); + }); + + function makeHost(): P2PDraftHost { + return new P2PDraftHost( + { id: "host-peer-abc" } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 2, + "Host", + "Swiss", + "Casual", + undefined, + "persist-backup-test", + "ROOM01", + BACKUP_URL, + ); + } + + function wireAdapter(host: P2PDraftHost): void { + const adapter = (host as unknown as { adapter: Record }).adapter; + adapter.createMultiplayerDraft = vi.fn().mockResolvedValue(undefined); + adapter.getViewForSeat = vi.fn(async () => draftingView); + adapter.exportSession = vi.fn().mockResolvedValue('{"status":"Drafting"}'); + } + + async function flushPersistQueue(host: P2PDraftHost): Promise { + await (host as unknown as { persistQueue: Promise }).persistQueue; + } + + it("uploads a server-valid draft code on draft start", async () => { + const host = makeHost(); + wireAdapter(host); + + await host.startDraft(); + await flushPersistQueue(host); + + expect(saveDraftHostSession).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${BACKUP_URL}/p2p-draft-backup`, + expect.objectContaining({ method: "POST" }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.host_peer_id).toBe("host-peer-abc"); + expect(body.draft_code).toMatch(/^[A-Z0-9]{6}$/); + expect(body.draft_code).not.toMatch(/^draft-/); + }); + + it("logs non-2xx upload responses instead of treating them as success", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 400 }); + const host = makeHost(); + wireAdapter(host); + + await host.startDraft(); + await flushPersistQueue(host); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("server backup upload failed: HTTP 400"), + ); + }); +}); diff --git a/client/src/adapter/draftPodHostAdapter.ts b/client/src/adapter/draftPodHostAdapter.ts index 4ec9d2c786..8e61b60ccf 100644 --- a/client/src/adapter/draftPodHostAdapter.ts +++ b/client/src/adapter/draftPodHostAdapter.ts @@ -14,6 +14,7 @@ import { DraftAdapter } from "./draft-adapter"; import type { DraftPlayerView, PairingView, PodPolicy, PoolInput, SeatPublicView, TournamentFormat } from "./draft-adapter"; import type { MatchScore } from "./types"; import { P2PDraftHost, type DraftHostEvent } from "./p2p-draft-host"; +import { resolveP2pBackupEndpoint } from "../config/multiplayerServer"; import { hostRoom, type HostResult } from "../network/connection"; import type { DraftMatchLaunch, DraftPauseReason } from "../network/draftProtocol"; import type { BrokerClient, RegisterHostRequest } from "../services/brokerClient"; @@ -207,7 +208,9 @@ export class DraftPodHostAdapter { await new DraftAdapter().loadCardDatabase(await resp.text()); } - // 4. Create P2PDraftHost + // 4. Create P2PDraftHost. Wire the phase-server HTTP origin so + // best-effort `/p2p-draft-backup` uploads actually run in production + // (omitting backupEndpoint left the upload gate permanently closed). const host = new P2PDraftHost( hostResult.peer, hostResult.onGuestConnected, @@ -220,6 +223,7 @@ export class DraftPodHostAdapter { undefined, // default grace period config.persistenceId, hostResult.roomCode, + resolveP2pBackupEndpoint() ?? undefined, ); // 4. Wire host events diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 26fede35a9..3c184f86d8 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -123,6 +123,25 @@ function hashStringToSeed(value: string): number { return hash >>> 0; } +/** Alphabet shared with `server_core::generate_draft_code` / `is_valid_draft_code`. */ +const DRAFT_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +/** + * Generate a 6-character uppercase alphanumeric draft code matching the + * phase-server `/p2p-draft-backup` validator. + */ +export function generateP2pDraftCode( + randomValues: (size: number) => Uint8Array = (size) => + crypto.getRandomValues(new Uint8Array(size)), +): string { + const values = randomValues(6); + let code = ""; + for (let i = 0; i < 6; i++) { + code += DRAFT_CODE_ALPHABET[values[i]! % DRAFT_CODE_ALPHABET.length]!; + } + return code; +} + function sideboardFromPool( session: ExportedDraftSession, seat: number, @@ -464,7 +483,9 @@ export class P2PDraftHost { const seed = Math.floor(Math.random() * 0xffffffff); this.draftSeed = seed; - const draftCode = `draft-${seed.toString(16).padStart(8, "0")}`; + // Must match server_core::is_valid_draft_code (6 uppercase alnum). + // The legacy `draft-xxxxxxxx` shape is rejected by phase-server with 400. + const draftCode = generateP2pDraftCode(); const seats: MultiplayerSeatDescriptor[] = []; for (let i = 0; i < this.podSize; i++) { const displayName = this.seatNames.get(i); @@ -508,6 +529,9 @@ export class P2PDraftHost { } } + // Force the first persisted state to upload immediately so the host + // claims the backup row before the normal N-picks interval. + this.picksSinceLastBackup = P2PDraftHost.BACKUP_INTERVAL_PICKS; this.persistSession(); const freshHostView = await this.adapter.getViewForSeat(0); this.emit({ type: "draftStarted", view: freshHostView }); @@ -1396,12 +1420,12 @@ export class P2PDraftHost { /** * Upload a backup snapshot to the phase-server (best-effort, D-08). - * Failures are silently logged — P2P works without server backup. + * Failures are logged — P2P works without server backup. */ private async uploadBackupSnapshot(snapshot: PersistedDraftHostSession): Promise { if (!this.backupEndpoint || !this.draftCode) return; try { - await fetch(`${this.backupEndpoint}/p2p-draft-backup`, { + const response = await fetch(`${this.backupEndpoint}/p2p-draft-backup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -1410,6 +1434,11 @@ export class P2PDraftHost { snapshot_json: JSON.stringify(snapshot), }), }); + if (!response.ok) { + console.warn( + `[P2PDraftHost] server backup upload failed: HTTP ${response.status}`, + ); + } } catch (err) { console.warn("[P2PDraftHost] server backup upload failed:", err); } @@ -1422,10 +1451,15 @@ export class P2PDraftHost { if (!this.backupEndpoint || !this.draftCode) return; try { const params = new URLSearchParams({ host_peer_id: this.hostPeer.id }); - await fetch( + const response = await fetch( `${this.backupEndpoint}/p2p-draft-backup/${this.draftCode}?${params}`, { method: "DELETE" }, ); + if (!response.ok) { + console.warn( + `[P2PDraftHost] server backup cleanup failed: HTTP ${response.status}`, + ); + } } catch { // Best-effort cleanup } diff --git a/client/src/config/multiplayerServer.ts b/client/src/config/multiplayerServer.ts index 611328fcd7..7ef6765792 100644 --- a/client/src/config/multiplayerServer.ts +++ b/client/src/config/multiplayerServer.ts @@ -13,3 +13,43 @@ export function isOfficialMultiplayerServerUrl(value: string): boolean { return false; } } + +/** + * Convert a multiplayer WebSocket URL to the HTTP origin that serves + * `/p2p-draft-backup` (and `/health`). Strips a trailing `/ws` path. + * + * Examples: + * - `wss://lobby.phase-rs.dev/ws` → `https://lobby.phase-rs.dev` + * - `ws://127.0.0.1:9374/ws` → `http://127.0.0.1:9374` + */ +export function wsUrlToHttpOrigin(wsUrl: string): string | null { + try { + const url = new URL(wsUrl); + if (url.protocol === "wss:") { + url.protocol = "https:"; + } else if (url.protocol === "ws:") { + url.protocol = "http:"; + } else { + return null; + } + if (url.pathname === "/ws" || url.pathname.endsWith("/ws")) { + url.pathname = url.pathname.replace(/\/ws\/?$/, "") || "/"; + } + url.search = ""; + url.hash = ""; + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + return `${url.origin}${path}`; + } catch { + return null; + } +} + +/** + * HTTP base URL for best-effort P2P draft server backups. + * Prefer an explicit Vite override, else the official multiplayer lobby. + */ +export function resolveP2pBackupEndpoint( + wsUrl: string = import.meta.env.VITE_WS_URL ?? OFFICIAL_MULTIPLAYER_SERVER_URL, +): string | null { + return wsUrlToHttpOrigin(wsUrl); +} diff --git a/crates/phase-server/src/admin.rs b/crates/phase-server/src/admin.rs index c8ec98bc32..d2097c27f0 100644 --- a/crates/phase-server/src/admin.rs +++ b/crates/phase-server/src/admin.rs @@ -7,20 +7,12 @@ use serde_json::Value; use tracing::{info, warn}; use server_core::{ - guard_p2p_backup, guard_p2p_backup_overwrite, redact_p2p_backup_snapshot_secrets, - validate_p2p_backup_host_peer_id, + guard_p2p_backup, guard_p2p_backup_overwrite, is_valid_draft_code, + redact_p2p_backup_snapshot_secrets, validate_p2p_backup_host_peer_id, }; use crate::AppState; -/// Validate draft code format: exactly 6 alphanumeric uppercase chars. -fn is_valid_draft_code(code: &str) -> bool { - code.len() == 6 - && code - .chars() - .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) -} - /// GET /admin/drafts — List all active draft sessions with summary info. pub async fn admin_list_drafts(State(app_state): State) -> Json { let drafts = app_state.draft_sessions.lock().await; diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index aaa166fa5a..49a261170e 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -8288,6 +8288,32 @@ mod p2p_backup_delete_tests { StatusCode::from_u16(status_code).expect("status code") } + async fn post_json(base_url: &str, path: &str, body: &str) -> StatusCode { + let url = Url::parse(&format!("{base_url}{path}")).expect("url"); + let host = url.host_str().expect("host"); + let port = url.port().expect("port"); + let mut stream = tokio::net::TcpStream::connect((host, port)) + .await + .expect("connect"); + let mut request = format!("POST {path} HTTP/1.1\r\n"); + request.push_str(&format!("Host: {host}\r\n")); + request.push_str("Content-Type: application/json\r\n"); + request.push_str(&format!("Content-Length: {}\r\n", body.len())); + request.push_str("Connection: close\r\n\r\n"); + request.push_str(body); + stream.write_all(request.as_bytes()).await.expect("write"); + let mut buf = [0u8; 1024]; + let n = stream.read(&mut buf).await.expect("read"); + let response = std::str::from_utf8(&buf[..n]).expect("utf8"); + let status_code = response + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|s| s.parse::().ok()) + .expect("status line"); + StatusCode::from_u16(status_code).expect("status code") + } + fn seed_backup(app_state: &AppState) { app_state .game_db @@ -8418,4 +8444,71 @@ mod p2p_backup_delete_tests { ); server.abort(); } + + #[tokio::test] + async fn post_rejects_legacy_draft_xxxxxxxx_code() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_state = test_app_state(&temp_dir); + let (base_url, server) = spawn_p2p_backup_http_test(app_state).await; + + let body = format!( + r#"{{"draft_code":"draft-abcdef12","host_peer_id":"{HOST_PEER}","snapshot_json":{}}}"#, + serde_json::to_string(SNAPSHOT).expect("json") + ); + assert_eq!( + post_json(&base_url, "/p2p-draft-backup", &body).await, + StatusCode::BAD_REQUEST, + ); + server.abort(); + } + + #[tokio::test] + async fn post_get_delete_round_trip_with_client_shaped_snapshot() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_state = test_app_state(&temp_dir); + let game_db = Arc::clone(&app_state.game_db); + let (base_url, server) = spawn_p2p_backup_http_test(app_state).await; + + // Client-shaped host session (secrets must be redacted on store). + let snapshot = + r#"{"status":"Drafting","seatTokens":{"0":"secret-token"},"kickedTokens":["kicked"]}"#; + let body = format!( + r#"{{"draft_code":"{DRAFT_CODE}","host_peer_id":"{HOST_PEER}","snapshot_json":{}}}"#, + serde_json::to_string(snapshot).expect("json") + ); + assert_eq!( + post_json(&base_url, "/p2p-draft-backup", &body).await, + StatusCode::OK, + ); + let stored = game_db + .load_p2p_backup(DRAFT_CODE) + .expect("load") + .expect("row created"); + assert_eq!(stored.0, HOST_PEER); + assert!( + !stored.1.contains("secret-token"), + "stored snapshot must redact seatTokens" + ); + + assert_eq!( + request_status( + &base_url, + "GET", + &format!("/p2p-draft-backup/{DRAFT_CODE}?host_peer_id={HOST_PEER}"), + ) + .await, + StatusCode::OK, + ); + assert_eq!( + request_status( + &base_url, + "DELETE", + &format!("/p2p-draft-backup/{DRAFT_CODE}?host_peer_id={HOST_PEER}"), + ) + .await, + StatusCode::OK, + ); + assert!(game_db.load_p2p_backup(DRAFT_CODE).expect("load").is_none()); + server.abort(); + } } diff --git a/crates/server-core/src/draft_session.rs b/crates/server-core/src/draft_session.rs index fe5f860336..29aa06742b 100644 --- a/crates/server-core/src/draft_session.rs +++ b/crates/server-core/src/draft_session.rs @@ -679,6 +679,18 @@ pub fn generate_draft_code() -> String { .collect() } +/// Shared draft-code contract for lobby WS drafts and HTTP P2P backups. +/// +/// Exactly six ASCII uppercase letters or digits — the same shape +/// [`generate_draft_code`] produces. Rejects legacy client shapes such as +/// `draft-xxxxxxxx`. +pub fn is_valid_draft_code(code: &str) -> bool { + code.len() == 6 + && code + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) +} + /// Returns the appropriate reconnect grace period for the given draft phase. /// /// Longer than the 10s game reconnect because tournaments span hours. @@ -1080,10 +1092,16 @@ mod tests { #[test] fn draft_code_is_uppercase_alphanumeric() { let code = generate_draft_code(); - assert_eq!(code.len(), 6); - assert!(code - .chars() - .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); + assert!(is_valid_draft_code(&code)); + } + + #[test] + fn is_valid_draft_code_rejects_legacy_p2p_shape() { + assert!(is_valid_draft_code("BACK01")); + assert!(!is_valid_draft_code("draft-abcdef12")); + assert!(!is_valid_draft_code("abc123")); + assert!(!is_valid_draft_code("BACK0")); + assert!(!is_valid_draft_code("BACK011")); } #[test] diff --git a/crates/server-core/src/lib.rs b/crates/server-core/src/lib.rs index d335f9a74c..6f3fd82e82 100644 --- a/crates/server-core/src/lib.rs +++ b/crates/server-core/src/lib.rs @@ -33,7 +33,9 @@ pub use client_message_wire_guard::{ }; pub use deck_resolve::resolve_deck; pub use draft_action_payload_guard::guard_draft_action_payload; -pub use draft_session::{generate_draft_code, DraftSession, DraftSessionManager}; +pub use draft_session::{ + generate_draft_code, is_valid_draft_code, DraftSession, DraftSessionManager, +}; pub use draft_wire_guard::{ guard_create_draft_with_settings, guard_draft_action, guard_join_draft_with_password, guard_reconnect_draft,