From 56f0a297030d2f49f2e2c4f8cbdf080b8c84a6fd Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 14:47:30 -0400
Subject: [PATCH] fix(join-policy): require legal consent on hosted invites

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
 crates/buzz-relay/src/api/invites.rs      | 157 +++++++++++++++++++++-
 crates/buzz-relay/src/router.rs           |  10 ++
 web/src/features/invite/ui/InvitePage.tsx |  91 ++++++-------
 3 files changed, 205 insertions(+), 53 deletions(-)

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<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);
+    }
 }
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index 0036a47af3..ed063398d3 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -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),
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 72a1d11c7e..7594d851b5 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -5,6 +5,8 @@ import * as React from "react";
 import Markdown from "react-markdown";
 import remarkGfm from "remark-gfm";
 
+import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
+
 const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
 type JoinPolicy = {
   terms_markdown?: string;
@@ -24,6 +26,7 @@ export function InvitePage({ code }: { code: string }) {
   );
   const [document, setDocument] = React.useState<PolicyDocument | null>(null);
   const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
   const [opening, setOpening] = React.useState(false);
 
   React.useEffect(() => {
@@ -64,7 +67,18 @@ export function InvitePage({ code }: { code: string }) {
   const disabled =
     policy === undefined ||
     opening ||
-    Boolean(policy?.age_attestation_required && !ageConfirmed);
+    Boolean(policy?.age_attestation_required && !ageConfirmed) ||
+    Boolean(
+      policy &&
+        (policy.terms_markdown || policy.privacy_markdown) &&
+        !agreementConfirmed,
+    );
+  const hasPolicyRequirements = Boolean(
+    policy &&
+      (policy.age_attestation_required ||
+        policy.terms_markdown ||
+        policy.privacy_markdown),
+  );
   const showDocument = (title: string, markdown: string) =>
     setDocument({ title, markdown });
 
@@ -88,23 +102,32 @@ export function InvitePage({ code }: { code: string }) {
           </h1>
           <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-          {policy?.age_attestation_required && (
-            <label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
-              <input
-                className="mt-0.5 h-4 w-4 accent-black"
-                type="checkbox"
-                checked={ageConfirmed}
-                onChange={(event) => setAgeConfirmed(event.target.checked)}
-              />
-              <span>I am 18 years of age or older.</span>
-            </label>
-          )}
-          <div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
+          <div
+            className={`grid w-full max-w-md overflow-hidden transition-[grid-template-rows,margin,opacity,transform] duration-[220ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
+              hasPolicyRequirements
+                ? "mt-9 -mb-4 grid-rows-[1fr] opacity-100 translate-y-0"
+                : "m-0 grid-rows-[0fr] opacity-0 -translate-y-1"
+            }`}
+          >
+            <div className="min-h-0 overflow-hidden">
+              {policy && hasPolicyRequirements ? (
+                <InviteJoinPolicyNotice
+                  ageConfirmed={ageConfirmed}
+                  agreementConfirmed={agreementConfirmed}
+                  onAgeConfirmedChange={setAgeConfirmed}
+                  onAgreementConfirmedChange={setAgreementConfirmed}
+                  onShowDocument={showDocument}
+                  policy={policy}
+                />
+              ) : null}
+            </div>
+          </div>
+
+          <div className="mt-9 w-full max-w-md">
             {policy === null ? (
               <Button
                 asChild
-                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
-                size="lg"
+                className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black"
               >
                 <a
                   href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
@@ -114,8 +137,7 @@ export function InvitePage({ code }: { code: string }) {
               </Button>
             ) : (
               <Button
-                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
-                size="lg"
+                className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
                 disabled={disabled}
                 onClick={openInvite}
               >
@@ -123,41 +145,6 @@ export function InvitePage({ code }: { code: string }) {
               </Button>
             )}
           </div>
-          {policy && (policy.terms_markdown || policy.privacy_markdown) && (
-            <p className="mt-4 max-w-md text-xs text-black/60">
-              By proceeding you agree to the Buzz{" "}
-              {policy.terms_markdown && (
-                <button
-                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
-                  type="button"
-                  onClick={() =>
-                    showDocument(
-                      "Terms of Service",
-                      policy.terms_markdown ?? "",
-                    )
-                  }
-                >
-                  Terms of Service
-                </button>
-              )}
-              {policy.terms_markdown && policy.privacy_markdown && " and "}
-              {policy.privacy_markdown && (
-                <button
-                  className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
-                  type="button"
-                  onClick={() =>
-                    showDocument(
-                      "Privacy Policy",
-                      policy.privacy_markdown ?? "",
-                    )
-                  }
-                >
-                  Privacy Policy
-                </button>
-              )}
-              .
-            </p>
-          )}
         </div>
         <p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
           Don&apos;t have the app?{" "}
