fix(join-policy): require legal consent on hosted invites (#1987)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-16 15:08:44 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent c540ec9678
commit 2e1577f76f
3 changed files with 205 additions and 53 deletions
+156 -1
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);
}
}
+10
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),
+39 -52
View File
@@ -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?{" "}