diff --git a/.env.example b/.env.example
index e9ff8c082b..fb1a3c2aa0 100644
--- a/.env.example
+++ b/.env.example
@@ -202,3 +202,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/Cargo.lock b/Cargo.lock
index d54fc64c0b..95fd7416cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1149,6 +1149,7 @@ dependencies = [
  "opentelemetry-otlp 0.32.0",
  "opentelemetry_sdk 0.32.1",
  "postcard",
+ "pulldown-cmark",
  "rand 0.10.1",
  "redis",
  "reqwest 0.13.4",
@@ -1522,7 +1523,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]]
@@ -2160,7 +2161,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]]
@@ -2371,7 +2372,7 @@ dependencies = [
  "libc",
  "option-ext",
  "redox_users",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2609,7 +2610,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]]
@@ -2992,8 +2993,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]]
@@ -5551,7 +5552,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]]
@@ -6735,6 +6736,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"
@@ -6840,7 +6859,7 @@ dependencies = [
  "once_cell",
  "socket2",
  "tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -7487,7 +7506,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -7546,7 +7565,7 @@ dependencies = [
  "security-framework 3.7.0",
  "security-framework-sys",
  "webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -7809,7 +7828,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]]
@@ -8706,7 +8725,7 @@ dependencies = [
  "getrandom 0.4.2",
  "once_cell",
  "rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -10005,7 +10024,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index 07c67a8241..5e20c267ee 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<bool> {
+        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<bool> {
+        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 1d8c961a29..2564988464 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]
@@ -1054,7 +1066,7 @@ mod tests {
         run_migrations(&pool)
             .await
             .expect("retry succeeds after operator repair");
-        assert_eq!(applied_versions(&pool).await.last().copied(), Some(19));
+        assert_eq!(applied_versions(&pool).await.last().copied(), Some(20));
     }
 
     #[tokio::test]
diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs
index d354b057bd..3bd114fda0 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<bool> {
+    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<bool> {
+    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/Cargo.toml b/crates/buzz-relay/Cargo.toml
index 36cc71dcb0..31eac113a1 100644
--- a/crates/buzz-relay/Cargo.toml
+++ b/crates/buzz-relay/Cargo.toml
@@ -77,6 +77,7 @@ moka            = { workspace = true }
 metrics         = { workspace = true }
 metrics-exporter-prometheus = { workspace = true }
 metrics-util    = { workspace = true }
+pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
 
 [features]
 dev = ["buzz-auth/dev"]
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 3b09e2a388..7d004c08c9 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<String>,
+}
+
+/// 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<Arc<AppState>>) -> Json<Value> {
+    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<Arc<AppState>>,
+    body: axum::body::Bytes,
+) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
+    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<Arc<AppState>> {
         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 c11dca9cc0..04c88ee94c 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<String>,
+    /// Operator-provided Privacy Policy document in Markdown.
+    pub privacy_markdown: Option<String>,
+    /// 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 {
@@ -201,6 +215,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<JoinPolicyConfig>,
+
     /// 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).
@@ -260,6 +278,22 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
     Ok(url)
 }
 
+fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
+    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<std::path::PathBuf>,
 ) -> Result<std::path::PathBuf, ConfigError> {
@@ -615,6 +649,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<Option<String>, 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()
@@ -687,6 +759,7 @@ impl Config {
             push_executor_key_id,
             push_gateway_delivery_url,
             push_gateway_timeout,
+            join_policy,
             web_dir,
             serve_git_web_gui,
         })
@@ -741,12 +814,34 @@ mod tests {
             !config.require_media_get_auth,
             "require_media_get_auth should default to false for staged client rollout"
         );
+        assert!(
+            config.join_policy.is_none(),
+            "join_policy should default to None so policy prompts and acceptance receipts are opt-in"
+        );
         assert!(
             config.huddle_audio_available,
             "huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
         );
     }
 
+    #[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 dda3c495ee..436040c32a 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<PolicyAcceptancePayload, InviteError> {
+    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 bb8de55569..0036a47af3 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<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))
+        .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 8969cc54bf..a890a99add 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -135,6 +135,7 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
 fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     let mut relay: Option<String> = None;
     let mut code: Option<String> = None;
+    let mut policy_receipt: Option<String> = None;
     for (k, v) in url.query_pairs() {
         let v = v.into_owned();
         if v.is_empty() {
@@ -143,6 +144,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
         match k.as_ref() {
             "relay" => relay = Some(v),
             "code" => code = Some(v),
+            "policy_receipt" => policy_receipt = Some(v),
             _ => {}
         }
     }
@@ -154,6 +156,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
     Some(serde_json::json!({
         "relayUrl": relay_url,
         "code": code,
+        "policyReceipt": policy_receipt,
     }))
 }
 
@@ -454,6 +457,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 ff2596a594..00986205f2 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -1,4 +1,5 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 
 import {
   deriveCommunityName,
@@ -6,6 +7,13 @@ import {
   normalizeRelayUrl,
 } from "@/features/communities/communityStorage";
 import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
+import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
+import {
+  acceptJoinPolicy,
+  getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
+  type JoinPolicy,
+} from "@/shared/api/invites";
 import { validateReposDir } from "@/shared/api/tauri";
 import { Button } from "@/shared/ui/button";
 import {
@@ -16,6 +24,10 @@ import {
   DialogTitle,
 } from "@/shared/ui/dialog";
 import { Input } from "@/shared/ui/input";
+import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
+
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
 
 type AddCommunityDialogProps = {
   onSubmit?: (
@@ -33,9 +45,42 @@ export function AddCommunityDialog({
   const [relayUrl, setRelayUrl] = React.useState("");
   const [token, setToken] = React.useState("");
   const [inviteCode, setInviteCode] = React.useState("");
+  const [inviteError, setInviteError] = React.useState<string | null>(null);
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
   const [reposDir, setReposDir] = React.useState("");
   const communityOnboarding = useCommunityOnboarding();
   const [reposDirError, setReposDirError] = React.useState<string | null>(null);
+  const shouldReduceMotion = useReducedMotion();
+
+  React.useEffect(() => {
+    if (!open || !relayUrl.trim()) return;
+
+    const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
+    if (!isJoinPolicyDiscoveryCandidate(normalizedUrl)) return;
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(normalizedUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setInviteError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [open, relayUrl]);
 
   const handleClose = React.useCallback(() => {
     onOpenChange(false);
@@ -43,6 +88,10 @@ export function AddCommunityDialog({
     setRelayUrl("");
     setToken("");
     setInviteCode("");
+    setInviteError(null);
+    setJoinPolicy(null);
+    setAgeConfirmed(false);
+    setAgreementConfirmed(false);
     setReposDir("");
     setReposDirError(null);
   }, [onOpenChange]);
@@ -67,6 +116,45 @@ export function AddCommunityDialog({
       }
 
       const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
+      let policyReceipt: string | undefined;
+      try {
+        const policy = await getJoinPolicy(normalizedRelayUrl);
+        if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
+          setJoinPolicy(policy);
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setInviteError(null);
+          return;
+        }
+        if (policy?.ageAttestationRequired && !ageConfirmed) {
+          setInviteError("Confirm that you are at least 18 years old.");
+          return;
+        }
+        if (
+          policy &&
+          (policy.termsMarkdown || policy.privacyMarkdown) &&
+          !agreementConfirmed
+        ) {
+          setInviteError("Agree to the Terms of Service and Privacy Policy.");
+          return;
+        }
+
+        // Receipts are bound to an invite code, so one is only minted when a
+        // code is present. The claim itself runs on the onboarding
+        // transaction (useClaimInvite), which forwards this receipt.
+        if (policy && inviteCode.trim()) {
+          policyReceipt = await acceptJoinPolicy(
+            normalizedRelayUrl,
+            inviteCode.trim(),
+            policy.version,
+            ageConfirmed,
+          );
+        }
+      } catch (error) {
+        setInviteError(`Community rejected: ${inviteErrorMessage(error)}`);
+        return;
+      }
+
       communityOnboarding.start({
         source: "add-community",
         relayUrl: normalizedRelayUrl,
@@ -74,6 +162,7 @@ export function AddCommunityDialog({
         communityName: name.trim() || deriveCommunityName(normalizedRelayUrl),
         token: token.trim() || undefined,
         reposDir: expandedReposDir,
+        policyReceipt,
       });
       handleClose();
     },
@@ -83,6 +172,9 @@ export function AddCommunityDialog({
       token,
       inviteCode,
       reposDir,
+      joinPolicy,
+      ageConfirmed,
+      agreementConfirmed,
       communityOnboarding,
       handleClose,
     ],
@@ -112,7 +204,13 @@ export function AddCommunityDialog({
             <Input
               autoFocus
               id="ws-relay-url"
-              onChange={(e) => setRelayUrl(e.target.value)}
+              onChange={(e) => {
+                setRelayUrl(e.target.value);
+                setInviteError(null);
+                setJoinPolicy(null);
+                setAgeConfirmed(false);
+                setAgreementConfirmed(false);
+              }}
               placeholder="wss://relay.example.com"
               type="text"
               value={relayUrl}
@@ -168,6 +266,10 @@ export function AddCommunityDialog({
               id="ws-invite-code"
               onChange={(e) => {
                 setInviteCode(e.target.value);
+                setInviteError(null);
+                setJoinPolicy(null);
+                setAgeConfirmed(false);
+                setAgreementConfirmed(false);
               }}
               placeholder="Paste an invite code for a members-only relay"
               type="text"
@@ -207,11 +309,81 @@ export function AddCommunityDialog({
             Communities share your active identity. To use a different key,
             import it on the profile step (or in settings).
           </p>
+          {inviteError ? (
+            <p className="text-xs text-destructive">{inviteError}</p>
+          ) : null}
+          <AnimatePresence initial={false}>
+            {joinPolicy && relayUrl.trim() ? (
+              <motion.div
+                animate={{
+                  height: "auto",
+                  marginTop: 0,
+                  opacity: 1,
+                  transform: "translateY(0rem)",
+                }}
+                className="overflow-hidden"
+                exit={
+                  shouldReduceMotion
+                    ? { height: 0, marginTop: "-1rem", opacity: 0 }
+                    : {
+                        height: 0,
+                        marginTop: "-1rem",
+                        opacity: 0,
+                        transform: "translateY(-0.25rem)",
+                      }
+                }
+                initial={
+                  shouldReduceMotion
+                    ? false
+                    : {
+                        height: 0,
+                        marginTop: "-1rem",
+                        opacity: 0,
+                        transform: "translateY(-0.25rem)",
+                      }
+                }
+                key={`${normalizeRelayUrl(relayUrl.trim())}:${joinPolicy.version}`}
+                transition={
+                  shouldReduceMotion
+                    ? { duration: 0 }
+                    : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+                }
+              >
+                <JoinPolicyNotice
+                  ageConfirmed={ageConfirmed}
+                  agreementConfirmed={agreementConfirmed}
+                  onAgeConfirmedChange={(confirmed) => {
+                    setAgeConfirmed(confirmed);
+                    setInviteError(null);
+                  }}
+                  onAgreementConfirmedChange={(confirmed) => {
+                    setAgreementConfirmed(confirmed);
+                    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())}
+                />
+              </motion.div>
+            ) : null}
+          </AnimatePresence>
           <div className="flex justify-end gap-2 pt-2">
             <Button onClick={handleClose} type="button" variant="outline">
               Cancel
             </Button>
-            <Button disabled={!relayUrl.trim()} type="submit">
+            <Button
+              disabled={
+                !relayUrl.trim() ||
+                Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+                Boolean(
+                  joinPolicy &&
+                    (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+                    !agreementConfirmed,
+                )
+              }
+              type="submit"
+            >
               Add Community
             </Button>
           </div>
diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx
index e0d47b2f78..4c94acf102 100644
--- a/desktop/src/features/communities/ui/CommunityEditForm.tsx
+++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx
@@ -1,14 +1,26 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
+import {
+  getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
+  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";
 
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
+
 export type CommunityEditFormProps = {
   cancelLabel?: string;
   initialName: string;
   initialRelayUrl: string;
   isSubmitting?: boolean;
+  joinPolicyRequired?: boolean;
   onCancel: () => void;
   onSubmit: (name: string, relayUrl: string) => void;
   submitLabel: string;
@@ -19,6 +31,7 @@ export function CommunityEditForm({
   initialName,
   initialRelayUrl,
   isSubmitting = false,
+  joinPolicyRequired = false,
   onCancel,
   onSubmit,
   submitLabel,
@@ -29,6 +42,13 @@ export function CommunityEditForm({
   const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
   const [isProbing, setIsProbing] = React.useState(false);
   const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [policyRelayUrl, setPolicyRelayUrl] = React.useState<string | null>(
+    null,
+  );
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
+  const shouldReduceMotion = useReducedMotion();
 
   const cancelRef = React.useRef<(() => void) | null>(null);
 
@@ -38,6 +58,37 @@ export function CommunityEditForm({
     };
   }, []);
 
+  React.useEffect(() => {
+    if (!joinPolicyRequired) return;
+
+    const normalizedUrl = normalizeRelayUrl(relayUrl);
+    if (!normalizedUrl || !isJoinPolicyDiscoveryCandidate(normalizedUrl)) {
+      return;
+    }
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(normalizedUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setPolicyRelayUrl(normalizedUrl);
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [joinPolicyRequired, relayUrl]);
+
   const handleSubmit = React.useCallback(
     async (event: React.FormEvent<HTMLFormElement>) => {
       event.preventDefault();
@@ -52,6 +103,41 @@ 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);
+            setAgreementConfirmed(false);
+            return;
+          }
+          if (policy.ageAttestationRequired && !ageConfirmed) {
+            setError("Confirm that you are at least 18 years old.");
+            return;
+          }
+          if (
+            (policy.termsMarkdown || policy.privacyMarkdown) &&
+            !agreementConfirmed
+          ) {
+            setError("Agree to the Terms of Service and Privacy Policy.");
+            return;
+          }
+        } catch (policyError) {
+          setError(inviteErrorMessage(policyError));
+          return;
+        }
+      }
+
       if (useAnywayOverride) {
         onSubmit(trimmedName, normalizedUrl);
         return;
@@ -79,7 +165,17 @@ export function CommunityEditForm({
 
       onSubmit(trimmedName, normalizedUrl);
     },
-    [name, onSubmit, relayUrl, useAnywayOverride],
+    [
+      ageConfirmed,
+      agreementConfirmed,
+      joinPolicy,
+      joinPolicyRequired,
+      name,
+      onSubmit,
+      policyRelayUrl,
+      relayUrl,
+      useAnywayOverride,
+    ],
   );
 
   const handleUseAnyway = React.useCallback(() => {
@@ -139,6 +235,10 @@ export function CommunityEditForm({
             setError(null);
             setProbeWarning(null);
             setUseAnywayOverride(false);
+            setJoinPolicy(null);
+            setPolicyRelayUrl(null);
+            setAgeConfirmed(false);
+            setAgreementConfirmed(false);
           }}
           placeholder="wss://relay.example.com"
           type="text"
@@ -146,10 +246,75 @@ export function CommunityEditForm({
         />
       </div>
 
+      <AnimatePresence initial={false}>
+        {joinPolicy && policyRelayUrl ? (
+          <motion.div
+            animate={{
+              height: "auto",
+              marginTop: 0,
+              opacity: 1,
+              transform: "translateY(0rem)",
+            }}
+            className="overflow-hidden"
+            exit={
+              shouldReduceMotion
+                ? { height: 0, marginTop: "-1rem", opacity: 0 }
+                : {
+                    height: 0,
+                    marginTop: "-1rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            initial={
+              shouldReduceMotion
+                ? false
+                : {
+                    height: 0,
+                    marginTop: "-1rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            key={`${policyRelayUrl}:${joinPolicy.version}`}
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+            }
+          >
+            <JoinPolicyNotice
+              ageConfirmed={ageConfirmed}
+              agreementConfirmed={agreementConfirmed}
+              onAgeConfirmedChange={(confirmed) => {
+                setAgeConfirmed(confirmed);
+                setError(null);
+              }}
+              onAgreementConfirmedChange={(confirmed) => {
+                setAgreementConfirmed(confirmed);
+                setError(null);
+              }}
+              policy={joinPolicy}
+              relayWsUrl={policyRelayUrl}
+            />
+          </motion.div>
+        ) : null}
+      </AnimatePresence>
+
       <div className="flex w-full flex-col gap-3 pt-1">
         <Button
           className="h-10 w-full"
-          disabled={isBusy || !name.trim() || !relayUrl.trim()}
+          disabled={
+            isBusy ||
+            !name.trim() ||
+            !relayUrl.trim() ||
+            Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+            Boolean(
+              joinPolicy &&
+                (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+                !agreementConfirmed,
+            )
+          }
           type="submit"
         >
           {isProbing ? (
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index c3df86c72a..cbf6549f2c 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -65,11 +65,12 @@ export function WelcomeSetup({
   }, []);
 
   const handleInviteRedeem = React.useCallback(
-    (relayWsUrl: string, code: string) => {
+    (relayWsUrl: string, code: string, policyReceipt?: string) => {
       communityOnboarding.start({
         source: "first-community",
         relayUrl: relayWsUrl,
         inviteCode: code,
+        policyReceipt,
       });
     },
     [communityOnboarding],
diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx
index 731a2d5161..dbe09cb8f6 100644
--- a/desktop/src/features/onboarding/communityOnboarding.tsx
+++ b/desktop/src/features/onboarding/communityOnboarding.tsx
@@ -29,6 +29,12 @@ export type CommunityOnboardingTransaction = {
   communityName: string;
   token?: string;
   reposDir?: string;
+  /**
+   * Join-policy acceptance receipt minted before the claim (bound to the
+   * invite code). Forwarded to `claimInvite` so relays with a configured
+   * join policy admit the claim.
+   */
+  policyReceipt?: string;
   communityId?: string;
   createdAt: string;
   updatedAt: string;
@@ -52,6 +58,7 @@ export type StartCommunityOnboardingInput = {
   communityName?: string;
   token?: string;
   reposDir?: string;
+  policyReceipt?: string;
 };
 
 function canonicalRelayUrl(rawRelayUrl: string) {
@@ -127,6 +134,7 @@ export function startCommunityOnboarding(
       communityName: input.communityName?.trim() || existing.communityName,
       token: input.token?.trim() || existing.token,
       reposDir: input.reposDir ?? existing.reposDir,
+      policyReceipt: input.policyReceipt ?? existing.policyReceipt,
       updatedAt: now.toISOString(),
       error: undefined,
       // A freshly opened link deserves fresh feedback — re-present the gate
@@ -147,6 +155,7 @@ export function startCommunityOnboarding(
     communityName: input.communityName?.trim() || deriveCommunityName(relayUrl),
     token: input.token?.trim() || undefined,
     reposDir: input.reposDir,
+    policyReceipt: input.policyReceipt,
     createdAt: timestamp,
     updatedAt: timestamp,
   };
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index 5e8f36a423..fc9e9cde7d 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -1,9 +1,23 @@
 import * as React from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 
-import { parseInviteInput } from "@/shared/api/inviteHelpers";
+import {
+  inviteErrorMessage,
+  parseInviteInput,
+} from "@/shared/api/inviteHelpers";
+import {
+  acceptJoinPolicy,
+  getJoinPolicy,
+  isJoinPolicyDiscoveryCandidate,
+  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";
+
+const POLICY_DISCOVERY_DELAY_MS = 250;
+const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
 
 type InviteRedeemFormProps = {
   /**
@@ -16,7 +30,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 +44,16 @@ export function InviteRedeemForm({
   const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
     defaultRelayUrl ?? "",
   );
+  const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
+  const [policyInvite, setPolicyInvite] = React.useState<{
+    relayWsUrl: string;
+    code: string;
+  } | null>(null);
+  const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+  const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
+  const [policyError, setPolicyError] = React.useState<string | null>(null);
+  const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
+  const shouldReduceMotion = useReducedMotion();
 
   const parsed = React.useMemo(
     () => parseInviteInput(inviteInput),
@@ -38,23 +62,106 @@ export function InviteRedeemForm({
   const isBareCode = parsed !== null && !("relayWsUrl" in parsed);
   const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
 
+  React.useEffect(() => {
+    if (!parsed) return;
+
+    const relayWsUrl =
+      "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+    if (!relayWsUrl || !isJoinPolicyDiscoveryCandidate(relayWsUrl)) return;
+
+    let cancelled = false;
+    const timeoutId = window.setTimeout(() => {
+      void getJoinPolicy(relayWsUrl)
+        .then((policy) => {
+          if (cancelled || !policy) return;
+          setJoinPolicy(policy);
+          setPolicyInvite({ relayWsUrl, code: parsed.code });
+          setAgeConfirmed(false);
+          setAgreementConfirmed(false);
+          setPolicyError(null);
+        })
+        .catch(() => {
+          // Background discovery is best-effort. A deliberate submit retries
+          // the request and surfaces any relay error to the user.
+        });
+    }, POLICY_DISCOVERY_DELAY_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearTimeout(timeoutId);
+    };
+  }, [bareCodeRelayUrl, parsed]);
+
   const canSubmit =
     parsed !== null &&
     ("relayWsUrl" in parsed ||
       (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);
+          setAgreementConfirmed(false);
+          return;
+        }
+
+        if (policy.ageAttestationRequired && !ageConfirmed) {
+          setPolicyError("Confirm that you are at least 18 years old.");
+          return;
+        }
+        if (
+          (policy.termsMarkdown || policy.privacyMarkdown) &&
+          !agreementConfirmed
+        ) {
+          setPolicyError("Agree to the Terms of Service and Privacy Policy.");
+          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,
+      agreementConfirmed,
+      bareCodeRelayUrl,
+      joinPolicy,
+      onRedeem,
+      parsed,
+      policyInvite,
+    ],
   );
 
   return (
@@ -74,7 +181,14 @@ 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);
+            setAgreementConfirmed(false);
+            setPolicyError(null);
+          }}
           placeholder="https://relay.example.com/invite/abc123 or paste a code"
           spellCheck={false}
           type="text"
@@ -94,7 +208,14 @@ 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);
+              setAgreementConfirmed(false);
+              setPolicyError(null);
+            }}
             placeholder="wss://relay.example.com"
             type="text"
             value={bareCodeRelayUrl}
@@ -102,18 +223,92 @@ export function InviteRedeemForm({
         </div>
       ) : null}
 
+      {policyError ? (
+        <p className="text-center text-sm text-destructive">{policyError}</p>
+      ) : null}
+
       {error ? (
         <p className="text-center text-sm text-destructive">{error}</p>
       ) : null}
 
+      <AnimatePresence initial={false}>
+        {joinPolicy && policyInvite ? (
+          <motion.div
+            animate={{
+              height: "auto",
+              marginTop: 0,
+              opacity: 1,
+              transform: "translateY(0rem)",
+            }}
+            className="overflow-hidden"
+            exit={
+              shouldReduceMotion
+                ? { height: 0, marginTop: "-0.75rem", opacity: 0 }
+                : {
+                    height: 0,
+                    marginTop: "-0.75rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            initial={
+              shouldReduceMotion
+                ? false
+                : {
+                    height: 0,
+                    marginTop: "-0.75rem",
+                    opacity: 0,
+                    transform: "translateY(-0.25rem)",
+                  }
+            }
+            key={`${policyInvite.relayWsUrl}:${joinPolicy.version}`}
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : { duration: 0.22, ease: POLICY_REVEAL_EASE }
+            }
+          >
+            <JoinPolicyNotice
+              ageConfirmed={ageConfirmed}
+              agreementConfirmed={agreementConfirmed}
+              onAgeConfirmedChange={(confirmed) => {
+                setAgeConfirmed(confirmed);
+                setPolicyError(null);
+              }}
+              onAgreementConfirmedChange={(confirmed) => {
+                setAgreementConfirmed(confirmed);
+                setPolicyError(null);
+              }}
+              policy={joinPolicy}
+              relayWsUrl={policyInvite.relayWsUrl}
+            />
+          </motion.div>
+        ) : null}
+      </AnimatePresence>
+
       <Button
         className="h-10 w-full"
         data-testid="invite-redeem-submit"
-        disabled={!canSubmit || isRedeeming}
+        disabled={
+          !canSubmit ||
+          isRedeeming ||
+          isLoadingPolicy ||
+          Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
+          Boolean(
+            joinPolicy &&
+              (joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
+              !agreementConfirmed,
+          )
+        }
         type="submit"
       >
-        {isRedeeming ? (
-          <Spinner aria-label="Redeeming invite" className="h-4 w-4 border-2" />
+        {isRedeeming || isLoadingPolicy ? (
+          <Spinner
+            aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
+            className="h-4 w-4 border-2"
+          />
+        ) : joinPolicy ? (
+          "Accept and redeem invite"
         ) : (
           "Redeem invite"
         )}
diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
new file mode 100644
index 0000000000..4d5e6771b6
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -0,0 +1,108 @@
+import * as React from "react";
+import { openUrl } from "@tauri-apps/plugin-opener";
+
+import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
+import { Button } from "@/shared/ui/button";
+import { Checkbox } from "@/shared/ui/checkbox";
+
+type JoinPolicyNoticeProps = {
+  ageConfirmed: boolean;
+  agreementConfirmed: boolean;
+  onAgeConfirmedChange: (confirmed: boolean) => void;
+  onAgreementConfirmedChange: (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,
+  agreementConfirmed,
+  onAgeConfirmedChange,
+  onAgreementConfirmedChange,
+  policy,
+  relayWsUrl,
+}: JoinPolicyNoticeProps) {
+  const ageConfirmationId = React.useId();
+  const agreementConfirmationId = React.useId();
+
+  return (
+    <div className="space-y-3 rounded-xl border border-border/70 bg-muted/30 p-4 text-left">
+      {policy.ageAttestationRequired ? (
+        <div className="flex items-start gap-3">
+          <Checkbox
+            checked={ageConfirmed}
+            className="mt-0.5"
+            id={ageConfirmationId}
+            onCheckedChange={(checked) =>
+              onAgeConfirmedChange(checked === true)
+            }
+          />
+          <label
+            className="cursor-pointer text-xs leading-5 text-muted-foreground"
+            htmlFor={ageConfirmationId}
+          >
+            I am 18 years of age or older.
+          </label>
+        </div>
+      ) : null}
+
+      {policy.termsMarkdown || policy.privacyMarkdown ? (
+        <div className="flex items-start gap-3">
+          <Checkbox
+            checked={agreementConfirmed}
+            className="mt-0.5"
+            id={agreementConfirmationId}
+            onCheckedChange={(checked) =>
+              onAgreementConfirmedChange(checked === true)
+            }
+          />
+          <label
+            className="cursor-pointer text-xs leading-5 text-muted-foreground"
+            htmlFor={agreementConfirmationId}
+          >
+            I agree to the Buzz{" "}
+            {policy.termsMarkdown ? (
+              <Button
+                className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
+                onClick={(event) => {
+                  event.preventDefault();
+                  void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
+                }}
+                type="button"
+                variant="link"
+              >
+                Terms of Service
+              </Button>
+            ) : null}
+            {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
+            {policy.privacyMarkdown ? (
+              <Button
+                className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
+                onClick={(event) => {
+                  event.preventDefault();
+                  void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
+                }}
+                type="button"
+                variant="link"
+              >
+                Privacy Policy
+              </Button>
+            ) : null}
+            .
+          </label>
+        </div>
+      ) : null}
+    </div>
+  );
+}
diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
index fc70b49770..ce7df54257 100644
--- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx
+++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
@@ -84,11 +84,12 @@ export function MembershipDenied({
   }, [onImportKey, previewNpub, trimmedNsec]);
 
   const handleInviteRedeem = React.useCallback(
-    (relayWsUrl: string, code: string) => {
+    (relayWsUrl: string, code: string, policyReceipt?: string) => {
       communityOnboarding.start({
         source: "membership-recovery",
         relayUrl: relayWsUrl,
         inviteCode: code,
+        policyReceipt,
       });
     },
     [communityOnboarding],
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index afb99a5ff5..2f4d5ade44 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -41,7 +41,7 @@ export function NostrKeyImportForm({
   const trimmedInput = nsecInput.trim();
   const hasInput = trimmedInput.length > 0;
   const isValid = previewNpub !== null;
-  const isBusy = disabled || isImporting;
+  const isInteractionDisabled = disabled || isImporting;
   const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
   const errorMessage = importError ?? externalErrorMessage;
 
@@ -50,12 +50,12 @@ export function NostrKeyImportForm({
   }, []);
 
   const openFilePicker = React.useCallback(() => {
-    if (isBusy) {
+    if (isInteractionDisabled) {
       return;
     }
 
     fileInputRef.current?.click();
-  }, [isBusy]);
+  }, [isInteractionDisabled]);
 
   const handleFiles = React.useCallback(async (files: FileList | null) => {
     const file = files?.[0];
@@ -98,7 +98,7 @@ export function NostrKeyImportForm({
       await onImport(trimmedInput);
     } catch (error) {
       setImportError(
-        error instanceof Error ? error.message : "Failed to import key.",
+        error instanceof Error ? error.message : "Couldn't import this key.",
       );
     } finally {
       setIsImporting(false);
@@ -141,7 +141,7 @@ export function NostrKeyImportForm({
       <input
         accept=".key,text/plain"
         className="sr-only"
-        disabled={isBusy}
+        disabled={isInteractionDisabled}
         onChange={(event) => {
           void handleFiles(event.currentTarget.files);
           event.currentTarget.value = "";
@@ -159,12 +159,12 @@ export function NostrKeyImportForm({
         )}
         data-dragging={isDragging ? "true" : undefined}
         data-testid="nostr-import-drop"
-        disabled={isBusy}
+        disabled={isInteractionDisabled}
         onClick={openFilePicker}
         onDragEnter={(event) => {
           event.preventDefault();
           event.stopPropagation();
-          if (!isBusy) {
+          if (!isInteractionDisabled) {
             setIsDragging(true);
           }
         }}
@@ -181,7 +181,7 @@ export function NostrKeyImportForm({
         onDragOver={(event) => {
           event.preventDefault();
           event.stopPropagation();
-          if (!isBusy) {
+          if (!isInteractionDisabled) {
             event.dataTransfer.dropEffect = "copy";
           }
         }}
@@ -189,7 +189,7 @@ export function NostrKeyImportForm({
           event.preventDefault();
           event.stopPropagation();
           setIsDragging(false);
-          if (isBusy) {
+          if (isInteractionDisabled) {
             return;
           }
           void handleFiles(event.dataTransfer.files);
@@ -250,10 +250,10 @@ export function NostrKeyImportForm({
         <Button
           className="h-10 w-full"
           data-testid="nostr-import-submit"
-          disabled={!isValid || isBusy}
+          disabled={!isValid || isInteractionDisabled}
           type="submit"
         >
-          {isBusy ? (
+          {isImporting ? (
             <Spinner aria-label="Importing key" className="h-4 w-4 border-2" />
           ) : (
             "Continue with this key"
@@ -262,7 +262,7 @@ export function NostrKeyImportForm({
 
         <Button
           className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
-          disabled={isBusy}
+          disabled={isImporting}
           onClick={onBack}
           type="button"
           variant="ghost"
diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts
index 830f9412fc..56ca185a26 100644
--- a/desktop/src/features/onboarding/useClaimInvite.ts
+++ b/desktop/src/features/onboarding/useClaimInvite.ts
@@ -1,7 +1,10 @@
 import * as React from "react";
 
 import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
-import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
+import {
+  inviteErrorMessage,
+  isInviteExpiredError,
+} from "@/shared/api/inviteHelpers";
 import { claimInvite } from "@/shared/api/invites";
 
 /**
@@ -23,12 +26,23 @@ export function useClaimInvite() {
       return;
     }
     setIsPending(true);
-    void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "")
+    void claimInvite(
+      transaction.relayUrl,
+      transaction.inviteCode ?? "",
+      transaction.policyReceipt,
+    )
       .then(() => {
         update({ stage: "connecting", error: undefined }, transaction.id);
       })
       .catch((error: unknown) =>
-        update({ error: inviteErrorMessage(error) }, transaction.id),
+        update(
+          {
+            error: isInviteExpiredError(error)
+              ? "This invite code has expired — ask for a new one."
+              : inviteErrorMessage(error),
+          },
+          transaction.id,
+        ),
       )
       .finally(() => setIsPending(false));
   }, [isPending, transaction, update]);
diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs
new file mode 100644
index 0000000000..eb4ca3dad1
--- /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 371de05cc2..ae03c71ada 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -23,6 +23,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;
@@ -90,6 +97,81 @@ 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}`;
+}
+
+/** Whether a normalized relay URL is complete enough for background policy discovery. */
+export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
+  try {
+    const url = new URL(relayWsUrl);
+    if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
+    return (
+      url.hostname === "localhost" ||
+      url.hostname === "127.0.0.1" ||
+      url.hostname.includes(".")
+    );
+  } catch {
+    return false;
+  }
+}
+
+/** Fetch relay-hosted policy content for any join surface. */
+export async function getJoinPolicy(
+  relayWsUrl: string,
+): Promise<JoinPolicy | null> {
+  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<string> {
+  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<MintedInvite> {
   const base = await getRelayHttpUrl();
@@ -109,9 +191,10 @@ export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
 export async function claimInvite(
   relayWsUrl: string,
   code: string,
+  policyReceipt?: string,
 ): Promise<ClaimResult> {
   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 66415cabfe..d93a3910d1 100644
--- a/desktop/src/shared/deep-link.ts
+++ b/desktop/src/shared/deep-link.ts
@@ -37,6 +37,7 @@ export type NostrBindDeepLinkPayload = {
 export type JoinDeepLinkPayload = {
   relayUrl: string;
   code: string;
+  policyReceipt: string | null;
 };
 
 type PendingCommunityDeepLink = {
@@ -44,6 +45,7 @@ type PendingCommunityDeepLink = {
   kind: "connect" | "join";
   relayUrl: string;
   code: string | null;
+  policyReceipt: string | null;
 };
 
 function acceptPendingCommunityDeepLink(
@@ -54,6 +56,7 @@ function acceptPendingCommunityDeepLink(
     source: pending.kind === "join" ? "deep-link-join" : "deep-link-connect",
     relayUrl: pending.relayUrl,
     inviteCode: pending.code ?? undefined,
+    policyReceipt: pending.policyReceipt ?? undefined,
   });
   return accepted
     ? invoke<boolean>("acknowledge_pending_community_deep_link", {
diff --git a/desktop/src/shared/ui/checkbox.tsx b/desktop/src/shared/ui/checkbox.tsx
index 41044b2ab0..fbfa2da490 100644
--- a/desktop/src/shared/ui/checkbox.tsx
+++ b/desktop/src/shared/ui/checkbox.tsx
@@ -1,28 +1,53 @@
 import * as React from "react";
 import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { Check } from "lucide-react";
+import { motion, useReducedMotion } from "motion/react";
 
 import { cn } from "@/shared/lib/cn";
 
 const Checkbox = React.forwardRef<
   React.ElementRef<typeof CheckboxPrimitive.Root>,
   React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
->(({ className, ...props }, ref) => (
-  <CheckboxPrimitive.Root
-    ref={ref}
-    className={cn(
-      "peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
-      className,
-    )}
-    {...props}
-  >
-    <CheckboxPrimitive.Indicator
-      className={cn("flex items-center justify-center text-current")}
+>(({ className, ...props }, ref) => {
+  const shouldReduceMotion = useReducedMotion();
+
+  return (
+    <CheckboxPrimitive.Root
+      ref={ref}
+      className={cn(
+        "peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+        className,
+      )}
+      {...props}
     >
-      <Check className="h-4 w-4" />
-    </CheckboxPrimitive.Indicator>
-  </CheckboxPrimitive.Root>
-));
+      <CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
+        <svg
+          aria-hidden="true"
+          className="h-4 w-4"
+          fill="none"
+          viewBox="0 0 24 24"
+        >
+          <motion.path
+            animate={{ opacity: 1, pathLength: 1 }}
+            d="m5 12 4 4L19 6"
+            initial={shouldReduceMotion ? false : { opacity: 0, pathLength: 0 }}
+            stroke="currentColor"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth="2"
+            transition={
+              shouldReduceMotion
+                ? { duration: 0 }
+                : {
+                    duration: 0.18,
+                    ease: [0.23, 1, 0.32, 1],
+                  }
+            }
+          />
+        </svg>
+      </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+  );
+});
 Checkbox.displayName = CheckboxPrimitive.Root.displayName;
 
 export { Checkbox };
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index c428f61956..1dcc832a58 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -2539,6 +2539,7 @@ let mockPersonas: RawPersona[] = [];
 let mockTeams: RawTeam[] = [];
 // Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name.
 const tauriEventListeners = new Map<string, Set<() => void>>();
+const openedExternalUrls: string[] = [];
 const defaultMockRelayAgents: RawRelayAgent[] = [
   {
     pubkey: ALICE_PUBKEY,
@@ -8587,6 +8588,11 @@ export function maybeInstallE2eTauriMocks() {
         return importMockIdentity(
           (payload as { nsec?: string } | null)?.nsec ?? "",
         );
+      case "validate_repos_dir":
+        // The browser harness has no host filesystem to validate. Treat the
+        // seeded empty/default path as valid so Add Community can continue to
+        // relay-policy discovery.
+        return;
       case "apply_workspace": {
         const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0;
         if (applyDelayMs > 0) {
@@ -9528,6 +9534,12 @@ export function maybeInstallE2eTauriMocks() {
           payload as Parameters<typeof sendToMockSocket>[0],
         );
       case "plugin:opener|open_url":
+        openedExternalUrls.push(String((payload as { url: string | URL }).url));
+        return null;
+      case "get_e2e_opened_external_urls":
+        return [...openedExternalUrls];
+      case "clear_e2e_opened_external_urls":
+        openedExternalUrls.length = 0;
         return null;
       case "plugin:window|show":
       case "plugin:window|unminimize":
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 0e7ab1f5c1..feddb43121 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -578,6 +578,16 @@ test("first-community choices expose npub and invite input", async ({
     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 expect(
@@ -1685,7 +1695,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",
@@ -1704,6 +1748,12 @@ 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
+    .getByLabel("I agree to the Buzz Terms of Service and Privacy Policy.")
+    .check();
+  await page.getByTestId("invite-redeem-submit").click();
 
   // After successful claim, relay B is added and becomes active; relay A remains
   // in the community list for future switching.
diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts
index 200d8d0297..6945c54151 100644
--- a/desktop/tests/e2e/sidebar.spec.ts
+++ b/desktop/tests/e2e/sidebar.spec.ts
@@ -57,6 +57,60 @@ async function dragSidebarRail(page: Page, deltaX: number) {
   await page.mouse.up();
 }
 
+test("automatically shows relay join requirements near the relay URL", async ({
+  page,
+}) => {
+  await page.route(
+    "https://policy.example.com/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.goto("/");
+
+  await page.getByTestId("sidebar-profile-card").click();
+  await page.getByText("Add Community", { exact: true }).click();
+  await page.getByLabel("Relay URL").fill("wss://policy.example.com");
+
+  const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
+  const agreementConfirmation = page.getByLabel(
+    "I agree to the Buzz Terms of Service and Privacy Policy.",
+  );
+  await expect(ageConfirmation).toBeVisible();
+  await expect(agreementConfirmation).toBeVisible();
+  await expect(
+    page.getByText("Review this relay's join policy below."),
+  ).toHaveCount(0);
+  await expect(page.getByText(/By continuing, you agree/)).toHaveCount(0);
+
+  const addCommunityButton = page.getByRole("button", {
+    name: "Add Community",
+  });
+  await expect(addCommunityButton).toBeDisabled();
+  await ageConfirmation.check();
+  await expect(ageConfirmation.locator("svg path")).toBeVisible();
+  await expect(addCommunityButton).toBeDisabled();
+  await agreementConfirmation.check();
+  await expect(addCommunityButton).toBeEnabled();
+
+  const consentBox = await agreementConfirmation.boundingBox();
+  const reposInput = await page.locator("#ws-repos-dir").boundingBox();
+  const addButtonBox = await addCommunityButton.boundingBox();
+  expect(consentBox?.y).toBeGreaterThan(reposInput?.y ?? Number.MAX_VALUE);
+  expect(consentBox?.y).toBeLessThan(addButtonBox?.y ?? 0);
+});
+
 test("leaving a channel from the context menu never freezes the app", async ({
   page,
 }) => {
diff --git a/migrations/0020_join_policy_acceptances.sql b/migrations/0020_join_policy_acceptances.sql
new file mode 100644
index 0000000000..061d4670fd
--- /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/InviteJoinPolicyNotice.tsx b/web/src/features/invite/ui/InviteJoinPolicyNotice.tsx
new file mode 100644
index 0000000000..585de0b898
--- /dev/null
+++ b/web/src/features/invite/ui/InviteJoinPolicyNotice.tsx
@@ -0,0 +1,161 @@
+import * as React from "react";
+
+type InviteJoinPolicy = {
+  terms_markdown?: string;
+  privacy_markdown?: string;
+  age_attestation_required: boolean;
+};
+
+type PolicyCheckboxProps = {
+  accessibleLabel: string;
+  checked: boolean;
+  children: React.ReactNode;
+  onCheckedChange: (checked: boolean) => void;
+};
+
+function PolicyCheckbox({
+  accessibleLabel,
+  checked,
+  children,
+  onCheckedChange,
+}: PolicyCheckboxProps) {
+  const id = React.useId();
+
+  return (
+    <div className="relative">
+      <input
+        aria-label={accessibleLabel}
+        checked={checked}
+        className="peer sr-only"
+        id={id}
+        onChange={(event) => onCheckedChange(event.target.checked)}
+        type="checkbox"
+      />
+      <label
+        className="flex cursor-pointer items-start gap-3 rounded-sm text-left text-xs leading-5 text-black/60 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-black"
+        htmlFor={id}
+      >
+        <span
+          aria-hidden="true"
+          className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-[background-color,border-color] duration-150 motion-reduce:transition-none ${
+            checked ? "border-black bg-black" : "border-black/40 bg-white"
+          }`}
+        >
+          <svg
+            aria-hidden="true"
+            className="h-3 w-3 text-white"
+            fill="none"
+            viewBox="0 0 16 16"
+          >
+            <path
+              className={
+                checked
+                  ? "transition-[stroke-dashoffset] duration-[180ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
+                  : "transition-none"
+              }
+              d="M3 8.25 6.25 11.5 13 4.75"
+              pathLength="16"
+              stroke="currentColor"
+              strokeDasharray="16"
+              strokeDashoffset={checked ? 0 : 16}
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth="2"
+            />
+          </svg>
+        </span>
+        <span>{children}</span>
+      </label>
+    </div>
+  );
+}
+
+/** The invite page's age and legal confirmations. */
+export function InviteJoinPolicyNotice({
+  ageConfirmed,
+  agreementConfirmed,
+  onAgeConfirmedChange,
+  onAgreementConfirmedChange,
+  onShowDocument,
+  policy,
+}: {
+  ageConfirmed: boolean;
+  agreementConfirmed: boolean;
+  onAgeConfirmedChange: (checked: boolean) => void;
+  onAgreementConfirmedChange: (checked: boolean) => void;
+  onShowDocument: (title: string, markdown: string) => void;
+  policy: InviteJoinPolicy;
+}) {
+  const stopLabelActivation = (
+    event: React.MouseEvent<HTMLButtonElement>,
+    title: string,
+    markdown: string,
+  ) => {
+    event.preventDefault();
+    event.stopPropagation();
+    onShowDocument(title, markdown);
+  };
+
+  return (
+    <div
+      className="w-full space-y-3 rounded-xl border border-black/10 bg-black/[0.03] p-4 text-left"
+      data-testid="invite-join-policy-notice"
+    >
+      {policy.age_attestation_required ? (
+        <PolicyCheckbox
+          accessibleLabel="I am 18 years of age or older."
+          checked={ageConfirmed}
+          onCheckedChange={onAgeConfirmedChange}
+        >
+          I am 18 years of age or older.
+        </PolicyCheckbox>
+      ) : null}
+
+      {policy.terms_markdown || policy.privacy_markdown ? (
+        <PolicyCheckbox
+          accessibleLabel="I agree to the Buzz Terms of Service and Privacy Policy."
+          checked={agreementConfirmed}
+          onCheckedChange={onAgreementConfirmedChange}
+        >
+          I agree to the Buzz{" "}
+          {policy.terms_markdown ? (
+            <button
+              className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
+              type="button"
+              onClick={(event) =>
+                stopLabelActivation(
+                  event,
+                  "Terms of Service",
+                  policy.terms_markdown ?? "",
+                )
+              }
+            >
+              Terms of Service
+            </button>
+          ) : (
+            "Terms of Service"
+          )}{" "}
+          and{" "}
+          {policy.privacy_markdown ? (
+            <button
+              className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
+              type="button"
+              onClick={(event) =>
+                stopLabelActivation(
+                  event,
+                  "Privacy Policy",
+                  policy.privacy_markdown ?? "",
+                )
+              }
+            >
+              Privacy Policy
+            </button>
+          ) : (
+            "Privacy Policy"
+          )}
+          .
+        </PolicyCheckbox>
+      ) : null}
+    </div>
+  );
+}
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 3a460a1ba8..72a1d11c7e 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/<code>`).
- *
- * 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/<code>`). */
 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<JoinPolicy | null | undefined>(
+    undefined,
+  );
+  const [document, setDocument] = React.useState<PolicyDocument | null>(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 (
     <div
@@ -24,38 +75,94 @@ export function InvitePage({ code }: { code: string }) {
         backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
       }}
     >
-      <div
-        className="flex w-full max-w-xl flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12"
-        style={{
-          boxShadow:
-            "0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)",
-        }}
-      >
-        <div
-          className="h-16 w-16 overflow-hidden bg-black"
-          style={{ borderRadius: "22.37%" }}
-        >
-          <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
-        </div>
-        <h1 className="mt-6 text-2xl font-semibold tracking-tight text-black">
-          You&apos;re invited to join
-        </h1>
-        <p className="mt-1 font-mono text-lg text-black/70">{host}</p>
-
-        <div className="mt-8">
-          <Button
-            asChild
-            className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
-            size="lg"
+      <div className="w-full max-w-xl space-y-4">
+        <div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
+          <div
+            className="h-12 w-12 overflow-hidden bg-black"
+            style={{ borderRadius: "22.37%" }}
           >
-            <a href={deepLink}>Accept invite in Buzz</a>
-          </Button>
-        </div>
+            <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
+          </div>
+          <h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
+            You&apos;re invited to
+          </h1>
+          <p className="mt-9 font-mono text-lg text-black/70">{host}</p>
 
-        <p className="mt-6 text-sm text-black/60">
+          {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"}>
+            {policy === null ? (
+              <Button
+                asChild
+                className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
+                size="lg"
+              >
+                <a
+                  href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
+                >
+                  Accept invite in Buzz
+                </a>
+              </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"
+                disabled={disabled}
+                onClick={openInvite}
+              >
+                Accept invite in Buzz
+              </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?{" "}
           <a
-            className="font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
+            className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
             href={DOWNLOAD_URL}
             rel="noreferrer"
             target="_blank"
@@ -64,6 +171,37 @@ export function InvitePage({ code }: { code: string }) {
           </a>
         </p>
       </div>
+
+      {document && (
+        <div
+          aria-label={document.title}
+          aria-modal="true"
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
+          role="dialog"
+          onMouseDown={(event) => {
+            if (event.currentTarget === event.target) setDocument(null);
+          }}
+        >
+          <div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
+            <div className="mb-6 flex items-start justify-between gap-4">
+              <h2 className="text-xl font-semibold">{document.title}</h2>
+              <button
+                aria-label="Close"
+                className="text-2xl leading-none text-black/60 hover:text-black"
+                type="button"
+                onClick={() => setDocument(null)}
+              >
+                ×
+              </button>
+            </div>
+            <div className="prose prose-sm max-w-none">
+              <Markdown remarkPlugins={[remarkGfm]}>
+                {document.markdown}
+              </Markdown>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }
diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts
index 183197dac3..3e897b12f3 100644
--- a/web/tests/e2e/smoke.spec.ts
+++ b/web/tests/e2e/smoke.spec.ts
@@ -9,3 +9,67 @@ test("home page shows repositories section", async ({ page }) => {
   await page.goto("/");
   await expect(page.getByText("Repositories")).toBeVisible();
 });
+
+test("invite requires age and legal consent before opening Buzz", async ({
+  page,
+}) => {
+  await page.route("**/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.goto("/invite/demo-code");
+
+  const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
+  const agreementConfirmation = page.getByLabel(
+    "I agree to the Buzz Terms of Service and Privacy Policy.",
+  );
+  const acceptInvite = page.getByRole("button", {
+    name: "Accept invite in Buzz",
+  });
+
+  await expect(ageConfirmation).toBeVisible();
+  await expect(agreementConfirmation).toBeVisible();
+  await expect(acceptInvite).toBeDisabled();
+
+  const termsLink = page.getByRole("button", { name: "Terms of Service" });
+  const privacyLink = page.getByRole("button", { name: "Privacy Policy" });
+  await expect(termsLink).toHaveCSS("text-decoration-line", "none");
+  await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
+  await termsLink.hover();
+  await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
+  await page.mouse.move(0, 0);
+  await privacyLink.hover();
+  await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
+
+  await page
+    .locator("label")
+    .filter({ hasText: "I am 18 years of age or older." })
+    .click();
+  await expect(ageConfirmation).toBeChecked();
+  await expect(acceptInvite).toBeDisabled();
+  await page
+    .locator("label")
+    .filter({
+      hasText: "I agree to the Buzz Terms of Service and Privacy Policy.",
+    })
+    .click({ position: { x: 8, y: 8 } });
+  await expect(agreementConfirmation).toBeChecked();
+  await expect(acceptInvite).toBeEnabled();
+
+  const consentBox = await page
+    .getByTestId("invite-join-policy-notice")
+    .boundingBox();
+  const acceptButtonBox = await acceptInvite.boundingBox();
+  expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
+  expect(consentBox?.width).toBe(acceptButtonBox?.width);
+});
