From 855d29aa96c2831b00f4a8ead0c5ff8fb9b9c31b Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Wed, 15 Jul 2026 22:06:01 -0400 Subject: [PATCH] Open join-policy documents in the system browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ToS/Privacy links on join surfaces opened an in-app dialog that rendered the policy Markdown with the message Markdown component, which requires the router. During onboarding the router is not mounted, so clicking either link tore down the whole React tree (blank app). Fix the class, not the symptom: - Relay: new public GET /api/join-policy/terms and /privacy routes render the operator-configured Markdown as minimal standalone HTML pages (pulldown-cmark; raw HTML in the Markdown is escaped to text; 404 when unconfigured, matching the JSON policy endpoint). - Desktop: JoinPolicyNotice links now openUrl() those pages in the system browser via the Tauri opener; the in-app dialog is deleted. Every call site threads relayWsUrl so links resolve against the relay that served the policy. The web invite page keeps its in-page dialog — it already runs in a browser and does not crash. Tests: render/escape unit test + Postgres-gated route test on the relay; joinPolicyDocumentUrl unit test on desktop. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- Cargo.lock | 45 +++-- crates/buzz-relay/Cargo.toml | 1 + crates/buzz-relay/src/api/invites.rs | 157 +++++++++++++++++- crates/buzz-relay/src/router.rs | 10 ++ .../communities/ui/AddCommunityDialog.tsx | 5 +- .../communities/ui/CommunityEditForm.tsx | 3 +- .../features/communities/ui/WelcomeSetup.tsx | 5 + .../onboarding/ui/InviteRedeemForm.tsx | 3 +- .../onboarding/ui/JoinPolicyNotice.tsx | 54 +++--- desktop/src/shared/api/invites.test.mjs | 13 +- desktop/src/shared/api/invites.ts | 9 + 11 files changed, 254 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6091f52b63..598142154f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,6 +1147,7 @@ dependencies = [ "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", "postcard", + "pulldown-cmark", "rand 0.10.1", "redis", "reqwest 0.13.4", @@ -1520,7 +1521,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2158,7 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2369,7 +2370,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2607,7 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2990,8 +2991,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -5549,7 +5550,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6733,6 +6734,24 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.0", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.29" @@ -6838,7 +6857,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7485,7 +7504,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7544,7 +7563,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7807,7 +7826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8704,7 +8723,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10003,7 +10022,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 36cc71dcb0..31eac113a1 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -77,6 +77,7 @@ moka = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } metrics-util = { workspace = true } +pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } [features] dev = ["buzz-auth/dev"] diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 7d004c08c9..cf7ee3a6eb 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -19,7 +19,7 @@ use std::time::Duration; use axum::{ extract::State, http::{HeaderMap, StatusCode}, - response::Json, + response::{Html, Json}, }; use serde::Deserialize; use serde_json::Value; @@ -86,6 +86,78 @@ pub async fn join_policy(State(state): State>) -> Json { } } +/// `GET /api/join-policy/terms` — Terms of Service as a standalone HTML page. +/// +/// Serves the operator-configured Markdown as a real browser page so desktop +/// clients can hand the link to the system browser instead of rendering the +/// document inside the webview (which requires app chrome the onboarding +/// surfaces don't have). 404 when no terms document is configured. +pub async fn join_policy_terms( + State(state): State>, +) -> Result, (StatusCode, Json)> { + policy_document_page(&state, "Terms of Service", |policy| { + policy.terms_markdown.as_deref() + }) +} + +/// `GET /api/join-policy/privacy` — Privacy Policy as a standalone HTML page. +pub async fn join_policy_privacy( + State(state): State>, +) -> Result, (StatusCode, Json)> { + policy_document_page(&state, "Privacy Policy", |policy| { + policy.privacy_markdown.as_deref() + }) +} + +fn policy_document_page( + state: &AppState, + title: &str, + select: impl Fn(&crate::config::JoinPolicyConfig) -> Option<&str>, +) -> Result, (StatusCode, Json)> { + let markdown = state + .config + .join_policy + .as_ref() + .and_then(select) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "join_policy_not_configured"))?; + Ok(Html(render_policy_document(title, markdown))) +} + +/// Render operator Markdown into a minimal self-contained HTML page. +/// +/// Raw HTML embedded in the Markdown is escaped and rendered as text — the +/// operator authors a policy document, not a web page, and this keeps the +/// endpoint from serving arbitrary operator-controlled markup. +fn render_policy_document(title: &str, markdown: &str) -> String { + use pulldown_cmark::{html, Event, Parser}; + + let mut body = String::new(); + html::push_html( + &mut body, + Parser::new(markdown).map(|event| match event { + Event::Html(raw) => Event::Text(raw.into_string().into()), + Event::InlineHtml(raw) => Event::Text(raw.into_string().into()), + other => other, + }), + ); + + // Titles are fixed literals today; escape anyway so a future caller + // can't accidentally inject markup through this seam. + let escaped_title = title + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + + format!( + "\n\n\n\n\ + \n\ + {escaped_title}\n\ + \n\ + \n\n{body}\n\n" + ) +} + /// Exchange explicit policy acceptance for a short-lived, invite-bound receipt. pub async fn accept_policy( State(state): State>, @@ -900,4 +972,87 @@ mod tests { let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); } + + #[test] + fn policy_document_renders_markdown_and_escapes_raw_html() { + let page = super::render_policy_document( + "Terms of Service", + "# Terms\n\nBe kind & honest.\n\n", + ); + assert!(page.contains("Terms of Service"), "{page}"); + assert!(page.contains("

Terms

"), "{page}"); + // `&` inside prose must be entity-encoded by the HTML writer. + assert!(page.contains("Be kind & honest."), "{page}"); + // Raw HTML in operator Markdown renders as escaped text, never markup. + assert!(!page.contains("