From a58fb065d80dfa6006ee06c1bc1f2b2f662a0623 Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
<5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 15 Jul 2026 17:48:29 -0400
Subject: [PATCH] Gate invite acceptance on join policy
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
.env.example | 7 +
crates/buzz-db/src/lib.rs | 24 ++
crates/buzz-db/src/migration.rs | 14 +-
crates/buzz-db/src/relay_members.rs | 93 ++++++
crates/buzz-relay/src/api/invites.rs | 296 +++++++++++++++++-
crates/buzz-relay/src/config.rs | 91 ++++++
crates/buzz-relay/src/invite_token.rs | 76 +++++
crates/buzz-relay/src/router.rs | 5 +
desktop/src-tauri/src/deep_link.rs | 14 +
.../communities/ui/AddCommunityDialog.tsx | 99 +++++-
.../communities/ui/CommunityEditForm.tsx | 69 +++-
.../features/communities/ui/WelcomeSetup.tsx | 87 ++++-
.../onboarding/ui/InviteRedeemForm.tsx | 124 +++++++-
.../onboarding/ui/JoinPolicyNotice.tsx | 89 ++++++
.../onboarding/ui/MembershipDenied.tsx | 8 +-
desktop/src/shared/api/invites.test.mjs | 54 ++++
desktop/src/shared/api/invites.ts | 61 +++-
desktop/src/shared/deep-link.ts | 5 +-
desktop/tests/e2e/onboarding.spec.ts | 57 ++++
migrations/0020_join_policy_acceptances.sql | 12 +
web/src/features/invite/ui/InvitePage.tsx | 212 ++++++++++---
21 files changed, 1412 insertions(+), 85 deletions(-)
create mode 100644 desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
create mode 100644 desktop/src/shared/api/invites.test.mjs
create mode 100644 migrations/0020_join_policy_acceptances.sql
diff --git a/.env.example b/.env.example
index 696d3a061..16b47d0a9 100644
--- a/.env.example
+++ b/.env.example
@@ -197,3 +197,10 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
# These are accepted for backward compatibility but the canonical names above
# are preferred:
# BUZZ_ACP_PRIVATE_KEY → BUZZ_PRIVATE_KEY
+
+# Optional relay join policy. Markdown is served by the relay so every join
+# surface can present the same documents. Each document and the independent age
+# attestation are optional; configuring any one enables policy acceptance.
+# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
+# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
+# BUZZ_AGE_ATTESTATION_REQUIRED=true
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index 07c67a824..5e20c267e 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -2689,6 +2689,30 @@ impl Db {
relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
}
+ /// Claims relay membership via an invite and atomically persists the
+ /// accepted policy version when a policy is configured.
+ pub async fn claim_relay_membership(
+ &self,
+ community: CommunityId,
+ pubkey: &str,
+ role: &str,
+ policy_version: Option<&str>,
+ ) -> Result {
+ relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version)
+ .await
+ }
+
+ /// Returns whether a member has persisted acceptance evidence for a policy version.
+ pub async fn has_join_policy_acceptance(
+ &self,
+ community: CommunityId,
+ pubkey: &str,
+ policy_version: &str,
+ ) -> Result {
+ relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version)
+ .await
+ }
+
/// Removes a relay member from `community` atomically, refusing to delete the owner.
pub async fn remove_relay_member(
&self,
diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index 1d8c961a2..eb4086024 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -549,7 +549,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
- assert_eq!(migrations.len(), 19);
+ assert_eq!(migrations.len(), 20);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -812,6 +812,18 @@ mod tests {
.sql
.as_str()
.contains("purge_soft_deleted_buzz_mesh_status"));
+
+ // Join policy acceptances landed concurrently with mesh status retention;
+ // keep both additive migrations in a single, unambiguous sequence.
+ assert_eq!(migrations[19].version, 20);
+ assert!(migrations[19]
+ .sql
+ .as_str()
+ .contains("CREATE TABLE join_policy_acceptances"));
+ assert!(!migrations[0]
+ .sql
+ .as_str()
+ .contains("join_policy_acceptances"));
}
#[test]
diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs
index d354b057b..3bd114fda 100644
--- a/crates/buzz-db/src/relay_members.rs
+++ b/crates/buzz-db/src/relay_members.rs
@@ -114,6 +114,67 @@ pub async fn add_relay_member(
Ok(result.rows_affected() > 0)
}
+/// Claims relay membership via an invite and atomically persists policy evidence.
+///
+/// Returns `true` when membership was inserted, or `false` when the pubkey was
+/// already a member. A configured `policy_version` is recorded in the same
+/// transaction, so membership cannot be granted without its acceptance record.
+pub async fn claim_relay_membership(
+ pool: &PgPool,
+ community: CommunityId,
+ pubkey: &str,
+ role: &str,
+ policy_version: Option<&str>,
+) -> Result {
+ let mut tx = pool.begin().await?;
+ let inserted = sqlx::query(
+ "INSERT INTO relay_members (community_id, pubkey, role, added_by) \
+ VALUES ($1, $2, $3, 'invite') \
+ ON CONFLICT (community_id, pubkey) DO NOTHING",
+ )
+ .bind(community.as_uuid())
+ .bind(pubkey)
+ .bind(role)
+ .execute(&mut *tx)
+ .await?
+ .rows_affected()
+ > 0;
+
+ if let Some(version) = policy_version {
+ sqlx::query(
+ "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
+ VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
+ )
+ .bind(community.as_uuid())
+ .bind(pubkey)
+ .bind(version)
+ .execute(&mut *tx)
+ .await?;
+ }
+
+ tx.commit().await?;
+ Ok(inserted)
+}
+
+/// Returns whether a member has persisted acceptance evidence for a policy version.
+pub async fn has_join_policy_acceptance(
+ pool: &PgPool,
+ community: CommunityId,
+ pubkey: &str,
+ policy_version: &str,
+) -> Result {
+ let row = sqlx::query(
+ "SELECT 1 FROM join_policy_acceptances \
+ WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3",
+ )
+ .bind(community.as_uuid())
+ .bind(pubkey)
+ .bind(policy_version)
+ .fetch_optional(pool)
+ .await?;
+ Ok(row.is_some())
+}
+
/// The result of a relay member removal attempt.
#[derive(Debug, PartialEq)]
pub enum RemoveResult {
@@ -544,6 +605,38 @@ mod tests {
(community, owner)
}
+ #[tokio::test]
+ #[ignore = "requires Postgres"]
+ async fn invite_claim_persists_policy_version_and_legacy_claim_does_not() {
+ let pool = setup_pool().await;
+ let community = make_test_community(&pool).await;
+ let policy_member = test_pubkey();
+ let legacy_member = test_pubkey();
+ let version = "a".repeat(64);
+
+ assert!(
+ claim_relay_membership(&pool, community, &policy_member, "member", Some(&version),)
+ .await
+ .expect("claim membership with policy")
+ );
+ assert!(
+ has_join_policy_acceptance(&pool, community, &policy_member, &version)
+ .await
+ .expect("policy acceptance lookup")
+ );
+
+ assert!(
+ claim_relay_membership(&pool, community, &legacy_member, "member", None)
+ .await
+ .expect("legacy claim membership")
+ );
+ assert!(
+ !has_join_policy_acceptance(&pool, community, &legacy_member, &version)
+ .await
+ .expect("legacy acceptance lookup")
+ );
+ }
+
/// NIP-43 admission confinement: a pubkey admitted to community A is *not*
/// admitted to community B. This is the exact mutation #1285 targets — a
/// `WHERE pubkey = $1` membership check (no community predicate) would let an
diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs
index 3b09e2a38..7d004c08c 100644
--- a/crates/buzz-relay/src/api/invites.rs
+++ b/crates/buzz-relay/src/api/invites.rs
@@ -54,6 +54,66 @@ pub struct MintInviteRequest {
pub struct ClaimInviteRequest {
/// The invite code to redeem.
pub code: String,
+ /// Relay-issued proof of accepting the configured terms, when required.
+ #[serde(default)]
+ pub policy_receipt: Option,
+}
+
+/// Body for `POST /api/invites/accept-policy`.
+#[derive(Debug, Deserialize)]
+pub struct AcceptPolicyRequest {
+ /// Invite code the acceptance receipt will be bound to.
+ pub code: String,
+ /// Policy revision displayed by the client.
+ pub policy_version: String,
+ /// Minimum-age assertion, required only when configured by the operator.
+ #[serde(default)]
+ pub age_confirmed: bool,
+}
+
+/// Public join policy shared by every client-side join surface.
+pub async fn join_policy(State(state): State>) -> Json {
+ match &state.config.join_policy {
+ Some(policy) => Json(serde_json::json!({
+ "policy": {
+ "terms_markdown": policy.terms_markdown,
+ "privacy_markdown": policy.privacy_markdown,
+ "age_attestation_required": policy.age_attestation_required,
+ "version": policy.version
+ }
+ })),
+ None => Json(serde_json::json!({})),
+ }
+}
+
+/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
+pub async fn accept_policy(
+ State(state): State>,
+ body: axum::body::Bytes,
+) -> Result, (StatusCode, Json)> {
+ let Some(policy) = &state.config.join_policy else {
+ return Err(api_error(
+ StatusCode::NOT_FOUND,
+ "join_policy_not_configured",
+ ));
+ };
+ let request: AcceptPolicyRequest = serde_json::from_slice(&body).map_err(|e| {
+ api_error(
+ StatusCode::BAD_REQUEST,
+ &format!("invalid policy acceptance JSON: {e}"),
+ )
+ })?;
+ if request.policy_version != policy.version
+ || (policy.age_attestation_required && !request.age_confirmed)
+ {
+ return Err(api_error(
+ StatusCode::BAD_REQUEST,
+ "join_policy_not_accepted",
+ ));
+ }
+ let key = invite_token::derive_invite_key(&state.relay_keypair);
+ let receipt = invite_token::mint_policy_acceptance(&key, &request.code, &policy.version);
+ Ok(Json(serde_json::json!({ "receipt": receipt })))
}
/// Shared prelude: bind the tenant from the Host header and verify the NIP-98
@@ -186,9 +246,27 @@ pub async fn claim_invite(
)?;
let claimer_hex = pubkey.to_hex();
+ if let Some(policy) = &state.config.join_policy {
+ let receipt = request
+ .policy_receipt
+ .as_deref()
+ .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
+ invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
+ .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
+ }
+
let was_inserted = state
.db
- .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite"))
+ .claim_relay_membership(
+ tenant.community(),
+ &claimer_hex,
+ &payload.r,
+ state
+ .config
+ .join_policy
+ .as_ref()
+ .map(|policy| policy.version.as_str()),
+ )
.await
.map_err(|e| internal_error(&format!("invite claim insert: {e}")))?;
@@ -361,14 +439,17 @@ mod tests {
/// a fresh community on `host`; returns `None` when Postgres is unavailable.
async fn invite_test_state(host: &str) -> Option> {
let mut config = crate::config::Config::from_env().ok()?;
- config.database_url = TEST_DB_URL.to_string();
+ let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
+ .or_else(|_| std::env::var("DATABASE_URL"))
+ .unwrap_or_else(|_| TEST_DB_URL.to_string());
+ config.database_url = database_url.clone();
config.redis_url = "redis://127.0.0.1:1".to_string();
config.relay_url = format!("wss://{host}");
// The claim route must work on relays where membership is enforced —
// that is the entire point of an invite.
config.require_relay_membership = true;
- let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
+ let pool = sqlx::PgPool::connect(&database_url).await.ok()?;
let db = buzz_db::Db::from_pool(pool.clone());
db.ensure_configured_community(host).await.ok()?;
@@ -505,6 +586,215 @@ mod tests {
);
}
+ #[tokio::test]
+ #[ignore = "requires Postgres"]
+ async fn join_policy_gate_end_to_end() {
+ let host = format!("invites-policy-{}.example", Uuid::new_v4().simple());
+ let owner = Keys::generate();
+ let joiner = Keys::generate();
+ let Some(state) = invite_test_state(&host).await else {
+ return;
+ };
+ // Force the join policy on regardless of env.
+ let mut state_inner = (*state).clone();
+ let mut config = state_inner.config.as_ref().clone();
+ config.join_policy = Some(crate::config::JoinPolicyConfig {
+ terms_markdown: Some("# Terms".to_string()),
+ privacy_markdown: Some("# Privacy".to_string()),
+ age_attestation_required: true,
+ version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
+ });
+ state_inner.config = Arc::new(config);
+ let state = Arc::new(state_inner);
+
+ let community = state
+ .db
+ .lookup_community_by_host(&host)
+ .await
+ .expect("lookup")
+ .expect("community exists");
+ state
+ .db
+ .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
+ .await
+ .expect("seed owner");
+
+ // Mint an invite.
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites",
+ &owner,
+ "{}".to_string(),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ let json = read_json(response).await;
+ let code = json
+ .get("code")
+ .and_then(Value::as_str)
+ .expect("code")
+ .to_string();
+
+ // 1. Claim WITHOUT receipt -> 403 (checkbox bypass).
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/claim",
+ &joiner,
+ serde_json::json!({ "code": code }).to_string(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::FORBIDDEN,
+ "no-receipt claim must fail"
+ );
+
+ // 2. Forged receipt (wrong key) -> 403.
+ let forged = crate::invite_token::mint_policy_acceptance(
+ &[9u8; 32],
+ &code,
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ );
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/claim",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_receipt": forged }).to_string(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::FORBIDDEN,
+ "forged receipt must fail"
+ );
+
+ // 3. Receipt bound to a DIFFERENT invite code -> 403.
+ let key = crate::invite_token::derive_invite_key(&state.relay_keypair);
+ let other = crate::invite_token::mint_policy_acceptance(
+ &key,
+ "some-other-code",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ );
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/claim",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_receipt": other }).to_string(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::FORBIDDEN,
+ "cross-invite receipt must fail"
+ );
+
+ // 4. Receipt for a STALE policy version -> 403.
+ let stale = crate::invite_token::mint_policy_acceptance(
+ &key,
+ &code,
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ );
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/claim",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_receipt": stale }).to_string(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::FORBIDDEN,
+ "stale-version receipt must fail"
+ );
+
+ // 5. accept-policy without age confirmation -> 400.
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/accept-policy",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": false })
+ .to_string(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::BAD_REQUEST,
+ "age not confirmed must be rejected when required"
+ );
+
+ // 5b. accept-policy with stale version -> 400.
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/accept-policy",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_version": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "age_confirmed": true })
+ .to_string(),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+ // 6. Legit flow: accept-policy -> receipt -> claim OK.
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/accept-policy",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": true })
+ .to_string(),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ let receipt = read_json(response)
+ .await
+ .get("receipt")
+ .and_then(Value::as_str)
+ .expect("receipt")
+ .to_string();
+
+ let response = post_json(
+ state.clone(),
+ &host,
+ "/api/invites/claim",
+ &joiner,
+ serde_json::json!({ "code": code, "policy_receipt": receipt }).to_string(),
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::OK,
+ "legit receipt claim must succeed"
+ );
+ let json = read_json(response).await;
+ assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
+
+ let member = state
+ .db
+ .get_relay_member(community.id, &joiner.public_key().to_hex())
+ .await
+ .expect("member lookup")
+ .expect("joiner is now a member");
+ assert_eq!(member.role, "member");
+ assert!(
+ state
+ .db
+ .has_join_policy_acceptance(
+ community.id,
+ &joiner.public_key().to_hex(),
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ )
+ .await
+ .expect("policy acceptance lookup"),
+ "accepted policy version must be persisted",
+ );
+ }
+
#[tokio::test]
#[ignore = "requires Postgres"]
async fn non_admin_cannot_mint() {
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index d72654dbf..b9a876bc0 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -3,6 +3,7 @@
use std::net::SocketAddr;
use std::time::Duration;
+use sha2::{Digest, Sha256};
use thiserror::Error;
use tracing::warn;
@@ -23,6 +24,19 @@ pub enum ConfigError {
InvalidValue(String),
}
+/// Relay-hosted policy content presented on join surfaces.
+#[derive(Debug, Clone)]
+pub struct JoinPolicyConfig {
+ /// Operator-provided Terms of Service document in Markdown.
+ pub terms_markdown: Option,
+ /// Operator-provided Privacy Policy document in Markdown.
+ pub privacy_markdown: Option,
+ /// Whether join surfaces must collect an 18+ attestation.
+ pub age_attestation_required: bool,
+ /// Content-derived identifier binding receipts to the exact policy revision.
+ pub version: String,
+}
+
/// Relay runtime configuration, loaded from environment variables.
#[derive(Debug, Clone)]
pub struct Config {
@@ -197,6 +211,10 @@ pub struct Config {
/// Hard timeout for one gateway delivery request.
pub push_gateway_timeout: Duration,
+ /// Optional relay-hosted policy shown on join surfaces. Disabled when no
+ /// documents or age attestation are configured.
+ pub join_policy: Option,
+
/// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the invite landing page and its static assets.
/// When unset, no static file serving happens (relay behaves as before).
@@ -256,6 +274,22 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result {
Ok(url)
}
+fn parse_optional_bool(name: &str) -> Result {
+ match std::env::var(name) {
+ Err(std::env::VarError::NotPresent) => Ok(false),
+ Err(error) => Err(ConfigError::InvalidValue(format!(
+ "{name} must be valid UTF-8: {error}"
+ ))),
+ Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
+ "true" | "1" | "on" => Ok(true),
+ "false" | "0" | "off" | "" => Ok(false),
+ _ => Err(ConfigError::InvalidValue(format!(
+ "{name} must be true or false"
+ ))),
+ },
+ }
+}
+
fn ensure_git_repo_path(
raw: impl Into,
) -> Result {
@@ -602,6 +636,44 @@ impl Config {
};
let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
+ const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024;
+ let read_policy_markdown = |name: &str| -> Result
+ {defaultJoinPolicy ? (
+
+ ) : null}
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
{
if (isConnecting) {
return;
@@ -341,6 +408,7 @@ export function WelcomeSetup({
initialName=""
initialRelayUrl=""
isSubmitting={isConnecting}
+ joinPolicyRequired
onCancel={showWelcomePage}
onSubmit={(name, url) => {
void handleConnect(url, name);
@@ -380,16 +448,23 @@ export function WelcomeSetup({
error={inviteError}
isRedeeming={isRedeeming}
onCancel={showWelcomePage}
- onRedeem={(relayWsUrl, code) => {
- void handleWelcomeInviteRedeem(relayWsUrl, code);
+ onRedeem={(relayWsUrl, code, policyReceipt) => {
+ void handleWelcomeInviteRedeem(
+ relayWsUrl,
+ code,
+ policyReceipt,
+ );
}}
/>
) : (
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index 5e8f36a42..e6541eafc 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -1,9 +1,18 @@
import * as React from "react";
-import { parseInviteInput } from "@/shared/api/inviteHelpers";
+import {
+ inviteErrorMessage,
+ parseInviteInput,
+} from "@/shared/api/inviteHelpers";
+import {
+ acceptJoinPolicy,
+ getJoinPolicy,
+ type JoinPolicy,
+} from "@/shared/api/invites";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
+import { JoinPolicyNotice } from "./JoinPolicyNotice";
type InviteRedeemFormProps = {
/**
@@ -16,7 +25,7 @@ type InviteRedeemFormProps = {
error: string | null;
isRedeeming: boolean;
onCancel: () => void;
- onRedeem: (relayWsUrl: string, code: string) => void;
+ onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
};
export function InviteRedeemForm({
@@ -30,6 +39,14 @@ export function InviteRedeemForm({
const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
defaultRelayUrl ?? "",
);
+ const [joinPolicy, setJoinPolicy] = React.useState(null);
+ const [policyInvite, setPolicyInvite] = React.useState<{
+ relayWsUrl: string;
+ code: string;
+ } | null>(null);
+ const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+ const [policyError, setPolicyError] = React.useState(null);
+ const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
const parsed = React.useMemo(
() => parseInviteInput(inviteInput),
@@ -44,17 +61,61 @@ export function InviteRedeemForm({
(isBareCode && bareCodeRelayUrl.trim().length > 0));
const handleSubmit = React.useCallback(
- (event: React.FormEvent) => {
+ async (event: React.FormEvent) => {
event.preventDefault();
if (!parsed) return;
- if ("relayWsUrl" in parsed) {
- onRedeem(parsed.relayWsUrl, parsed.code);
- } else if (bareCodeRelayUrl.trim()) {
- onRedeem(bareCodeRelayUrl.trim(), parsed.code);
+ const relayWsUrl =
+ "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+ if (!relayWsUrl) return;
+
+ setPolicyError(null);
+ setIsLoadingPolicy(true);
+ try {
+ const policy = await getJoinPolicy(relayWsUrl);
+ if (!policy) {
+ onRedeem(relayWsUrl, parsed.code);
+ return;
+ }
+
+ if (
+ !joinPolicy ||
+ joinPolicy.version !== policy.version ||
+ policyInvite?.relayWsUrl !== relayWsUrl ||
+ policyInvite.code !== parsed.code
+ ) {
+ setJoinPolicy(policy);
+ setPolicyInvite({ relayWsUrl, code: parsed.code });
+ setAgeConfirmed(false);
+ return;
+ }
+
+ if (policy.ageAttestationRequired && !ageConfirmed) {
+ setPolicyError("Confirm that you are at least 18 years old.");
+ return;
+ }
+
+ const receipt = await acceptJoinPolicy(
+ relayWsUrl,
+ parsed.code,
+ policy.version,
+ ageConfirmed,
+ );
+ onRedeem(relayWsUrl, parsed.code, receipt);
+ } catch (policyFetchError) {
+ setPolicyError(inviteErrorMessage(policyFetchError));
+ } finally {
+ setIsLoadingPolicy(false);
}
},
- [bareCodeRelayUrl, onRedeem, parsed],
+ [
+ ageConfirmed,
+ bareCodeRelayUrl,
+ joinPolicy,
+ onRedeem,
+ parsed,
+ policyInvite,
+ ],
);
return (
@@ -74,7 +135,13 @@ export function InviteRedeemForm({
data-testid="invite-redeem-input"
disabled={isRedeeming}
id="invite-input"
- onChange={(event) => setInviteInput(event.target.value)}
+ onChange={(event) => {
+ setInviteInput(event.target.value);
+ setJoinPolicy(null);
+ setPolicyInvite(null);
+ setAgeConfirmed(false);
+ setPolicyError(null);
+ }}
placeholder="https://relay.example.com/invite/abc123 or paste a code"
spellCheck={false}
type="text"
@@ -94,7 +161,13 @@ export function InviteRedeemForm({
className="h-10 bg-background"
disabled={isRedeeming}
id="invite-relay-url"
- onChange={(event) => setBareCodeRelayUrl(event.target.value)}
+ onChange={(event) => {
+ setBareCodeRelayUrl(event.target.value);
+ setJoinPolicy(null);
+ setPolicyInvite(null);
+ setAgeConfirmed(false);
+ setPolicyError(null);
+ }}
placeholder="wss://relay.example.com"
type="text"
value={bareCodeRelayUrl}
@@ -102,6 +175,21 @@ export function InviteRedeemForm({
) : null}
+ {joinPolicy ? (
+ {
+ setAgeConfirmed(confirmed);
+ setPolicyError(null);
+ }}
+ policy={joinPolicy}
+ />
+ ) : null}
+
+ {policyError ? (
+ {policyError}
+ ) : null}
+
{error ? (
{error}
) : null}
@@ -109,11 +197,21 @@ export function InviteRedeemForm({
- {isRedeeming ? (
-
+ {isRedeeming || isLoadingPolicy ? (
+
+ ) : 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 000000000..3cbfb18e7
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
@@ -0,0 +1,89 @@
+import * as React from "react";
+
+import type { JoinPolicy } from "@/shared/api/invites";
+import { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/dialog";
+import { Markdown } from "@/shared/ui/markdown";
+
+type JoinPolicyNoticeProps = {
+ ageConfirmed: boolean;
+ onAgeConfirmedChange: (confirmed: boolean) => void;
+ policy: JoinPolicy;
+};
+
+export function JoinPolicyNotice({
+ ageConfirmed,
+ onAgeConfirmedChange,
+ policy,
+}: JoinPolicyNoticeProps) {
+ const [openDocument, setOpenDocument] = React.useState<
+ "terms" | "privacy" | null
+ >(null);
+ const markdown =
+ openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
+
+ return (
+
+ {policy.ageAttestationRequired ? (
+
+ ) : null}
+
+ {policy.termsMarkdown || policy.privacyMarkdown ? (
+
+ By proceeding you agree to the Buzz{" "}
+ {policy.termsMarkdown ? (
+ setOpenDocument("terms")}
+ type="button"
+ variant="link"
+ >
+ Terms of Service
+
+ ) : null}
+ {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
+ {policy.privacyMarkdown ? (
+ setOpenDocument("privacy")}
+ type="button"
+ variant="link"
+ >
+ Privacy Policy
+
+ ) : null}
+ .
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
index bca564fbd..51745f22a 100644
--- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx
+++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx
@@ -89,11 +89,11 @@ export function MembershipDenied({
}, [onImportKey, previewNpub, trimmedNsec]);
const handleInviteRedeem = React.useCallback(
- async (relayWsUrl: string, code: string) => {
+ async (relayWsUrl: string, code: string, policyReceipt?: string) => {
setIsRedeeming(true);
setInviteError(null);
try {
- await claimInvite(relayWsUrl, code);
+ await claimInvite(relayWsUrl, code, policyReceipt);
onInviteRedeemed(relayWsUrl);
} catch (error) {
setInviteError(inviteErrorMessage(error));
@@ -170,8 +170,8 @@ export function MembershipDenied({
setInviteError(null);
setIsInviteFormOpen(false);
}}
- onRedeem={(relayWsUrl, code) => {
- void handleInviteRedeem(relayWsUrl, code);
+ onRedeem={(relayWsUrl, code, policyReceipt) => {
+ void handleInviteRedeem(relayWsUrl, code, policyReceipt);
}}
/>
) : isImportFormOpen ? (
diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs
new file mode 100644
index 000000000..eb4ca3dad
--- /dev/null
+++ b/desktop/src/shared/api/invites.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getJoinPolicy } from "./invites.ts";
+
+function withFetch(response, run) {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (url) => {
+ assert.equal(url, "https://relay.example/api/join-policy");
+ return response;
+ };
+ return Promise.resolve(run()).finally(() => {
+ globalThis.fetch = originalFetch;
+ });
+}
+
+test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () => {
+ await withFetch(
+ new Response(
+ JSON.stringify({
+ policy: {
+ terms_markdown: "# Terms",
+ privacy_markdown: "# Privacy",
+ age_attestation_required: true,
+ version: "policy-v1",
+ },
+ }),
+ { status: 200 },
+ ),
+ async () => {
+ assert.deepEqual(await getJoinPolicy("wss://relay.example"), {
+ termsMarkdown: "# Terms",
+ privacyMarkdown: "# Privacy",
+ ageAttestationRequired: true,
+ version: "policy-v1",
+ });
+ },
+ );
+});
+
+test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => {
+ await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () =>
+ assert.equal(await getJoinPolicy("wss://relay.example"), null),
+ );
+ await withFetch(new Response(null, { status: 404 }), async () =>
+ assert.equal(await getJoinPolicy("wss://relay.example"), null),
+ );
+});
+
+test("getJoinPolicy fails closed on a policy endpoint error", async () => {
+ await withFetch(new Response(null, { status: 503 }), async () =>
+ assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
+ );
+});
diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts
index f0161d620..d9e6845cc 100644
--- a/desktop/src/shared/api/invites.ts
+++ b/desktop/src/shared/api/invites.ts
@@ -18,6 +18,13 @@ export type MintedInvite = {
url: string;
};
+export type JoinPolicy = {
+ termsMarkdown?: string;
+ privacyMarkdown?: string;
+ ageAttestationRequired: boolean;
+ version: string;
+};
+
export type ClaimResult = {
status: "joined" | "already_member";
communityId: string;
@@ -84,6 +91,57 @@ async function invitePost(
return json as T;
}
+/** Fetch relay-hosted policy content for any join surface. */
+export async function getJoinPolicy(
+ relayWsUrl: string,
+): Promise {
+ const base = relayHttpFromWs(relayWsUrl);
+ const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
+ // Relays predating join-policy support have no configured policy.
+ if (response.status === 404) return null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const raw = (await response.json()) as {
+ policy?: {
+ terms_markdown?: string;
+ privacy_markdown?: string;
+ age_attestation_required: boolean;
+ version: string;
+ };
+ };
+ return raw.policy
+ ? {
+ termsMarkdown: raw.policy.terms_markdown,
+ privacyMarkdown: raw.policy.privacy_markdown,
+ ageAttestationRequired: raw.policy.age_attestation_required,
+ version: raw.policy.version,
+ }
+ : null;
+}
+
+/** Accept the current join policy for an invite and receive a bound receipt. */
+export async function acceptJoinPolicy(
+ relayWsUrl: string,
+ code: string,
+ policyVersion: string,
+ ageConfirmed: boolean,
+): Promise {
+ const base = relayHttpFromWs(relayWsUrl);
+ const response = await fetch(
+ `${base.replace(/\/+$/, "")}/api/invites/accept-policy`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ policy_version: policyVersion,
+ age_confirmed: ageConfirmed,
+ }),
+ },
+ );
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return ((await response.json()) as { receipt: string }).receipt;
+}
+
/** Mint an invite code on the active community's relay (owner/admin only). */
export async function mintInvite(ttlSecs?: number): Promise {
const base = await getRelayHttpUrl();
@@ -103,9 +161,10 @@ export async function mintInvite(ttlSecs?: number): Promise {
export async function claimInvite(
relayWsUrl: string,
code: string,
+ policyReceipt?: string,
): Promise {
const base = relayHttpFromWs(relayWsUrl);
- const body = JSON.stringify({ code });
+ const body = JSON.stringify({ code, policy_receipt: policyReceipt });
const raw = await invitePost<{
status: "joined" | "already_member";
community_id: string;
diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts
index 193633857..96a702f70 100644
--- a/desktop/src/shared/deep-link.ts
+++ b/desktop/src/shared/deep-link.ts
@@ -49,6 +49,7 @@ export type NostrBindDeepLinkPayload = {
export type JoinDeepLinkPayload = {
relayUrl: string;
code: string;
+ policyReceipt: string | null;
};
/**
@@ -90,8 +91,8 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise {
});
const joinPromise = listen("deep-link-join", (event) => {
- const { relayUrl, code } = event.payload;
- void claimInvite(relayUrl, code)
+ const { relayUrl, code, policyReceipt } = event.payload;
+ void claimInvite(relayUrl, code, policyReceipt ?? undefined)
.then((result) => {
const name = addAndSwitch(relayUrl);
toast.success(
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 6ee97dba4..481cba11d 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -568,6 +568,16 @@ test("first-run default community handoff gives immediate stepper feedback", asy
skipCommunitySeed: true,
},
);
+ await page.route(
+ "https://default.example.com/api/join-policy",
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: "{}",
+ });
+ },
+ );
await page.goto("/");
await expect(page.getByText("Welcome to Buzz")).toBeVisible();
@@ -609,6 +619,16 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => {
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
+ await page.route(
+ "https://default.example.com/api/join-policy",
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: "{}",
+ });
+ },
+ );
await page.goto("/");
await page.getByTestId("welcome-continue-nostr").click();
@@ -1824,7 +1844,41 @@ test("denied on relay A then paste relay B invite URL switches community to B",
// Intercept the claimInvite POST to relay B so it succeeds.
const relayBUrl = "wss://relay-b.example.com";
const relayBHttpUrl = "https://relay-b.example.com";
+ const policyReceipt = "relay-signed-policy-receipt";
+ await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ policy: {
+ terms_markdown: "# Terms",
+ privacy_markdown: "# Privacy",
+ age_attestation_required: true,
+ version: "policy-v1",
+ },
+ }),
+ });
+ });
+ await page.route(
+ `${relayBHttpUrl}/api/invites/accept-policy`,
+ async (route) => {
+ expect(route.request().postDataJSON()).toEqual({
+ code: "test-invite-code",
+ policy_version: "policy-v1",
+ age_confirmed: true,
+ });
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ receipt: policyReceipt }),
+ });
+ },
+ );
await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
+ expect(route.request().postDataJSON()).toMatchObject({
+ code: "test-invite-code",
+ policy_receipt: policyReceipt,
+ });
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -1843,6 +1897,9 @@ test("denied on relay A then paste relay B invite URL switches community to B",
.getByTestId("invite-redeem-input")
.fill(`${relayBHttpUrl}/invite/test-invite-code`);
await page.getByTestId("invite-redeem-submit").click();
+ await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
+ await page.getByLabel("I am 18 years of age or older.").check();
+ await page.getByTestId("invite-redeem-submit").click();
// After successful claim, the community should switch to relay B's URL.
await expect
diff --git a/migrations/0020_join_policy_acceptances.sql b/migrations/0020_join_policy_acceptances.sql
new file mode 100644
index 000000000..061d4670f
--- /dev/null
+++ b/migrations/0020_join_policy_acceptances.sql
@@ -0,0 +1,12 @@
+-- Durable evidence of the policy version accepted when an invite claim grants
+-- relay membership. Rows are scoped to the same community and member identity
+-- as relay_members and are deleted with that membership.
+CREATE TABLE join_policy_acceptances (
+ community_id UUID NOT NULL,
+ pubkey TEXT NOT NULL,
+ policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
+ accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (community_id, pubkey, policy_version),
+ FOREIGN KEY (community_id, pubkey)
+ REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
+);
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 3a460a1ba..72a1d11c7 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -1,21 +1,72 @@
import buzzAppIcon from "@/assets/app-icon@3x.png";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
+import * as React from "react";
+import Markdown from "react-markdown";
+import remarkGfm from "remark-gfm";
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
+type JoinPolicy = {
+ terms_markdown?: string;
+ privacy_markdown?: string;
+ age_attestation_required: boolean;
+ version: string;
+};
-/**
- * Landing page for a community invite link (`/invite/`).
- *
- * The code is not validated here — validation happens in the desktop app when
- * the invite is claimed against `POST /api/invites/claim`, signed by the
- * joining key. This page only hands the code off via the `buzz://join` deep
- * link (or tells the visitor where to get the app first).
- */
+type PolicyDocument = { title: string; markdown: string };
+
+/** Landing page for a community invite link (`/invite/`). */
export function InvitePage({ code }: { code: string }) {
const relay = relayWsUrl();
const host = relay.replace(/^wss?:\/\//, "");
- const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
+ const [policy, setPolicy] = React.useState(
+ undefined,
+ );
+ const [document, setDocument] = React.useState(null);
+ const [ageConfirmed, setAgeConfirmed] = React.useState(false);
+ const [opening, setOpening] = React.useState(false);
+
+ React.useEffect(() => {
+ fetch("/api/join-policy")
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const config = (await response.json()) as { policy?: JoinPolicy };
+ setPolicy(config.policy ?? null);
+ })
+ .catch(() => setPolicy(undefined));
+ }, []);
+
+ const openInvite = async () => {
+ setOpening(true);
+ try {
+ let receipt: string | undefined;
+ if (policy) {
+ const response = await fetch("/api/invites/accept-policy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ policy_version: policy.version,
+ age_confirmed: ageConfirmed,
+ }),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ receipt = ((await response.json()) as { receipt: string }).receipt;
+ }
+ const query = new URLSearchParams({ relay, code });
+ if (receipt) query.set("policy_receipt", receipt);
+ window.location.href = `buzz://join?${query.toString()}`;
+ } finally {
+ setOpening(false);
+ }
+ };
+
+ const disabled =
+ policy === undefined ||
+ opening ||
+ Boolean(policy?.age_attestation_required && !ageConfirmed);
+ const showDocument = (title: string, markdown: string) =>
+ setDocument({ title, markdown });
return (
-
-
-

-
-
- You're invited to join
-
-
{host}
-
-
-
+
+
+

+
+
+ You're invited to
+
+ {host}
-
+ {policy?.age_attestation_required && (
+
+ )}
+
+ {policy && (policy.terms_markdown || policy.privacy_markdown) && (
+
+ By proceeding you agree to the Buzz{" "}
+ {policy.terms_markdown && (
+
+ showDocument(
+ "Terms of Service",
+ policy.terms_markdown ?? "",
+ )
+ }
+ >
+ Terms of Service
+
+ )}
+ {policy.terms_markdown && policy.privacy_markdown && " and "}
+ {policy.privacy_markdown && (
+
+ showDocument(
+ "Privacy Policy",
+ policy.privacy_markdown ?? "",
+ )
+ }
+ >
+ Privacy Policy
+
+ )}
+ .
+
+ )}
+
+
Don't have the app?{" "}
+
+ {document && (
+
{
+ if (event.currentTarget === event.target) setDocument(null);
+ }}
+ >
+
+
+
{document.title}
+ setDocument(null)}
+ >
+ ×
+
+
+
+
+ {document.markdown}
+
+
+
+
+ )}
);
}