diff --git a/.env.example b/.env.example index 696d3a061..16b47d0a9 100644 --- a/.env.example +++ b/.env.example @@ -197,3 +197,10 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_ # These are accepted for backward compatibility but the canonical names above # are preferred: # BUZZ_ACP_PRIVATE_KEY → BUZZ_PRIVATE_KEY + +# Optional relay join policy. Markdown is served by the relay so every join +# surface can present the same documents. Each document and the independent age +# attestation are optional; configuring any one enables policy acceptance. +# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here." +# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here." +# BUZZ_AGE_ATTESTATION_REQUIRED=true diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 07c67a824..5e20c267e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2689,6 +2689,30 @@ impl Db { relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await } + /// Claims relay membership via an invite and atomically persists the + /// accepted policy version when a policy is configured. + pub async fn claim_relay_membership( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + ) -> Result { + relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version) + .await + } + + /// Returns whether a member has persisted acceptance evidence for a policy version. + pub async fn has_join_policy_acceptance( + &self, + community: CommunityId, + pubkey: &str, + policy_version: &str, + ) -> Result { + relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version) + .await + } + /// Removes a relay member from `community` atomically, refusing to delete the owner. pub async fn remove_relay_member( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1d8c961a2..eb4086024 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -549,7 +549,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 19); + assert_eq!(migrations.len(), 20); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -812,6 +812,18 @@ mod tests { .sql .as_str() .contains("purge_soft_deleted_buzz_mesh_status")); + + // Join policy acceptances landed concurrently with mesh status retention; + // keep both additive migrations in a single, unambiguous sequence. + assert_eq!(migrations[19].version, 20); + assert!(migrations[19] + .sql + .as_str() + .contains("CREATE TABLE join_policy_acceptances")); + assert!(!migrations[0] + .sql + .as_str() + .contains("join_policy_acceptances")); } #[test] diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index d354b057b..3bd114fda 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -114,6 +114,67 @@ pub async fn add_relay_member( Ok(result.rows_affected() > 0) } +/// Claims relay membership via an invite and atomically persists policy evidence. +/// +/// Returns `true` when membership was inserted, or `false` when the pubkey was +/// already a member. A configured `policy_version` is recorded in the same +/// transaction, so membership cannot be granted without its acceptance record. +pub async fn claim_relay_membership( + pool: &PgPool, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, +) -> Result { + let mut tx = pool.begin().await?; + let inserted = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, 'invite') \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(role) + .execute(&mut *tx) + .await? + .rows_affected() + > 0; + + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(inserted) +} + +/// Returns whether a member has persisted acceptance evidence for a policy version. +pub async fn has_join_policy_acceptance( + pool: &PgPool, + community: CommunityId, + pubkey: &str, + policy_version: &str, +) -> Result { + let row = sqlx::query( + "SELECT 1 FROM join_policy_acceptances \ + WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(policy_version) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + /// The result of a relay member removal attempt. #[derive(Debug, PartialEq)] pub enum RemoveResult { @@ -544,6 +605,38 @@ mod tests { (community, owner) } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn invite_claim_persists_policy_version_and_legacy_claim_does_not() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let policy_member = test_pubkey(); + let legacy_member = test_pubkey(); + let version = "a".repeat(64); + + assert!( + claim_relay_membership(&pool, community, &policy_member, "member", Some(&version),) + .await + .expect("claim membership with policy") + ); + assert!( + has_join_policy_acceptance(&pool, community, &policy_member, &version) + .await + .expect("policy acceptance lookup") + ); + + assert!( + claim_relay_membership(&pool, community, &legacy_member, "member", None) + .await + .expect("legacy claim membership") + ); + assert!( + !has_join_policy_acceptance(&pool, community, &legacy_member, &version) + .await + .expect("legacy acceptance lookup") + ); + } + /// NIP-43 admission confinement: a pubkey admitted to community A is *not* /// admitted to community B. This is the exact mutation #1285 targets — a /// `WHERE pubkey = $1` membership check (no community predicate) would let an diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 3b09e2a38..7d004c08c 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -54,6 +54,66 @@ pub struct MintInviteRequest { pub struct ClaimInviteRequest { /// The invite code to redeem. pub code: String, + /// Relay-issued proof of accepting the configured terms, when required. + #[serde(default)] + pub policy_receipt: Option, +} + +/// Body for `POST /api/invites/accept-policy`. +#[derive(Debug, Deserialize)] +pub struct AcceptPolicyRequest { + /// Invite code the acceptance receipt will be bound to. + pub code: String, + /// Policy revision displayed by the client. + pub policy_version: String, + /// Minimum-age assertion, required only when configured by the operator. + #[serde(default)] + pub age_confirmed: bool, +} + +/// Public join policy shared by every client-side join surface. +pub async fn join_policy(State(state): State>) -> Json { + match &state.config.join_policy { + Some(policy) => Json(serde_json::json!({ + "policy": { + "terms_markdown": policy.terms_markdown, + "privacy_markdown": policy.privacy_markdown, + "age_attestation_required": policy.age_attestation_required, + "version": policy.version + } + })), + None => Json(serde_json::json!({})), + } +} + +/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt. +pub async fn accept_policy( + State(state): State>, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let Some(policy) = &state.config.join_policy else { + return Err(api_error( + StatusCode::NOT_FOUND, + "join_policy_not_configured", + )); + }; + let request: AcceptPolicyRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid policy acceptance JSON: {e}"), + ) + })?; + if request.policy_version != policy.version + || (policy.age_attestation_required && !request.age_confirmed) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "join_policy_not_accepted", + )); + } + let key = invite_token::derive_invite_key(&state.relay_keypair); + let receipt = invite_token::mint_policy_acceptance(&key, &request.code, &policy.version); + Ok(Json(serde_json::json!({ "receipt": receipt }))) } /// Shared prelude: bind the tenant from the Host header and verify the NIP-98 @@ -186,9 +246,27 @@ pub async fn claim_invite( )?; let claimer_hex = pubkey.to_hex(); + if let Some(policy) = &state.config.join_policy { + let receipt = request + .policy_receipt + .as_deref() + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; + invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; + } + let was_inserted = state .db - .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite")) + .claim_relay_membership( + tenant.community(), + &claimer_hex, + &payload.r, + state + .config + .join_policy + .as_ref() + .map(|policy| policy.version.as_str()), + ) .await .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; @@ -361,14 +439,17 @@ mod tests { /// a fresh community on `host`; returns `None` when Postgres is unavailable. async fn invite_test_state(host: &str) -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + config.database_url = database_url.clone(); config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_url = format!("wss://{host}"); // The claim route must work on relays where membership is enforced — // that is the entire point of an invite. config.require_relay_membership = true; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&database_url).await.ok()?; let db = buzz_db::Db::from_pool(pool.clone()); db.ensure_configured_community(host).await.ok()?; @@ -505,6 +586,215 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn join_policy_gate_end_to_end() { + let host = format!("invites-policy-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + // Force the join policy on regardless of env. + 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".to_string()), + privacy_markdown: Some("# Privacy".to_string()), + age_attestation_required: true, + version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }); + state_inner.config = Arc::new(config); + let state = Arc::new(state_inner); + + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + // Mint an invite. + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + "{}".to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let code = json + .get("code") + .and_then(Value::as_str) + .expect("code") + .to_string(); + + // 1. Claim WITHOUT receipt -> 403 (checkbox bypass). + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code }).to_string(), + ) + .await; + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "no-receipt claim must fail" + ); + + // 2. Forged receipt (wrong key) -> 403. + let forged = crate::invite_token::mint_policy_acceptance( + &[9u8; 32], + &code, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code, "policy_receipt": forged }).to_string(), + ) + .await; + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "forged receipt must fail" + ); + + // 3. Receipt bound to a DIFFERENT invite code -> 403. + let key = crate::invite_token::derive_invite_key(&state.relay_keypair); + let other = crate::invite_token::mint_policy_acceptance( + &key, + "some-other-code", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code, "policy_receipt": other }).to_string(), + ) + .await; + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "cross-invite receipt must fail" + ); + + // 4. Receipt for a STALE policy version -> 403. + let stale = crate::invite_token::mint_policy_acceptance( + &key, + &code, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code, "policy_receipt": stale }).to_string(), + ) + .await; + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "stale-version receipt must fail" + ); + + // 5. accept-policy without age confirmation -> 400. + let response = post_json( + state.clone(), + &host, + "/api/invites/accept-policy", + &joiner, + serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": false }) + .to_string(), + ) + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "age not confirmed must be rejected when required" + ); + + // 5b. accept-policy with stale version -> 400. + let response = post_json( + state.clone(), + &host, + "/api/invites/accept-policy", + &joiner, + serde_json::json!({ "code": code, "policy_version": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "age_confirmed": true }) + .to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // 6. Legit flow: accept-policy -> receipt -> claim OK. + let response = post_json( + state.clone(), + &host, + "/api/invites/accept-policy", + &joiner, + serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": true }) + .to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let receipt = read_json(response) + .await + .get("receipt") + .and_then(Value::as_str) + .expect("receipt") + .to_string(); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code, "policy_receipt": receipt }).to_string(), + ) + .await; + assert_eq!( + response.status(), + StatusCode::OK, + "legit receipt claim must succeed" + ); + let json = read_json(response).await; + assert_eq!(json.get("status").and_then(Value::as_str), Some("joined")); + + let member = state + .db + .get_relay_member(community.id, &joiner.public_key().to_hex()) + .await + .expect("member lookup") + .expect("joiner is now a member"); + assert_eq!(member.role, "member"); + assert!( + state + .db + .has_join_policy_acceptance( + community.id, + &joiner.public_key().to_hex(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .await + .expect("policy acceptance lookup"), + "accepted policy version must be persisted", + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn non_admin_cannot_mint() { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index d72654dbf..b9a876bc0 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use std::time::Duration; +use sha2::{Digest, Sha256}; use thiserror::Error; use tracing::warn; @@ -23,6 +24,19 @@ pub enum ConfigError { InvalidValue(String), } +/// Relay-hosted policy content presented on join surfaces. +#[derive(Debug, Clone)] +pub struct JoinPolicyConfig { + /// Operator-provided Terms of Service document in Markdown. + pub terms_markdown: Option, + /// Operator-provided Privacy Policy document in Markdown. + pub privacy_markdown: Option, + /// Whether join surfaces must collect an 18+ attestation. + pub age_attestation_required: bool, + /// Content-derived identifier binding receipts to the exact policy revision. + pub version: String, +} + /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -197,6 +211,10 @@ pub struct Config { /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, + /// Optional relay-hosted policy shown on join surfaces. Disabled when no + /// documents or age attestation are configured. + pub join_policy: Option, + /// Optional path to the web UI `dist/` directory. /// When set, the relay serves the invite landing page and its static assets. /// When unset, no static file serving happens (relay behaves as before). @@ -256,6 +274,22 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { Ok(url) } +fn parse_optional_bool(name: &str) -> Result { + match std::env::var(name) { + Err(std::env::VarError::NotPresent) => Ok(false), + Err(error) => Err(ConfigError::InvalidValue(format!( + "{name} must be valid UTF-8: {error}" + ))), + Ok(value) => match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "on" => Ok(true), + "false" | "0" | "off" | "" => Ok(false), + _ => Err(ConfigError::InvalidValue(format!( + "{name} must be true or false" + ))), + }, + } +} + fn ensure_git_repo_path( raw: impl Into, ) -> Result { @@ -602,6 +636,44 @@ impl Config { }; let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); + const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024; + let read_policy_markdown = |name: &str| -> Result, ConfigError> { + let value = std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if value + .as_ref() + .is_some_and(|value| value.len() > MAX_POLICY_MARKDOWN_BYTES) + { + return Err(ConfigError::InvalidValue(format!( + "{name} must contain at most {MAX_POLICY_MARKDOWN_BYTES} bytes" + ))); + } + Ok(value) + }; + let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?; + let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?; + let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?; + let join_policy = if terms_markdown.is_none() + && privacy_markdown.is_none() + && !age_attestation_required + { + None + } else { + let mut hasher = Sha256::new(); + hasher.update(terms_markdown.as_deref().unwrap_or_default().as_bytes()); + hasher.update([0]); + hasher.update(privacy_markdown.as_deref().unwrap_or_default().as_bytes()); + hasher.update([0, u8::from(age_attestation_required)]); + Some(JoinPolicyConfig { + terms_markdown, + privacy_markdown, + age_attestation_required, + version: hex::encode(hasher.finalize()), + }) + }; + // Web UI static file serving let web_dir = std::env::var("BUZZ_WEB_DIR") .ok() @@ -673,6 +745,7 @@ impl Config { push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, + join_policy, web_dir, serve_git_web_gui, }) @@ -729,6 +802,24 @@ mod tests { ); } + #[test] + fn join_policy_age_attestation_rejects_invalid_boolean() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED"); + std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes"); + let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED"); + if let Some(value) = previous { + std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value); + } else { + std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED"); + } + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED") + )); + } + #[test] fn relay_operator_pubkeys_parse_dedupe_and_normalize() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs index dda3c495e..436040c32 100644 --- a/crates/buzz-relay/src/invite_token.rs +++ b/crates/buzz-relay/src/invite_token.rs @@ -327,3 +327,79 @@ mod tests { assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole)); } } + +/// Short-lived proof that the browser accepted the configured join policy. +#[derive(Debug, Serialize, Deserialize)] +pub struct PolicyAcceptancePayload { + /// SHA-256 of the invite code this acceptance is bound to. + pub c: String, + /// Configured policy version. + pub v: String, + /// Receipt expiry (unix seconds). + pub e: u64, +} + +/// Mint a relay-authenticated, invite-bound policy acceptance receipt. +pub fn mint_policy_acceptance(key: &[u8; 32], code: &str, version: &str) -> String { + let payload = PolicyAcceptancePayload { + c: hex::encode(Sha256::digest(code.as_bytes())), + v: version.to_string(), + e: now_unix() + 10 * 60, + }; + let bytes = serde_json::to_vec(&payload).expect("policy acceptance serializes"); + format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&bytes), + URL_SAFE_NO_PAD.encode(sign_payload(key, &bytes)) + ) +} + +/// Verify a policy receipt and bind it to the submitted invite and current policy. +pub fn verify_policy_acceptance( + key: &[u8; 32], + receipt: &str, + code: &str, + version: &str, +) -> Result { + if receipt.len() > 2048 { + return Err(InviteError::Malformed); + } + let (payload, signature) = receipt.split_once('.').ok_or(InviteError::Malformed)?; + let bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| InviteError::Malformed)?; + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| InviteError::Malformed)?; + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(&bytes); + mac.verify_slice(&signature) + .map_err(|_| InviteError::BadSignature)?; + let payload: PolicyAcceptancePayload = + serde_json::from_slice(&bytes).map_err(|_| InviteError::Malformed)?; + if payload.e < now_unix() { + return Err(InviteError::Expired); + } + let expected_code = hex::encode(Sha256::digest(code.as_bytes())); + if payload.c != expected_code || payload.v != version { + return Err(InviteError::Malformed); + } + Ok(payload) +} + +#[cfg(test)] +mod policy_acceptance_tests { + use super::*; + + #[test] + fn policy_receipt_is_bound_to_invite_and_version() { + let key = [7_u8; 32]; + let receipt = mint_policy_acceptance(&key, "invite-a", "v1"); + let payload = + verify_policy_acceptance(&key, &receipt, "invite-a", "v1").expect("valid receipt"); + assert_eq!(payload.v, "v1"); + assert!(verify_policy_acceptance(&key, &receipt, "invite-b", "v1").is_err()); + assert!(verify_policy_acceptance(&key, &receipt, "invite-a", "v2").is_err()); + assert!(verify_policy_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err()); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index bb8de5556..0036a47af 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -83,6 +83,11 @@ pub fn build_router(state: Arc) -> 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)) + .route( + "/api/invites/accept-policy", + post(api::invites::accept_policy), + ) .route("/api/invites/claim", post(api::invites::claim_invite)) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index f90d2f390..2a542357d 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -59,6 +59,7 @@ fn parse_message_deep_link(url: &Url) -> Option { fn parse_join_deep_link(url: &Url) -> Option { let mut relay: Option = None; let mut code: Option = None; + let mut policy_receipt: Option = None; for (k, v) in url.query_pairs() { let v = v.into_owned(); if v.is_empty() { @@ -67,6 +68,7 @@ fn parse_join_deep_link(url: &Url) -> Option { match k.as_ref() { "relay" => relay = Some(v), "code" => code = Some(v), + "policy_receipt" => policy_receipt = Some(v), _ => {} } } @@ -78,6 +80,7 @@ fn parse_join_deep_link(url: &Url) -> Option { Some(serde_json::json!({ "relayUrl": relay_url, "code": code, + "policyReceipt": policy_receipt, })) } @@ -337,6 +340,17 @@ mod tests { let payload = parse_join_deep_link(&url).expect("required params present"); assert_eq!(payload["relayUrl"], "wss://relay.example"); assert_eq!(payload["code"], "abc.def"); + assert!(payload["policyReceipt"].is_null()); + } + + #[test] + fn parse_join_deep_link_extracts_policy_receipt() { + let url = Url::parse( + "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", + ) + .unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["policyReceipt"], "receipt.value"); } #[test] diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index b3ac2116e..9c2b8f091 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -10,7 +10,12 @@ import { inviteErrorMessage, isInviteExpiredError, } from "@/shared/api/inviteHelpers"; -import { claimInvite } from "@/shared/api/invites"; +import { + acceptJoinPolicy, + claimInvite, + getJoinPolicy, + type JoinPolicy, +} from "@/shared/api/invites"; import { validateReposDir } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { @@ -21,6 +26,7 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; +import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice"; type AddCommunityDialogProps = { open: boolean; @@ -38,6 +44,8 @@ export function AddCommunityDialog({ const [token, setToken] = React.useState(""); const [inviteCode, setInviteCode] = React.useState(""); const [inviteError, setInviteError] = React.useState(null); + const [joinPolicy, setJoinPolicy] = React.useState(null); + const [ageConfirmed, setAgeConfirmed] = React.useState(false); const [reposDir, setReposDir] = React.useState(""); const [reposDirError, setReposDirError] = React.useState(null); @@ -48,6 +56,8 @@ export function AddCommunityDialog({ setToken(""); setInviteCode(""); setInviteError(null); + setJoinPolicy(null); + setAgeConfirmed(false); setReposDir(""); setReposDirError(null); }, [onOpenChange]); @@ -71,21 +81,45 @@ export function AddCommunityDialog({ return; } - // If the relay handed out an invite code, claim it before saving the - // community — a closed relay would otherwise reject the connection. const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim()); - if (inviteCode.trim()) { - try { - await claimInvite(normalizedRelayUrl, inviteCode.trim()); - } catch (error) { - const message = inviteErrorMessage(error); - setInviteError( - isInviteExpiredError(error) - ? "This invite code has expired — ask for a new one." - : `Invite rejected: ${message}`, - ); + try { + const policy = await getJoinPolicy(normalizedRelayUrl); + if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) { + setJoinPolicy(policy); + setAgeConfirmed(false); + setInviteError("Review this relay's join policy below."); return; } + if (policy?.ageAttestationRequired && !ageConfirmed) { + setInviteError("Confirm that you are at least 18 years old."); + return; + } + + // If the relay handed out an invite code, claim it before saving the + // community — a closed relay would otherwise reject the connection. + if (inviteCode.trim()) { + const policyReceipt = policy + ? await acceptJoinPolicy( + normalizedRelayUrl, + inviteCode.trim(), + policy.version, + ageConfirmed, + ) + : undefined; + await claimInvite( + normalizedRelayUrl, + inviteCode.trim(), + policyReceipt, + ); + } + } catch (error) { + const message = inviteErrorMessage(error); + setInviteError( + isInviteExpiredError(error) + ? "This invite code has expired — ask for a new one." + : `Community rejected: ${message}`, + ); + return; } const community: Community = { @@ -100,7 +134,17 @@ export function AddCommunityDialog({ onSubmit(community); handleClose(); }, - [name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose], + [ + name, + relayUrl, + token, + inviteCode, + reposDir, + joinPolicy, + ageConfirmed, + onSubmit, + handleClose, + ], ); return ( @@ -127,7 +171,12 @@ export function AddCommunityDialog({ setRelayUrl(e.target.value)} + onChange={(e) => { + setRelayUrl(e.target.value); + setInviteError(null); + setJoinPolicy(null); + setAgeConfirmed(false); + }} placeholder="wss://relay.example.com" type="text" value={relayUrl} @@ -184,6 +233,8 @@ export function AddCommunityDialog({ onChange={(e) => { setInviteCode(e.target.value); setInviteError(null); + setJoinPolicy(null); + setAgeConfirmed(false); }} placeholder="Paste an invite code for a members-only relay" type="text" @@ -192,6 +243,16 @@ export function AddCommunityDialog({ {inviteError ? (

{inviteError}

) : null} + {joinPolicy ? ( + { + setAgeConfirmed(confirmed); + setInviteError(null); + }} + policy={joinPolicy} + /> + ) : null}
diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx index e0d47b2f7..7365ebf2b 100644 --- a/desktop/src/features/communities/ui/CommunityEditForm.tsx +++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx @@ -1,7 +1,10 @@ import * as React from "react"; +import { inviteErrorMessage } from "@/shared/api/inviteHelpers"; +import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; +import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice"; import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe"; export type CommunityEditFormProps = { @@ -9,6 +12,7 @@ export type CommunityEditFormProps = { initialName: string; initialRelayUrl: string; isSubmitting?: boolean; + joinPolicyRequired?: boolean; onCancel: () => void; onSubmit: (name: string, relayUrl: string) => void; submitLabel: string; @@ -19,6 +23,7 @@ export function CommunityEditForm({ initialName, initialRelayUrl, isSubmitting = false, + joinPolicyRequired = false, onCancel, onSubmit, submitLabel, @@ -29,6 +34,11 @@ export function CommunityEditForm({ const [probeWarning, setProbeWarning] = React.useState(null); const [isProbing, setIsProbing] = React.useState(false); const [useAnywayOverride, setUseAnywayOverride] = React.useState(false); + const [joinPolicy, setJoinPolicy] = React.useState(null); + const [policyRelayUrl, setPolicyRelayUrl] = React.useState( + null, + ); + const [ageConfirmed, setAgeConfirmed] = React.useState(false); const cancelRef = React.useRef<(() => void) | null>(null); @@ -52,6 +62,33 @@ export function CommunityEditForm({ return; } + if (joinPolicyRequired) { + try { + const policy = await getJoinPolicy(normalizedUrl); + if (!policy) { + onSubmit(trimmedName, normalizedUrl); + return; + } + if ( + !joinPolicy || + joinPolicy.version !== policy.version || + policyRelayUrl !== normalizedUrl + ) { + setJoinPolicy(policy); + setPolicyRelayUrl(normalizedUrl); + setAgeConfirmed(false); + return; + } + if (policy.ageAttestationRequired && !ageConfirmed) { + setError("Confirm that you are at least 18 years old."); + return; + } + } catch (policyError) { + setError(inviteErrorMessage(policyError)); + return; + } + } + if (useAnywayOverride) { onSubmit(trimmedName, normalizedUrl); return; @@ -79,7 +116,16 @@ export function CommunityEditForm({ onSubmit(trimmedName, normalizedUrl); }, - [name, onSubmit, relayUrl, useAnywayOverride], + [ + ageConfirmed, + joinPolicy, + joinPolicyRequired, + name, + onSubmit, + policyRelayUrl, + relayUrl, + useAnywayOverride, + ], ); const handleUseAnyway = React.useCallback(() => { @@ -139,6 +185,9 @@ export function CommunityEditForm({ setError(null); setProbeWarning(null); setUseAnywayOverride(false); + setJoinPolicy(null); + setPolicyRelayUrl(null); + setAgeConfirmed(false); }} placeholder="wss://relay.example.com" type="text" @@ -146,10 +195,26 @@ export function CommunityEditForm({ /> + {joinPolicy ? ( + { + setAgeConfirmed(confirmed); + setError(null); + }} + policy={joinPolicy} + /> + ) : null} +
+ {joinPolicy ? ( +
+ +
+ ) : null} + (null); const [isRedeeming, setIsRedeeming] = React.useState(false); const [inviteError, setInviteError] = React.useState(null); + const [defaultJoinPolicy, setDefaultJoinPolicy] = React.useState< + JoinPolicy | null | undefined + >(undefined); + const [defaultAgeConfirmed, setDefaultAgeConfirmed] = React.useState(false); const systemColorScheme = useSystemColorScheme(); + React.useEffect(() => { + let cancelled = false; + setDefaultAgeConfirmed(false); + if (isLocalDevRelayUrl(defaultRelayUrl)) { + setDefaultJoinPolicy(null); + return; + } + setDefaultJoinPolicy(undefined); + void getJoinPolicy(defaultRelayUrl) + .then((policy) => { + if (!cancelled) { + setDefaultJoinPolicy(policy); + } + }) + .catch((policyError) => { + if (!cancelled) { + setError(inviteErrorMessage(policyError)); + } + }); + return () => { + cancelled = true; + }; + }, [defaultRelayUrl]); + const handleConnect = React.useCallback( async (relayUrl: string, communityName?: string, pubkey?: string) => { const trimmedUrl = relayUrl.trim(); @@ -159,11 +212,11 @@ export function WelcomeSetup({ ); const handleWelcomeInviteRedeem = React.useCallback( - async (relayWsUrl: string, code: string) => { + async (relayWsUrl: string, code: string, policyReceipt?: string) => { setIsRedeeming(true); setInviteError(null); try { - await claimInvite(relayWsUrl, code); + await claimInvite(relayWsUrl, code, policyReceipt); await handleConnect(relayWsUrl); } catch (err) { setInviteError(inviteErrorMessage(err)); @@ -249,10 +302,24 @@ export function WelcomeSetup({

+ {defaultJoinPolicy ? ( + + ) : null} {isLocalDevRelayUrl(defaultRelayUrl) ? null : (
) : ( diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index 5e8f36a42..e6541eafc 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -1,9 +1,18 @@ import * as React from "react"; -import { parseInviteInput } from "@/shared/api/inviteHelpers"; +import { + inviteErrorMessage, + parseInviteInput, +} from "@/shared/api/inviteHelpers"; +import { + acceptJoinPolicy, + getJoinPolicy, + type JoinPolicy, +} from "@/shared/api/invites"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; +import { JoinPolicyNotice } from "./JoinPolicyNotice"; type InviteRedeemFormProps = { /** @@ -16,7 +25,7 @@ type InviteRedeemFormProps = { error: string | null; isRedeeming: boolean; onCancel: () => void; - onRedeem: (relayWsUrl: string, code: string) => void; + onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void; }; export function InviteRedeemForm({ @@ -30,6 +39,14 @@ export function InviteRedeemForm({ const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState( defaultRelayUrl ?? "", ); + const [joinPolicy, setJoinPolicy] = React.useState(null); + const [policyInvite, setPolicyInvite] = React.useState<{ + relayWsUrl: string; + code: string; + } | null>(null); + const [ageConfirmed, setAgeConfirmed] = React.useState(false); + const [policyError, setPolicyError] = React.useState(null); + const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false); const parsed = React.useMemo( () => parseInviteInput(inviteInput), @@ -44,17 +61,61 @@ export function InviteRedeemForm({ (isBareCode && bareCodeRelayUrl.trim().length > 0)); const handleSubmit = React.useCallback( - (event: React.FormEvent) => { + async (event: React.FormEvent) => { event.preventDefault(); if (!parsed) return; - if ("relayWsUrl" in parsed) { - onRedeem(parsed.relayWsUrl, parsed.code); - } else if (bareCodeRelayUrl.trim()) { - onRedeem(bareCodeRelayUrl.trim(), parsed.code); + const relayWsUrl = + "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim(); + if (!relayWsUrl) return; + + setPolicyError(null); + setIsLoadingPolicy(true); + try { + const policy = await getJoinPolicy(relayWsUrl); + if (!policy) { + onRedeem(relayWsUrl, parsed.code); + return; + } + + if ( + !joinPolicy || + joinPolicy.version !== policy.version || + policyInvite?.relayWsUrl !== relayWsUrl || + policyInvite.code !== parsed.code + ) { + setJoinPolicy(policy); + setPolicyInvite({ relayWsUrl, code: parsed.code }); + setAgeConfirmed(false); + return; + } + + if (policy.ageAttestationRequired && !ageConfirmed) { + setPolicyError("Confirm that you are at least 18 years old."); + return; + } + + const receipt = await acceptJoinPolicy( + relayWsUrl, + parsed.code, + policy.version, + ageConfirmed, + ); + onRedeem(relayWsUrl, parsed.code, receipt); + } catch (policyFetchError) { + setPolicyError(inviteErrorMessage(policyFetchError)); + } finally { + setIsLoadingPolicy(false); } }, - [bareCodeRelayUrl, onRedeem, parsed], + [ + ageConfirmed, + bareCodeRelayUrl, + joinPolicy, + onRedeem, + parsed, + policyInvite, + ], ); return ( @@ -74,7 +135,13 @@ export function InviteRedeemForm({ data-testid="invite-redeem-input" disabled={isRedeeming} id="invite-input" - onChange={(event) => setInviteInput(event.target.value)} + onChange={(event) => { + setInviteInput(event.target.value); + setJoinPolicy(null); + setPolicyInvite(null); + setAgeConfirmed(false); + setPolicyError(null); + }} placeholder="https://relay.example.com/invite/abc123 or paste a code" spellCheck={false} type="text" @@ -94,7 +161,13 @@ export function InviteRedeemForm({ className="h-10 bg-background" disabled={isRedeeming} id="invite-relay-url" - onChange={(event) => setBareCodeRelayUrl(event.target.value)} + onChange={(event) => { + setBareCodeRelayUrl(event.target.value); + setJoinPolicy(null); + setPolicyInvite(null); + setAgeConfirmed(false); + setPolicyError(null); + }} placeholder="wss://relay.example.com" type="text" value={bareCodeRelayUrl} @@ -102,6 +175,21 @@ export function InviteRedeemForm({ ) : null} + {joinPolicy ? ( + { + setAgeConfirmed(confirmed); + setPolicyError(null); + }} + policy={joinPolicy} + /> + ) : null} + + {policyError ? ( +

{policyError}

+ ) : null} + {error ? (

{error}

) : null} @@ -109,11 +197,21 @@ export function InviteRedeemForm({ + ) : null} + {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null} + {policy.privacyMarkdown ? ( + + ) : null} + . +

+ ) : null} + + { + if (!open) setOpenDocument(null); + }} + open={openDocument !== null} + > + + + + {openDocument === "terms" ? "Terms of Service" : "Privacy Policy"} + + + {markdown ? : null} + + + + ); +} diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index bca564fbd..51745f22a 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -89,11 +89,11 @@ export function MembershipDenied({ }, [onImportKey, previewNpub, trimmedNsec]); const handleInviteRedeem = React.useCallback( - async (relayWsUrl: string, code: string) => { + async (relayWsUrl: string, code: string, policyReceipt?: string) => { setIsRedeeming(true); setInviteError(null); try { - await claimInvite(relayWsUrl, code); + await claimInvite(relayWsUrl, code, policyReceipt); onInviteRedeemed(relayWsUrl); } catch (error) { setInviteError(inviteErrorMessage(error)); @@ -170,8 +170,8 @@ export function MembershipDenied({ setInviteError(null); setIsInviteFormOpen(false); }} - onRedeem={(relayWsUrl, code) => { - void handleInviteRedeem(relayWsUrl, code); + onRedeem={(relayWsUrl, code, policyReceipt) => { + void handleInviteRedeem(relayWsUrl, code, policyReceipt); }} /> ) : isImportFormOpen ? ( diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs new file mode 100644 index 000000000..eb4ca3dad --- /dev/null +++ b/desktop/src/shared/api/invites.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getJoinPolicy } from "./invites.ts"; + +function withFetch(response, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + assert.equal(url, "https://relay.example/api/join-policy"); + return response; + }; + return Promise.resolve(run()).finally(() => { + globalThis.fetch = originalFetch; + }); +} + +test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () => { + await withFetch( + new Response( + JSON.stringify({ + policy: { + terms_markdown: "# Terms", + privacy_markdown: "# Privacy", + age_attestation_required: true, + version: "policy-v1", + }, + }), + { status: 200 }, + ), + async () => { + assert.deepEqual(await getJoinPolicy("wss://relay.example"), { + termsMarkdown: "# Terms", + privacyMarkdown: "# Privacy", + ageAttestationRequired: true, + version: "policy-v1", + }); + }, + ); +}); + +test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => { + await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () => + assert.equal(await getJoinPolicy("wss://relay.example"), null), + ); + await withFetch(new Response(null, { status: 404 }), async () => + assert.equal(await getJoinPolicy("wss://relay.example"), null), + ); +}); + +test("getJoinPolicy fails closed on a policy endpoint error", async () => { + await withFetch(new Response(null, { status: 503 }), async () => + assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/), + ); +}); diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts index f0161d620..d9e6845cc 100644 --- a/desktop/src/shared/api/invites.ts +++ b/desktop/src/shared/api/invites.ts @@ -18,6 +18,13 @@ export type MintedInvite = { url: string; }; +export type JoinPolicy = { + termsMarkdown?: string; + privacyMarkdown?: string; + ageAttestationRequired: boolean; + version: string; +}; + export type ClaimResult = { status: "joined" | "already_member"; communityId: string; @@ -84,6 +91,57 @@ async function invitePost( return json as T; } +/** Fetch relay-hosted policy content for any join surface. */ +export async function getJoinPolicy( + relayWsUrl: string, +): Promise { + const base = relayHttpFromWs(relayWsUrl); + const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`); + // Relays predating join-policy support have no configured policy. + if (response.status === 404) return null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = (await response.json()) as { + policy?: { + terms_markdown?: string; + privacy_markdown?: string; + age_attestation_required: boolean; + version: string; + }; + }; + return raw.policy + ? { + termsMarkdown: raw.policy.terms_markdown, + privacyMarkdown: raw.policy.privacy_markdown, + ageAttestationRequired: raw.policy.age_attestation_required, + version: raw.policy.version, + } + : null; +} + +/** Accept the current join policy for an invite and receive a bound receipt. */ +export async function acceptJoinPolicy( + relayWsUrl: string, + code: string, + policyVersion: string, + ageConfirmed: boolean, +): Promise { + const base = relayHttpFromWs(relayWsUrl); + const response = await fetch( + `${base.replace(/\/+$/, "")}/api/invites/accept-policy`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + policy_version: policyVersion, + age_confirmed: ageConfirmed, + }), + }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return ((await response.json()) as { receipt: string }).receipt; +} + /** Mint an invite code on the active community's relay (owner/admin only). */ export async function mintInvite(ttlSecs?: number): Promise { const base = await getRelayHttpUrl(); @@ -103,9 +161,10 @@ export async function mintInvite(ttlSecs?: number): Promise { export async function claimInvite( relayWsUrl: string, code: string, + policyReceipt?: string, ): Promise { const base = relayHttpFromWs(relayWsUrl); - const body = JSON.stringify({ code }); + const body = JSON.stringify({ code, policy_receipt: policyReceipt }); const raw = await invitePost<{ status: "joined" | "already_member"; community_id: string; diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 193633857..96a702f70 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -49,6 +49,7 @@ export type NostrBindDeepLinkPayload = { export type JoinDeepLinkPayload = { relayUrl: string; code: string; + policyReceipt: string | null; }; /** @@ -90,8 +91,8 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise { }); const joinPromise = listen("deep-link-join", (event) => { - const { relayUrl, code } = event.payload; - void claimInvite(relayUrl, code) + const { relayUrl, code, policyReceipt } = event.payload; + void claimInvite(relayUrl, code, policyReceipt ?? undefined) .then((result) => { const name = addAndSwitch(relayUrl); toast.success( diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 6ee97dba4..481cba11d 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -568,6 +568,16 @@ test("first-run default community handoff gives immediate stepper feedback", asy skipCommunitySeed: true, }, ); + await page.route( + "https://default.example.com/api/join-policy", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: "{}", + }); + }, + ); await page.goto("/"); await expect(page.getByText("Welcome to Buzz")).toBeVisible(); @@ -609,6 +619,16 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => { skipOnboardingSeed: true, skipCommunitySeed: true, }); + await page.route( + "https://default.example.com/api/join-policy", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: "{}", + }); + }, + ); await page.goto("/"); await page.getByTestId("welcome-continue-nostr").click(); @@ -1824,7 +1844,41 @@ test("denied on relay A then paste relay B invite URL switches community to B", // Intercept the claimInvite POST to relay B so it succeeds. const relayBUrl = "wss://relay-b.example.com"; const relayBHttpUrl = "https://relay-b.example.com"; + const policyReceipt = "relay-signed-policy-receipt"; + await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + policy: { + terms_markdown: "# Terms", + privacy_markdown: "# Privacy", + age_attestation_required: true, + version: "policy-v1", + }, + }), + }); + }); + await page.route( + `${relayBHttpUrl}/api/invites/accept-policy`, + async (route) => { + expect(route.request().postDataJSON()).toEqual({ + code: "test-invite-code", + policy_version: "policy-v1", + age_confirmed: true, + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ receipt: policyReceipt }), + }); + }, + ); await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => { + expect(route.request().postDataJSON()).toMatchObject({ + code: "test-invite-code", + policy_receipt: policyReceipt, + }); await route.fulfill({ status: 200, contentType: "application/json", @@ -1843,6 +1897,9 @@ test("denied on relay A then paste relay B invite URL switches community to B", .getByTestId("invite-redeem-input") .fill(`${relayBHttpUrl}/invite/test-invite-code`); await page.getByTestId("invite-redeem-submit").click(); + await expect(page.getByText("I am 18 years of age or older.")).toBeVisible(); + await page.getByLabel("I am 18 years of age or older.").check(); + await page.getByTestId("invite-redeem-submit").click(); // After successful claim, the community should switch to relay B's URL. await expect diff --git a/migrations/0020_join_policy_acceptances.sql b/migrations/0020_join_policy_acceptances.sql new file mode 100644 index 000000000..061d4670f --- /dev/null +++ b/migrations/0020_join_policy_acceptances.sql @@ -0,0 +1,12 @@ +-- Durable evidence of the policy version accepted when an invite claim grants +-- relay membership. Rows are scoped to the same community and member identity +-- as relay_members and are deleted with that membership. +CREATE TABLE join_policy_acceptances ( + community_id UUID NOT NULL, + pubkey TEXT NOT NULL, + policy_version TEXT NOT NULL CHECK (length(policy_version) = 64), + accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey, policy_version), + FOREIGN KEY (community_id, pubkey) + REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE +); diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 3a460a1ba..72a1d11c7 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -1,21 +1,72 @@ import buzzAppIcon from "@/assets/app-icon@3x.png"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { Button } from "@/shared/ui/button"; +import * as React from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest"; +type JoinPolicy = { + terms_markdown?: string; + privacy_markdown?: string; + age_attestation_required: boolean; + version: string; +}; -/** - * Landing page for a community invite link (`/invite/`). - * - * The code is not validated here — validation happens in the desktop app when - * the invite is claimed against `POST /api/invites/claim`, signed by the - * joining key. This page only hands the code off via the `buzz://join` deep - * link (or tells the visitor where to get the app first). - */ +type PolicyDocument = { title: string; markdown: string }; + +/** Landing page for a community invite link (`/invite/`). */ export function InvitePage({ code }: { code: string }) { const relay = relayWsUrl(); const host = relay.replace(/^wss?:\/\//, ""); - const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`; + const [policy, setPolicy] = React.useState( + undefined, + ); + const [document, setDocument] = React.useState(null); + const [ageConfirmed, setAgeConfirmed] = React.useState(false); + const [opening, setOpening] = React.useState(false); + + React.useEffect(() => { + fetch("/api/join-policy") + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const config = (await response.json()) as { policy?: JoinPolicy }; + setPolicy(config.policy ?? null); + }) + .catch(() => setPolicy(undefined)); + }, []); + + const openInvite = async () => { + setOpening(true); + try { + let receipt: string | undefined; + if (policy) { + const response = await fetch("/api/invites/accept-policy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + policy_version: policy.version, + age_confirmed: ageConfirmed, + }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + receipt = ((await response.json()) as { receipt: string }).receipt; + } + const query = new URLSearchParams({ relay, code }); + if (receipt) query.set("policy_receipt", receipt); + window.location.href = `buzz://join?${query.toString()}`; + } finally { + setOpening(false); + } + }; + + const disabled = + policy === undefined || + opening || + Boolean(policy?.age_attestation_required && !ageConfirmed); + const showDocument = (title: string, markdown: string) => + setDocument({ title, markdown }); return (
-
-
- Buzz -
-

- You're invited to join -

-

{host}

- -
- -
+ Buzz +
+

+ You're invited to +

+

{host}

-

+ {policy?.age_attestation_required && ( + + )} +

+ {policy === null ? ( + + ) : ( + + )} +
+ {policy && (policy.terms_markdown || policy.privacy_markdown) && ( +

+ By proceeding you agree to the Buzz{" "} + {policy.terms_markdown && ( + + )} + {policy.terms_markdown && policy.privacy_markdown && " and "} + {policy.privacy_markdown && ( + + )} + . +

+ )} +
+

Don't have the app?{" "}

+ + {document && ( +
{ + if (event.currentTarget === event.target) setDocument(null); + }} + > +
+
+

{document.title}

+ +
+
+ + {document.markdown} + +
+
+
+ )} ); }