Open join-policy documents in the system browser

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-15 22:06:01 -04:00
parent 15d674f185
commit 855d29aa96
11 changed files with 254 additions and 51 deletions

45
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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"]

View File

@@ -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<Arc<AppState>>) -> Json<Value> {
}
}
/// `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<Arc<AppState>>,
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
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<Arc<AppState>>,
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
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<Html<String>, (StatusCode, Json<Value>)> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{escaped_title}</title>\n\
<style>body{{max-width:42rem;margin:2rem auto;padding:0 1rem;\
font-family:system-ui,sans-serif;line-height:1.6}}</style>\n\
</head>\n<body>\n{body}</body>\n</html>\n"
)
}
/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
pub async fn accept_policy(
State(state): State<Arc<AppState>>,
@@ -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<script>alert(1)</script>",
);
assert!(page.contains("<title>Terms of Service</title>"), "{page}");
assert!(page.contains("<h1>Terms</h1>"), "{page}");
// `&` inside prose must be entity-encoded by the HTML writer.
assert!(page.contains("Be kind &amp; honest."), "{page}");
// Raw HTML in operator Markdown renders as escaped text, never markup.
assert!(!page.contains("<script>"), "{page}");
assert!(
page.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
"{page}"
);
}
/// The document routes are public (no NIP-98) and 404 until configured,
/// exactly like the JSON policy endpoint they sit beside.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn join_policy_document_pages_serve_configured_markdown() {
let host = format!("invites-docs-{}.example", Uuid::new_v4().simple());
let Some(state) = invite_test_state(&host).await else {
return;
};
let get_page = |state: Arc<crate::state::AppState>, path: &'static str| {
let host = host.clone();
async move {
build_router(state)
.oneshot(
Request::builder()
.method("GET")
.uri(path)
.header(header::HOST, host)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response")
}
};
// Unconfigured relay: both documents 404.
let response = get_page(state.clone(), "/api/join-policy/terms").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = get_page(state.clone(), "/api/join-policy/privacy").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// Configure terms only — terms serves HTML, privacy still 404s.
let mut state_inner = (*state).clone();
let mut config = state_inner.config.as_ref().clone();
config.join_policy = Some(crate::config::JoinPolicyConfig {
terms_markdown: Some("# Terms\n\nNo funny business.".to_string()),
privacy_markdown: None,
age_attestation_required: false,
version: "v".repeat(64),
});
state_inner.config = Arc::new(config);
let state = Arc::new(state_inner);
let response = get_page(state.clone(), "/api/join-policy/terms").await;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(content_type.starts_with("text/html"), "{content_type}");
let bytes = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("read body");
let page = String::from_utf8(bytes.to_vec()).expect("utf8");
assert!(page.contains("<h1>Terms</h1>"), "{page}");
assert!(page.contains("No funny business."), "{page}");
let response = get_page(state, "/api/join-policy/privacy").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}

View File

@@ -84,6 +84,16 @@ pub fn build_router(state: Arc<AppState>) -> Router {
// Relay invites: mint (owner/admin) + claim (membership-gate exempt)
.route("/api/invites", post(api::invites::mint_invite))
.route("/api/join-policy", get(api::invites::join_policy))
// Policy documents as standalone pages — desktop opens these in the
// system browser instead of rendering the Markdown in-app.
.route(
"/api/join-policy/terms",
get(api::invites::join_policy_terms),
)
.route(
"/api/join-policy/privacy",
get(api::invites::join_policy_privacy),
)
.route(
"/api/invites/accept-policy",
post(api::invites::accept_policy),

View File

@@ -243,7 +243,7 @@ export function AddCommunityDialog({
{inviteError ? (
<p className="text-xs text-destructive">{inviteError}</p>
) : null}
{joinPolicy ? (
{joinPolicy && relayUrl.trim() ? (
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={(confirmed) => {
@@ -251,6 +251,9 @@ export function AddCommunityDialog({
setInviteError(null);
}}
policy={joinPolicy}
// Editing the relay URL resets joinPolicy, so a visible
// notice always belongs to the URL currently in the field.
relayWsUrl={normalizeRelayUrl(relayUrl.trim())}
/>
) : null}
</div>

View File

@@ -195,7 +195,7 @@ export function CommunityEditForm({
/>
</div>
{joinPolicy ? (
{joinPolicy && policyRelayUrl ? (
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={(confirmed) => {
@@ -203,6 +203,7 @@ export function CommunityEditForm({
setError(null);
}}
policy={joinPolicy}
relayWsUrl={policyRelayUrl}
/>
) : null}

View File

@@ -60,6 +60,7 @@ function NostrKeyImportPage({
onAgeConfirmedChange,
onBack,
onImport,
relayWsUrl,
}: {
ageConfirmed: boolean;
connectionError: string | null;
@@ -68,6 +69,7 @@ function NostrKeyImportPage({
onAgeConfirmedChange: (confirmed: boolean) => void;
onBack: () => void;
onImport: (nsec: string) => Promise<void>;
relayWsUrl: string;
}) {
return (
<OnboardingSlideTransition
@@ -92,6 +94,7 @@ function NostrKeyImportPage({
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={onAgeConfirmedChange}
policy={joinPolicy}
relayWsUrl={relayWsUrl}
/>
</div>
) : null}
@@ -307,6 +310,7 @@ export function WelcomeSetup({
ageConfirmed={defaultAgeConfirmed}
onAgeConfirmedChange={setDefaultAgeConfirmed}
policy={defaultJoinPolicy}
relayWsUrl={defaultRelayUrl}
/>
) : null}
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
@@ -467,6 +471,7 @@ export function WelcomeSetup({
onAgeConfirmedChange={setDefaultAgeConfirmed}
onBack={showWelcomePage}
onImport={handleNostrImport}
relayWsUrl={defaultRelayUrl}
/>
)}
</div>

View File

@@ -175,7 +175,7 @@ export function InviteRedeemForm({
</div>
) : null}
{joinPolicy ? (
{joinPolicy && policyInvite ? (
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={(confirmed) => {
@@ -183,6 +183,7 @@ export function InviteRedeemForm({
setPolicyError(null);
}}
policy={joinPolicy}
relayWsUrl={policyInvite.relayWsUrl}
/>
) : null}

View File

@@ -1,32 +1,32 @@
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { JoinPolicy } from "@/shared/api/invites";
import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Markdown } from "@/shared/ui/markdown";
type JoinPolicyNoticeProps = {
ageConfirmed: boolean;
onAgeConfirmedChange: (confirmed: boolean) => void;
policy: JoinPolicy;
/** Relay hosting the policy documents the links below point at. */
relayWsUrl: string;
};
/**
* Join-policy consent block shown on every join surface.
*
* The Terms/Privacy links open the relay-hosted document pages
* (`/api/join-policy/terms|privacy`) in the system browser via the OS
* opener. They must NOT navigate or render in-app: these surfaces exist
* before onboarding completes, where the router (required by the message
* Markdown component) is not mounted — an in-app render tears down the
* whole React tree.
*/
export function JoinPolicyNotice({
ageConfirmed,
onAgeConfirmedChange,
policy,
relayWsUrl,
}: JoinPolicyNoticeProps) {
const [openDocument, setOpenDocument] = React.useState<
"terms" | "privacy" | null
>(null);
const markdown =
openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
return (
<div className="space-y-3 rounded-md border p-3 text-left text-sm">
{policy.ageAttestationRequired ? (
@@ -47,7 +47,9 @@ export function JoinPolicyNotice({
{policy.termsMarkdown ? (
<Button
className="h-auto p-0 align-baseline"
onClick={() => setOpenDocument("terms")}
onClick={() =>
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"))
}
type="button"
variant="link"
>
@@ -58,7 +60,9 @@ export function JoinPolicyNotice({
{policy.privacyMarkdown ? (
<Button
className="h-auto p-0 align-baseline"
onClick={() => setOpenDocument("privacy")}
onClick={() =>
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"))
}
type="button"
variant="link"
>
@@ -68,22 +72,6 @@ export function JoinPolicyNotice({
.
</p>
) : null}
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDocument(null);
}}
open={openDocument !== null}
>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
</DialogTitle>
</DialogHeader>
{markdown ? <Markdown content={markdown} /> : null}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getJoinPolicy } from "./invites.ts";
import { getJoinPolicy, joinPolicyDocumentUrl } from "./invites.ts";
function withFetch(response, run) {
const originalFetch = globalThis.fetch;
@@ -52,3 +52,14 @@ test("getJoinPolicy fails closed on a policy endpoint error", async () => {
assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
);
});
test("joinPolicyDocumentUrl builds browser links for both documents", () => {
assert.equal(
joinPolicyDocumentUrl("wss://relay.example", "terms"),
"https://relay.example/api/join-policy/terms",
);
assert.equal(
joinPolicyDocumentUrl("ws://localhost:7000/", "privacy"),
"http://localhost:7000/api/join-policy/privacy",
);
});

View File

@@ -91,6 +91,15 @@ async function invitePost<T>(
return json as T;
}
/** Absolute URL of a relay-hosted policy document page (system-browser target). */
export function joinPolicyDocumentUrl(
relayWsUrl: string,
document: "terms" | "privacy",
): string {
const base = relayHttpFromWs(relayWsUrl);
return `${base.replace(/\/+$/, "")}/api/join-policy/${document}`;
}
/** Fetch relay-hosted policy content for any join surface. */
export async function getJoinPolicy(
relayWsUrl: string,