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>
This commit is contained in:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-15 17:48:29 -04:00
parent 7fbdacb03f
commit a58fb065d8
21 changed files with 1412 additions and 85 deletions
+7
View File
@@ -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
+24
View File
@@ -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,
+13 -1
View File
@@ -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]
+93
View File
@@ -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
+293 -3
View File
@@ -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() {
+91
View File
@@ -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 {
@@ -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<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).
@@ -256,6 +274,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> {
@@ -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<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()
@@ -673,6 +745,7 @@ impl Config {
push_executor_key_id,
push_gateway_delivery_url,
push_gateway_timeout,
join_policy,
web_dir,
serve_git_web_gui,
})
@@ -729,6 +802,24 @@ mod tests {
);
}
#[test]
fn join_policy_age_attestation_rejects_invalid_boolean() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
if let Some(value) = previous {
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
} else {
std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
));
}
#[test]
fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
let _guard = ENV_MUTEX.lock().unwrap();
+76
View File
@@ -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());
}
}
+5
View File
@@ -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))
+14
View File
@@ -59,6 +59,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() {
@@ -67,6 +68,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),
_ => {}
}
}
@@ -78,6 +80,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
Some(serde_json::json!({
"relayUrl": relay_url,
"code": code,
"policyReceipt": policy_receipt,
}))
}
@@ -337,6 +340,17 @@ mod tests {
let payload = parse_join_deep_link(&url).expect("required params present");
assert_eq!(payload["relayUrl"], "wss://relay.example");
assert_eq!(payload["code"], "abc.def");
assert!(payload["policyReceipt"].is_null());
}
#[test]
fn parse_join_deep_link_extracts_policy_receipt() {
let url = Url::parse(
"buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value",
)
.unwrap();
let payload = parse_join_deep_link(&url).expect("required params present");
assert_eq!(payload["policyReceipt"], "receipt.value");
}
#[test]
@@ -10,7 +10,12 @@ import {
inviteErrorMessage,
isInviteExpiredError,
} from "@/shared/api/inviteHelpers";
import { claimInvite } from "@/shared/api/invites";
import {
acceptJoinPolicy,
claimInvite,
getJoinPolicy,
type JoinPolicy,
} from "@/shared/api/invites";
import { validateReposDir } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
@@ -21,6 +26,7 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
type AddCommunityDialogProps = {
open: boolean;
@@ -38,6 +44,8 @@ export function AddCommunityDialog({
const [token, setToken] = React.useState("");
const [inviteCode, setInviteCode] = React.useState("");
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [reposDir, setReposDir] = React.useState("");
const [reposDirError, setReposDirError] = React.useState<string | null>(null);
@@ -48,6 +56,8 @@ export function AddCommunityDialog({
setToken("");
setInviteCode("");
setInviteError(null);
setJoinPolicy(null);
setAgeConfirmed(false);
setReposDir("");
setReposDirError(null);
}, [onOpenChange]);
@@ -71,21 +81,45 @@ export function AddCommunityDialog({
return;
}
// If the relay handed out an invite code, claim it before saving the
// community — a closed relay would otherwise reject the connection.
const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
if (inviteCode.trim()) {
try {
await claimInvite(normalizedRelayUrl, inviteCode.trim());
} catch (error) {
const message = inviteErrorMessage(error);
setInviteError(
isInviteExpiredError(error)
? "This invite code has expired — ask for a new one."
: `Invite rejected: ${message}`,
);
try {
const policy = await getJoinPolicy(normalizedRelayUrl);
if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
setJoinPolicy(policy);
setAgeConfirmed(false);
setInviteError("Review this relay's join policy below.");
return;
}
if (policy?.ageAttestationRequired && !ageConfirmed) {
setInviteError("Confirm that you are at least 18 years old.");
return;
}
// If the relay handed out an invite code, claim it before saving the
// community — a closed relay would otherwise reject the connection.
if (inviteCode.trim()) {
const policyReceipt = policy
? await acceptJoinPolicy(
normalizedRelayUrl,
inviteCode.trim(),
policy.version,
ageConfirmed,
)
: undefined;
await claimInvite(
normalizedRelayUrl,
inviteCode.trim(),
policyReceipt,
);
}
} catch (error) {
const message = inviteErrorMessage(error);
setInviteError(
isInviteExpiredError(error)
? "This invite code has expired — ask for a new one."
: `Community rejected: ${message}`,
);
return;
}
const community: Community = {
@@ -100,7 +134,17 @@ export function AddCommunityDialog({
onSubmit(community);
handleClose();
},
[name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose],
[
name,
relayUrl,
token,
inviteCode,
reposDir,
joinPolicy,
ageConfirmed,
onSubmit,
handleClose,
],
);
return (
@@ -127,7 +171,12 @@ export function AddCommunityDialog({
<Input
autoFocus
id="ws-relay-url"
onChange={(e) => setRelayUrl(e.target.value)}
onChange={(e) => {
setRelayUrl(e.target.value);
setInviteError(null);
setJoinPolicy(null);
setAgeConfirmed(false);
}}
placeholder="wss://relay.example.com"
type="text"
value={relayUrl}
@@ -184,6 +233,8 @@ export function AddCommunityDialog({
onChange={(e) => {
setInviteCode(e.target.value);
setInviteError(null);
setJoinPolicy(null);
setAgeConfirmed(false);
}}
placeholder="Paste an invite code for a members-only relay"
type="text"
@@ -192,6 +243,16 @@ export function AddCommunityDialog({
{inviteError ? (
<p className="text-xs text-destructive">{inviteError}</p>
) : null}
{joinPolicy ? (
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={(confirmed) => {
setAgeConfirmed(confirmed);
setInviteError(null);
}}
policy={joinPolicy}
/>
) : null}
</div>
<div className="flex flex-col gap-1.5">
<label
@@ -230,7 +291,13 @@ export function AddCommunityDialog({
<Button onClick={handleClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={!relayUrl.trim()} type="submit">
<Button
disabled={
!relayUrl.trim() ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
}
type="submit"
>
Add Community
</Button>
</div>
@@ -1,7 +1,10 @@
import * as React from "react";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
export type CommunityEditFormProps = {
@@ -9,6 +12,7 @@ export type CommunityEditFormProps = {
initialName: string;
initialRelayUrl: string;
isSubmitting?: boolean;
joinPolicyRequired?: boolean;
onCancel: () => void;
onSubmit: (name: string, relayUrl: string) => void;
submitLabel: string;
@@ -19,6 +23,7 @@ export function CommunityEditForm({
initialName,
initialRelayUrl,
isSubmitting = false,
joinPolicyRequired = false,
onCancel,
onSubmit,
submitLabel,
@@ -29,6 +34,11 @@ export function CommunityEditForm({
const [probeWarning, setProbeWarning] = React.useState<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 cancelRef = React.useRef<(() => void) | null>(null);
@@ -52,6 +62,33 @@ export function CommunityEditForm({
return;
}
if (joinPolicyRequired) {
try {
const policy = await getJoinPolicy(normalizedUrl);
if (!policy) {
onSubmit(trimmedName, normalizedUrl);
return;
}
if (
!joinPolicy ||
joinPolicy.version !== policy.version ||
policyRelayUrl !== normalizedUrl
) {
setJoinPolicy(policy);
setPolicyRelayUrl(normalizedUrl);
setAgeConfirmed(false);
return;
}
if (policy.ageAttestationRequired && !ageConfirmed) {
setError("Confirm that you are at least 18 years old.");
return;
}
} catch (policyError) {
setError(inviteErrorMessage(policyError));
return;
}
}
if (useAnywayOverride) {
onSubmit(trimmedName, normalizedUrl);
return;
@@ -79,7 +116,16 @@ export function CommunityEditForm({
onSubmit(trimmedName, normalizedUrl);
},
[name, onSubmit, relayUrl, useAnywayOverride],
[
ageConfirmed,
joinPolicy,
joinPolicyRequired,
name,
onSubmit,
policyRelayUrl,
relayUrl,
useAnywayOverride,
],
);
const handleUseAnyway = React.useCallback(() => {
@@ -139,6 +185,9 @@ export function CommunityEditForm({
setError(null);
setProbeWarning(null);
setUseAnywayOverride(false);
setJoinPolicy(null);
setPolicyRelayUrl(null);
setAgeConfirmed(false);
}}
placeholder="wss://relay.example.com"
type="text"
@@ -146,10 +195,26 @@ export function CommunityEditForm({
/>
</div>
{joinPolicy ? (
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={(confirmed) => {
setAgeConfirmed(confirmed);
setError(null);
}}
policy={joinPolicy}
/>
) : null}
<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)
}
type="submit"
>
{isProbing ? (
@@ -5,9 +5,14 @@ import {
getIdentity,
importIdentity as tauriImportIdentity,
} from "@/shared/api/tauriIdentity";
import { claimInvite } from "@/shared/api/invites";
import {
claimInvite,
getJoinPolicy,
type JoinPolicy,
} from "@/shared/api/invites";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
import {
type OnboardingTransitionDirection,
@@ -48,13 +53,19 @@ function wait(ms: number) {
}
function NostrKeyImportPage({
ageConfirmed,
connectionError,
disabled,
joinPolicy,
onAgeConfirmedChange,
onBack,
onImport,
}: {
ageConfirmed: boolean;
connectionError: string | null;
disabled: boolean;
joinPolicy: JoinPolicy | null | undefined;
onAgeConfirmedChange: (confirmed: boolean) => void;
onBack: () => void;
onImport: (nsec: string) => Promise<void>;
}) {
@@ -75,8 +86,22 @@ function NostrKeyImportPage({
</p>
</div>
{joinPolicy ? (
<div className="mt-6 w-full">
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={onAgeConfirmedChange}
policy={joinPolicy}
/>
</div>
) : null}
<NostrKeyImportForm
disabled={disabled}
disabled={
disabled ||
joinPolicy === undefined ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
}
errorMessage={connectionError}
onBack={onBack}
onImport={onImport}
@@ -97,8 +122,36 @@ export function WelcomeSetup({
const [error, setError] = React.useState<string | null>(null);
const [isRedeeming, setIsRedeeming] = React.useState(false);
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [defaultJoinPolicy, setDefaultJoinPolicy] = React.useState<
JoinPolicy | null | undefined
>(undefined);
const [defaultAgeConfirmed, setDefaultAgeConfirmed] = React.useState(false);
const systemColorScheme = useSystemColorScheme();
React.useEffect(() => {
let cancelled = false;
setDefaultAgeConfirmed(false);
if (isLocalDevRelayUrl(defaultRelayUrl)) {
setDefaultJoinPolicy(null);
return;
}
setDefaultJoinPolicy(undefined);
void getJoinPolicy(defaultRelayUrl)
.then((policy) => {
if (!cancelled) {
setDefaultJoinPolicy(policy);
}
})
.catch((policyError) => {
if (!cancelled) {
setError(inviteErrorMessage(policyError));
}
});
return () => {
cancelled = true;
};
}, [defaultRelayUrl]);
const handleConnect = React.useCallback(
async (relayUrl: string, communityName?: string, pubkey?: string) => {
const trimmedUrl = relayUrl.trim();
@@ -159,11 +212,11 @@ export function WelcomeSetup({
);
const handleWelcomeInviteRedeem = React.useCallback(
async (relayWsUrl: string, code: string) => {
async (relayWsUrl: string, code: string, policyReceipt?: string) => {
setIsRedeeming(true);
setInviteError(null);
try {
await claimInvite(relayWsUrl, code);
await claimInvite(relayWsUrl, code, policyReceipt);
await handleConnect(relayWsUrl);
} catch (err) {
setInviteError(inviteErrorMessage(err));
@@ -249,10 +302,24 @@ export function WelcomeSetup({
</p>
<div className="mt-8 flex w-full flex-col gap-3">
{defaultJoinPolicy ? (
<JoinPolicyNotice
ageConfirmed={defaultAgeConfirmed}
onAgeConfirmedChange={setDefaultAgeConfirmed}
policy={defaultJoinPolicy}
/>
) : null}
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
<Button
className="h-10 w-full"
aria-disabled={isConnecting}
disabled={
defaultJoinPolicy === undefined ||
Boolean(
defaultJoinPolicy?.ageAttestationRequired &&
!defaultAgeConfirmed,
)
}
onClick={() => {
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,
);
}}
/>
</div>
</OnboardingSlideTransition>
) : (
<NostrKeyImportPage
ageConfirmed={defaultAgeConfirmed}
connectionError={error}
disabled={isConnecting}
joinPolicy={defaultJoinPolicy}
onAgeConfirmedChange={setDefaultAgeConfirmed}
onBack={showWelcomePage}
onImport={handleNostrImport}
/>
@@ -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<JoinPolicy | null>(null);
const [policyInvite, setPolicyInvite] = React.useState<{
relayWsUrl: string;
code: string;
} | null>(null);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [policyError, setPolicyError] = React.useState<string | null>(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({
</div>
) : null}
{joinPolicy ? (
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
onAgeConfirmedChange={(confirmed) => {
setAgeConfirmed(confirmed);
setPolicyError(null);
}}
policy={joinPolicy}
/>
) : 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}
@@ -109,11 +197,21 @@ export function InviteRedeemForm({
<Button
className="h-10 w-full"
data-testid="invite-redeem-submit"
disabled={!canSubmit || isRedeeming}
disabled={
!canSubmit ||
isRedeeming ||
isLoadingPolicy ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
}
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"
)}
@@ -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 (
<div className="space-y-3 rounded-md border p-3 text-left text-sm">
{policy.ageAttestationRequired ? (
<label className="flex items-start gap-2">
<input
checked={ageConfirmed}
className="mt-1"
onChange={(event) => onAgeConfirmedChange(event.target.checked)}
type="checkbox"
/>
<span>I am 18 years of age or older.</span>
</label>
) : null}
{policy.termsMarkdown || policy.privacyMarkdown ? (
<p className="text-muted-foreground">
By proceeding you agree to the Buzz{" "}
{policy.termsMarkdown ? (
<Button
className="h-auto p-0 align-baseline"
onClick={() => setOpenDocument("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"
onClick={() => setOpenDocument("privacy")}
type="button"
variant="link"
>
Privacy Policy
</Button>
) : null}
.
</p>
) : null}
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDocument(null);
}}
open={openDocument !== null}
>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
</DialogTitle>
</DialogHeader>
{markdown ? <Markdown content={markdown} /> : null}
</DialogContent>
</Dialog>
</div>
);
}
@@ -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 ? (
+54
View File
@@ -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/),
);
});
+60 -1
View File
@@ -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<T>(
return json as T;
}
/** 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();
@@ -103,9 +161,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;
+3 -2
View File
@@ -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<UnlistenFn> {
});
const joinPromise = listen<JoinDeepLinkPayload>("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(
+57
View File
@@ -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
@@ -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
);
+175 -37
View File
@@ -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>
);
}