Apply optional relay join policy across join flows (#1894)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: klopez4212 <klopez4212@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-16 13:59:53 -04:00
committed by GitHub
parent a1626f96ce
commit 6c2d667575
31 changed files with 2063 additions and 106 deletions

View File

@@ -202,3 +202,10 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
# These are accepted for backward compatibility but the canonical names above # These are accepted for backward compatibility but the canonical names above
# are preferred: # are preferred:
# BUZZ_ACP_PRIVATE_KEY → BUZZ_PRIVATE_KEY # 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

45
Cargo.lock generated
View File

@@ -1149,6 +1149,7 @@ dependencies = [
"opentelemetry-otlp 0.32.0", "opentelemetry-otlp 0.32.0",
"opentelemetry_sdk 0.32.1", "opentelemetry_sdk 0.32.1",
"postcard", "postcard",
"pulldown-cmark",
"rand 0.10.1", "rand 0.10.1",
"redis", "redis",
"reqwest 0.13.4", "reqwest 0.13.4",
@@ -1522,7 +1523,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2160,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [ dependencies = [
"data-encoding", "data-encoding",
"syn 1.0.109", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2371,7 +2372,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2609,7 +2610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2992,8 +2993,8 @@ dependencies = [
"libc", "libc",
"log", "log",
"rustversion", "rustversion",
"windows-link 0.1.3", "windows-link 0.2.1",
"windows-result 0.3.4", "windows-result 0.4.1",
] ]
[[package]] [[package]]
@@ -5551,7 +5552,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6735,6 +6736,24 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.0",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.29" version = "0.1.29"
@@ -6840,7 +6859,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -7487,7 +7506,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7546,7 +7565,7 @@ dependencies = [
"security-framework 3.7.0", "security-framework 3.7.0",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7809,7 +7828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -8706,7 +8725,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -10005,7 +10024,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

@@ -2689,6 +2689,30 @@ impl Db {
relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await 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. /// Removes a relay member from `community` atomically, refusing to delete the owner.
pub async fn remove_relay_member( pub async fn remove_relay_member(
&self, &self,

View File

@@ -549,7 +549,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect(); let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version); 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].version, 1);
assert_eq!(&*migrations[0].description, "initial schema"); assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0] assert!(migrations[0]
@@ -812,6 +812,18 @@ mod tests {
.sql .sql
.as_str() .as_str()
.contains("purge_soft_deleted_buzz_mesh_status")); .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] #[test]
@@ -1054,7 +1066,7 @@ mod tests {
run_migrations(&pool) run_migrations(&pool)
.await .await
.expect("retry succeeds after operator repair"); .expect("retry succeeds after operator repair");
assert_eq!(applied_versions(&pool).await.last().copied(), Some(19)); assert_eq!(applied_versions(&pool).await.last().copied(), Some(20));
} }
#[tokio::test] #[tokio::test]

View File

@@ -114,6 +114,67 @@ pub async fn add_relay_member(
Ok(result.rows_affected() > 0) 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. /// The result of a relay member removal attempt.
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum RemoveResult { pub enum RemoveResult {
@@ -544,6 +605,38 @@ mod tests {
(community, owner) (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* /// NIP-43 admission confinement: a pubkey admitted to community A is *not*
/// admitted to community B. This is the exact mutation #1285 targets — a /// admitted to community B. This is the exact mutation #1285 targets — a
/// `WHERE pubkey = $1` membership check (no community predicate) would let an /// `WHERE pubkey = $1` membership check (no community predicate) would let an

View File

@@ -77,6 +77,7 @@ moka = { workspace = true }
metrics = { workspace = true } metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true } metrics-exporter-prometheus = { workspace = true }
metrics-util = { workspace = true } metrics-util = { workspace = true }
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
[features] [features]
dev = ["buzz-auth/dev"] dev = ["buzz-auth/dev"]

View File

@@ -54,6 +54,66 @@ pub struct MintInviteRequest {
pub struct ClaimInviteRequest { pub struct ClaimInviteRequest {
/// The invite code to redeem. /// The invite code to redeem.
pub code: String, 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 /// 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(); 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 let was_inserted = state
.db .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 .await
.map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; .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. /// a fresh community on `host`; returns `None` when Postgres is unavailable.
async fn invite_test_state(host: &str) -> Option<Arc<AppState>> { async fn invite_test_state(host: &str) -> Option<Arc<AppState>> {
let mut config = crate::config::Config::from_env().ok()?; 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.redis_url = "redis://127.0.0.1:1".to_string();
config.relay_url = format!("wss://{host}"); config.relay_url = format!("wss://{host}");
// The claim route must work on relays where membership is enforced — // The claim route must work on relays where membership is enforced —
// that is the entire point of an invite. // that is the entire point of an invite.
config.require_relay_membership = true; 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()); let db = buzz_db::Db::from_pool(pool.clone());
db.ensure_configured_community(host).await.ok()?; 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] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn non_admin_cannot_mint() { async fn non_admin_cannot_mint() {

View File

@@ -3,6 +3,7 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::time::Duration; use std::time::Duration;
use sha2::{Digest, Sha256};
use thiserror::Error; use thiserror::Error;
use tracing::warn; use tracing::warn;
@@ -23,6 +24,19 @@ pub enum ConfigError {
InvalidValue(String), 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. /// Relay runtime configuration, loaded from environment variables.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
@@ -201,6 +215,10 @@ pub struct Config {
/// Hard timeout for one gateway delivery request. /// Hard timeout for one gateway delivery request.
pub push_gateway_timeout: Duration, 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. /// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the invite landing page and its static assets. /// When set, the relay serves the invite landing page and its static assets.
/// When unset, no static file serving happens (relay behaves as before). /// When unset, no static file serving happens (relay behaves as before).
@@ -260,6 +278,22 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
Ok(url) 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( fn ensure_git_repo_path(
raw: impl Into<std::path::PathBuf>, raw: impl Into<std::path::PathBuf>,
) -> Result<std::path::PathBuf, ConfigError> { ) -> Result<std::path::PathBuf, ConfigError> {
@@ -615,6 +649,44 @@ impl Config {
}; };
let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); 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 // Web UI static file serving
let web_dir = std::env::var("BUZZ_WEB_DIR") let web_dir = std::env::var("BUZZ_WEB_DIR")
.ok() .ok()
@@ -687,6 +759,7 @@ impl Config {
push_executor_key_id, push_executor_key_id,
push_gateway_delivery_url, push_gateway_delivery_url,
push_gateway_timeout, push_gateway_timeout,
join_policy,
web_dir, web_dir,
serve_git_web_gui, serve_git_web_gui,
}) })
@@ -741,12 +814,34 @@ mod tests {
!config.require_media_get_auth, !config.require_media_get_auth,
"require_media_get_auth should default to false for staged client rollout" "require_media_get_auth should default to false for staged client rollout"
); );
assert!(
config.join_policy.is_none(),
"join_policy should default to None so policy prompts and acceptance receipts are opt-in"
);
assert!( assert!(
config.huddle_audio_available, config.huddle_audio_available,
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior" "huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
); );
} }
#[test]
fn join_policy_age_attestation_rejects_invalid_boolean() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
if let Some(value) = previous {
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
} else {
std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
));
}
#[test] #[test]
fn relay_operator_pubkeys_parse_dedupe_and_normalize() { fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
let _guard = ENV_MUTEX.lock().unwrap(); let _guard = ENV_MUTEX.lock().unwrap();

View File

@@ -327,3 +327,79 @@ mod tests {
assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole)); 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());
}
}

View File

@@ -83,6 +83,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
) )
// Relay invites: mint (owner/admin) + claim (membership-gate exempt) // Relay invites: mint (owner/admin) + claim (membership-gate exempt)
.route("/api/invites", post(api::invites::mint_invite)) .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)) .route("/api/invites/claim", post(api::invites::claim_invite))
// Moderation queue reads (NIP-98 auth + mod-authz gate, L6) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
.route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/reports", get(api::bridge::moderation_reports))

View File

@@ -135,6 +135,7 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> { fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
let mut relay: Option<String> = None; let mut relay: Option<String> = None;
let mut code: Option<String> = None; let mut code: Option<String> = None;
let mut policy_receipt: Option<String> = None;
for (k, v) in url.query_pairs() { for (k, v) in url.query_pairs() {
let v = v.into_owned(); let v = v.into_owned();
if v.is_empty() { if v.is_empty() {
@@ -143,6 +144,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
match k.as_ref() { match k.as_ref() {
"relay" => relay = Some(v), "relay" => relay = Some(v),
"code" => code = Some(v), "code" => code = Some(v),
"policy_receipt" => policy_receipt = Some(v),
_ => {} _ => {}
} }
} }
@@ -154,6 +156,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
Some(serde_json::json!({ Some(serde_json::json!({
"relayUrl": relay_url, "relayUrl": relay_url,
"code": code, "code": code,
"policyReceipt": policy_receipt,
})) }))
} }
@@ -454,6 +457,17 @@ mod tests {
let payload = parse_join_deep_link(&url).expect("required params present"); let payload = parse_join_deep_link(&url).expect("required params present");
assert_eq!(payload["relayUrl"], "wss://relay.example"); assert_eq!(payload["relayUrl"], "wss://relay.example");
assert_eq!(payload["code"], "abc.def"); 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] #[test]

View File

@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { import {
deriveCommunityName, deriveCommunityName,
@@ -6,6 +7,13 @@ import {
normalizeRelayUrl, normalizeRelayUrl,
} from "@/features/communities/communityStorage"; } from "@/features/communities/communityStorage";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import {
acceptJoinPolicy,
getJoinPolicy,
isJoinPolicyDiscoveryCandidate,
type JoinPolicy,
} from "@/shared/api/invites";
import { validateReposDir } from "@/shared/api/tauri"; import { validateReposDir } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -16,6 +24,10 @@ import {
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
const POLICY_DISCOVERY_DELAY_MS = 250;
const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
type AddCommunityDialogProps = { type AddCommunityDialogProps = {
onSubmit?: ( onSubmit?: (
@@ -33,9 +45,42 @@ export function AddCommunityDialog({
const [relayUrl, setRelayUrl] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState(""); const [token, setToken] = React.useState("");
const [inviteCode, setInviteCode] = React.useState(""); const [inviteCode, setInviteCode] = React.useState("");
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
const [reposDir, setReposDir] = React.useState(""); const [reposDir, setReposDir] = React.useState("");
const communityOnboarding = useCommunityOnboarding(); const communityOnboarding = useCommunityOnboarding();
const [reposDirError, setReposDirError] = React.useState<string | null>(null); const [reposDirError, setReposDirError] = React.useState<string | null>(null);
const shouldReduceMotion = useReducedMotion();
React.useEffect(() => {
if (!open || !relayUrl.trim()) return;
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
if (!isJoinPolicyDiscoveryCandidate(normalizedUrl)) return;
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void getJoinPolicy(normalizedUrl)
.then((policy) => {
if (cancelled || !policy) return;
setJoinPolicy(policy);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setInviteError(null);
})
.catch(() => {
// Background discovery is best-effort. A deliberate submit retries
// the request and surfaces any relay error to the user.
});
}, POLICY_DISCOVERY_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [open, relayUrl]);
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
onOpenChange(false); onOpenChange(false);
@@ -43,6 +88,10 @@ export function AddCommunityDialog({
setRelayUrl(""); setRelayUrl("");
setToken(""); setToken("");
setInviteCode(""); setInviteCode("");
setInviteError(null);
setJoinPolicy(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setReposDir(""); setReposDir("");
setReposDirError(null); setReposDirError(null);
}, [onOpenChange]); }, [onOpenChange]);
@@ -67,6 +116,45 @@ export function AddCommunityDialog({
} }
const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim()); const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
let policyReceipt: string | undefined;
try {
const policy = await getJoinPolicy(normalizedRelayUrl);
if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
setJoinPolicy(policy);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setInviteError(null);
return;
}
if (policy?.ageAttestationRequired && !ageConfirmed) {
setInviteError("Confirm that you are at least 18 years old.");
return;
}
if (
policy &&
(policy.termsMarkdown || policy.privacyMarkdown) &&
!agreementConfirmed
) {
setInviteError("Agree to the Terms of Service and Privacy Policy.");
return;
}
// Receipts are bound to an invite code, so one is only minted when a
// code is present. The claim itself runs on the onboarding
// transaction (useClaimInvite), which forwards this receipt.
if (policy && inviteCode.trim()) {
policyReceipt = await acceptJoinPolicy(
normalizedRelayUrl,
inviteCode.trim(),
policy.version,
ageConfirmed,
);
}
} catch (error) {
setInviteError(`Community rejected: ${inviteErrorMessage(error)}`);
return;
}
communityOnboarding.start({ communityOnboarding.start({
source: "add-community", source: "add-community",
relayUrl: normalizedRelayUrl, relayUrl: normalizedRelayUrl,
@@ -74,6 +162,7 @@ export function AddCommunityDialog({
communityName: name.trim() || deriveCommunityName(normalizedRelayUrl), communityName: name.trim() || deriveCommunityName(normalizedRelayUrl),
token: token.trim() || undefined, token: token.trim() || undefined,
reposDir: expandedReposDir, reposDir: expandedReposDir,
policyReceipt,
}); });
handleClose(); handleClose();
}, },
@@ -83,6 +172,9 @@ export function AddCommunityDialog({
token, token,
inviteCode, inviteCode,
reposDir, reposDir,
joinPolicy,
ageConfirmed,
agreementConfirmed,
communityOnboarding, communityOnboarding,
handleClose, handleClose,
], ],
@@ -112,7 +204,13 @@ export function AddCommunityDialog({
<Input <Input
autoFocus autoFocus
id="ws-relay-url" id="ws-relay-url"
onChange={(e) => setRelayUrl(e.target.value)} onChange={(e) => {
setRelayUrl(e.target.value);
setInviteError(null);
setJoinPolicy(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
}}
placeholder="wss://relay.example.com" placeholder="wss://relay.example.com"
type="text" type="text"
value={relayUrl} value={relayUrl}
@@ -168,6 +266,10 @@ export function AddCommunityDialog({
id="ws-invite-code" id="ws-invite-code"
onChange={(e) => { onChange={(e) => {
setInviteCode(e.target.value); setInviteCode(e.target.value);
setInviteError(null);
setJoinPolicy(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
}} }}
placeholder="Paste an invite code for a members-only relay" placeholder="Paste an invite code for a members-only relay"
type="text" type="text"
@@ -207,11 +309,81 @@ export function AddCommunityDialog({
Communities share your active identity. To use a different key, Communities share your active identity. To use a different key,
import it on the profile step (or in settings). import it on the profile step (or in settings).
</p> </p>
{inviteError ? (
<p className="text-xs text-destructive">{inviteError}</p>
) : null}
<AnimatePresence initial={false}>
{joinPolicy && relayUrl.trim() ? (
<motion.div
animate={{
height: "auto",
marginTop: 0,
opacity: 1,
transform: "translateY(0rem)",
}}
className="overflow-hidden"
exit={
shouldReduceMotion
? { height: 0, marginTop: "-1rem", opacity: 0 }
: {
height: 0,
marginTop: "-1rem",
opacity: 0,
transform: "translateY(-0.25rem)",
}
}
initial={
shouldReduceMotion
? false
: {
height: 0,
marginTop: "-1rem",
opacity: 0,
transform: "translateY(-0.25rem)",
}
}
key={`${normalizeRelayUrl(relayUrl.trim())}:${joinPolicy.version}`}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.22, ease: POLICY_REVEAL_EASE }
}
>
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
agreementConfirmed={agreementConfirmed}
onAgeConfirmedChange={(confirmed) => {
setAgeConfirmed(confirmed);
setInviteError(null);
}}
onAgreementConfirmedChange={(confirmed) => {
setAgreementConfirmed(confirmed);
setInviteError(null);
}}
policy={joinPolicy}
// Editing the relay URL resets joinPolicy, so a visible
// notice always belongs to the URL currently in the field.
relayWsUrl={normalizeRelayUrl(relayUrl.trim())}
/>
</motion.div>
) : null}
</AnimatePresence>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button onClick={handleClose} type="button" variant="outline"> <Button onClick={handleClose} type="button" variant="outline">
Cancel Cancel
</Button> </Button>
<Button disabled={!relayUrl.trim()} type="submit"> <Button
disabled={
!relayUrl.trim() ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
Boolean(
joinPolicy &&
(joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
!agreementConfirmed,
)
}
type="submit"
>
Add Community Add Community
</Button> </Button>
</div> </div>

View File

@@ -1,14 +1,26 @@
import * as React from "react"; import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import {
getJoinPolicy,
isJoinPolicyDiscoveryCandidate,
type JoinPolicy,
} from "@/shared/api/invites";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner"; import { Spinner } from "@/shared/ui/spinner";
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe"; import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
const POLICY_DISCOVERY_DELAY_MS = 250;
const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
export type CommunityEditFormProps = { export type CommunityEditFormProps = {
cancelLabel?: string; cancelLabel?: string;
initialName: string; initialName: string;
initialRelayUrl: string; initialRelayUrl: string;
isSubmitting?: boolean; isSubmitting?: boolean;
joinPolicyRequired?: boolean;
onCancel: () => void; onCancel: () => void;
onSubmit: (name: string, relayUrl: string) => void; onSubmit: (name: string, relayUrl: string) => void;
submitLabel: string; submitLabel: string;
@@ -19,6 +31,7 @@ export function CommunityEditForm({
initialName, initialName,
initialRelayUrl, initialRelayUrl,
isSubmitting = false, isSubmitting = false,
joinPolicyRequired = false,
onCancel, onCancel,
onSubmit, onSubmit,
submitLabel, submitLabel,
@@ -29,6 +42,13 @@ export function CommunityEditForm({
const [probeWarning, setProbeWarning] = React.useState<string | null>(null); const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
const [isProbing, setIsProbing] = React.useState(false); const [isProbing, setIsProbing] = React.useState(false);
const [useAnywayOverride, setUseAnywayOverride] = React.useState(false); const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
const [policyRelayUrl, setPolicyRelayUrl] = React.useState<string | null>(
null,
);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
const cancelRef = React.useRef<(() => void) | null>(null); const cancelRef = React.useRef<(() => void) | null>(null);
@@ -38,6 +58,37 @@ export function CommunityEditForm({
}; };
}, []); }, []);
React.useEffect(() => {
if (!joinPolicyRequired) return;
const normalizedUrl = normalizeRelayUrl(relayUrl);
if (!normalizedUrl || !isJoinPolicyDiscoveryCandidate(normalizedUrl)) {
return;
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void getJoinPolicy(normalizedUrl)
.then((policy) => {
if (cancelled || !policy) return;
setJoinPolicy(policy);
setPolicyRelayUrl(normalizedUrl);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setError(null);
})
.catch(() => {
// Background discovery is best-effort. A deliberate submit retries
// the request and surfaces any relay error to the user.
});
}, POLICY_DISCOVERY_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [joinPolicyRequired, relayUrl]);
const handleSubmit = React.useCallback( const handleSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => { async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -52,6 +103,41 @@ export function CommunityEditForm({
return; return;
} }
if (joinPolicyRequired) {
try {
const policy = await getJoinPolicy(normalizedUrl);
if (!policy) {
onSubmit(trimmedName, normalizedUrl);
return;
}
if (
!joinPolicy ||
joinPolicy.version !== policy.version ||
policyRelayUrl !== normalizedUrl
) {
setJoinPolicy(policy);
setPolicyRelayUrl(normalizedUrl);
setAgeConfirmed(false);
setAgreementConfirmed(false);
return;
}
if (policy.ageAttestationRequired && !ageConfirmed) {
setError("Confirm that you are at least 18 years old.");
return;
}
if (
(policy.termsMarkdown || policy.privacyMarkdown) &&
!agreementConfirmed
) {
setError("Agree to the Terms of Service and Privacy Policy.");
return;
}
} catch (policyError) {
setError(inviteErrorMessage(policyError));
return;
}
}
if (useAnywayOverride) { if (useAnywayOverride) {
onSubmit(trimmedName, normalizedUrl); onSubmit(trimmedName, normalizedUrl);
return; return;
@@ -79,7 +165,17 @@ export function CommunityEditForm({
onSubmit(trimmedName, normalizedUrl); onSubmit(trimmedName, normalizedUrl);
}, },
[name, onSubmit, relayUrl, useAnywayOverride], [
ageConfirmed,
agreementConfirmed,
joinPolicy,
joinPolicyRequired,
name,
onSubmit,
policyRelayUrl,
relayUrl,
useAnywayOverride,
],
); );
const handleUseAnyway = React.useCallback(() => { const handleUseAnyway = React.useCallback(() => {
@@ -139,6 +235,10 @@ export function CommunityEditForm({
setError(null); setError(null);
setProbeWarning(null); setProbeWarning(null);
setUseAnywayOverride(false); setUseAnywayOverride(false);
setJoinPolicy(null);
setPolicyRelayUrl(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
}} }}
placeholder="wss://relay.example.com" placeholder="wss://relay.example.com"
type="text" type="text"
@@ -146,10 +246,75 @@ export function CommunityEditForm({
/> />
</div> </div>
<AnimatePresence initial={false}>
{joinPolicy && policyRelayUrl ? (
<motion.div
animate={{
height: "auto",
marginTop: 0,
opacity: 1,
transform: "translateY(0rem)",
}}
className="overflow-hidden"
exit={
shouldReduceMotion
? { height: 0, marginTop: "-1rem", opacity: 0 }
: {
height: 0,
marginTop: "-1rem",
opacity: 0,
transform: "translateY(-0.25rem)",
}
}
initial={
shouldReduceMotion
? false
: {
height: 0,
marginTop: "-1rem",
opacity: 0,
transform: "translateY(-0.25rem)",
}
}
key={`${policyRelayUrl}:${joinPolicy.version}`}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.22, ease: POLICY_REVEAL_EASE }
}
>
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
agreementConfirmed={agreementConfirmed}
onAgeConfirmedChange={(confirmed) => {
setAgeConfirmed(confirmed);
setError(null);
}}
onAgreementConfirmedChange={(confirmed) => {
setAgreementConfirmed(confirmed);
setError(null);
}}
policy={joinPolicy}
relayWsUrl={policyRelayUrl}
/>
</motion.div>
) : null}
</AnimatePresence>
<div className="flex w-full flex-col gap-3 pt-1"> <div className="flex w-full flex-col gap-3 pt-1">
<Button <Button
className="h-10 w-full" className="h-10 w-full"
disabled={isBusy || !name.trim() || !relayUrl.trim()} disabled={
isBusy ||
!name.trim() ||
!relayUrl.trim() ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
Boolean(
joinPolicy &&
(joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
!agreementConfirmed,
)
}
type="submit" type="submit"
> >
{isProbing ? ( {isProbing ? (

View File

@@ -72,11 +72,12 @@ export function WelcomeSetup({
}, [communityOnboarding, defaultRelayUrl]); }, [communityOnboarding, defaultRelayUrl]);
const handleInviteRedeem = React.useCallback( const handleInviteRedeem = React.useCallback(
(relayWsUrl: string, code: string) => { (relayWsUrl: string, code: string, policyReceipt?: string) => {
communityOnboarding.start({ communityOnboarding.start({
source: "first-community", source: "first-community",
relayUrl: relayWsUrl, relayUrl: relayWsUrl,
inviteCode: code, inviteCode: code,
policyReceipt,
}); });
}, },
[communityOnboarding], [communityOnboarding],

View File

@@ -29,6 +29,12 @@ export type CommunityOnboardingTransaction = {
communityName: string; communityName: string;
token?: string; token?: string;
reposDir?: string; reposDir?: string;
/**
* Join-policy acceptance receipt minted before the claim (bound to the
* invite code). Forwarded to `claimInvite` so relays with a configured
* join policy admit the claim.
*/
policyReceipt?: string;
communityId?: string; communityId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -52,6 +58,7 @@ export type StartCommunityOnboardingInput = {
communityName?: string; communityName?: string;
token?: string; token?: string;
reposDir?: string; reposDir?: string;
policyReceipt?: string;
}; };
function canonicalRelayUrl(rawRelayUrl: string) { function canonicalRelayUrl(rawRelayUrl: string) {
@@ -127,6 +134,7 @@ export function startCommunityOnboarding(
communityName: input.communityName?.trim() || existing.communityName, communityName: input.communityName?.trim() || existing.communityName,
token: input.token?.trim() || existing.token, token: input.token?.trim() || existing.token,
reposDir: input.reposDir ?? existing.reposDir, reposDir: input.reposDir ?? existing.reposDir,
policyReceipt: input.policyReceipt ?? existing.policyReceipt,
updatedAt: now.toISOString(), updatedAt: now.toISOString(),
error: undefined, error: undefined,
// A freshly opened link deserves fresh feedback — re-present the gate // A freshly opened link deserves fresh feedback — re-present the gate
@@ -147,6 +155,7 @@ export function startCommunityOnboarding(
communityName: input.communityName?.trim() || deriveCommunityName(relayUrl), communityName: input.communityName?.trim() || deriveCommunityName(relayUrl),
token: input.token?.trim() || undefined, token: input.token?.trim() || undefined,
reposDir: input.reposDir, reposDir: input.reposDir,
policyReceipt: input.policyReceipt,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}; };

View File

@@ -1,9 +1,23 @@
import * as React from "react"; import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { parseInviteInput } from "@/shared/api/inviteHelpers"; import {
inviteErrorMessage,
parseInviteInput,
} from "@/shared/api/inviteHelpers";
import {
acceptJoinPolicy,
getJoinPolicy,
isJoinPolicyDiscoveryCandidate,
type JoinPolicy,
} from "@/shared/api/invites";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner"; import { Spinner } from "@/shared/ui/spinner";
import { JoinPolicyNotice } from "./JoinPolicyNotice";
const POLICY_DISCOVERY_DELAY_MS = 250;
const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
type InviteRedeemFormProps = { type InviteRedeemFormProps = {
/** /**
@@ -16,7 +30,7 @@ type InviteRedeemFormProps = {
error: string | null; error: string | null;
isRedeeming: boolean; isRedeeming: boolean;
onCancel: () => void; onCancel: () => void;
onRedeem: (relayWsUrl: string, code: string) => void; onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
}; };
export function InviteRedeemForm({ export function InviteRedeemForm({
@@ -30,6 +44,16 @@ export function InviteRedeemForm({
const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState( const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
defaultRelayUrl ?? "", defaultRelayUrl ?? "",
); );
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
const [policyInvite, setPolicyInvite] = React.useState<{
relayWsUrl: string;
code: string;
} | null>(null);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
const [policyError, setPolicyError] = React.useState<string | null>(null);
const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
const parsed = React.useMemo( const parsed = React.useMemo(
() => parseInviteInput(inviteInput), () => parseInviteInput(inviteInput),
@@ -38,23 +62,106 @@ export function InviteRedeemForm({
const isBareCode = parsed !== null && !("relayWsUrl" in parsed); const isBareCode = parsed !== null && !("relayWsUrl" in parsed);
const needsRelayField = isBareCode && defaultRelayUrl !== undefined; const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
React.useEffect(() => {
if (!parsed) return;
const relayWsUrl =
"relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
if (!relayWsUrl || !isJoinPolicyDiscoveryCandidate(relayWsUrl)) return;
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void getJoinPolicy(relayWsUrl)
.then((policy) => {
if (cancelled || !policy) return;
setJoinPolicy(policy);
setPolicyInvite({ relayWsUrl, code: parsed.code });
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
})
.catch(() => {
// Background discovery is best-effort. A deliberate submit retries
// the request and surfaces any relay error to the user.
});
}, POLICY_DISCOVERY_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [bareCodeRelayUrl, parsed]);
const canSubmit = const canSubmit =
parsed !== null && parsed !== null &&
("relayWsUrl" in parsed || ("relayWsUrl" in parsed ||
(isBareCode && bareCodeRelayUrl.trim().length > 0)); (isBareCode && bareCodeRelayUrl.trim().length > 0));
const handleSubmit = React.useCallback( const handleSubmit = React.useCallback(
(event: React.FormEvent) => { async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!parsed) return; if (!parsed) return;
if ("relayWsUrl" in parsed) { const relayWsUrl =
onRedeem(parsed.relayWsUrl, parsed.code); "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
} else if (bareCodeRelayUrl.trim()) { if (!relayWsUrl) return;
onRedeem(bareCodeRelayUrl.trim(), parsed.code);
setPolicyError(null);
setIsLoadingPolicy(true);
try {
const policy = await getJoinPolicy(relayWsUrl);
if (!policy) {
onRedeem(relayWsUrl, parsed.code);
return;
}
if (
!joinPolicy ||
joinPolicy.version !== policy.version ||
policyInvite?.relayWsUrl !== relayWsUrl ||
policyInvite.code !== parsed.code
) {
setJoinPolicy(policy);
setPolicyInvite({ relayWsUrl, code: parsed.code });
setAgeConfirmed(false);
setAgreementConfirmed(false);
return;
}
if (policy.ageAttestationRequired && !ageConfirmed) {
setPolicyError("Confirm that you are at least 18 years old.");
return;
}
if (
(policy.termsMarkdown || policy.privacyMarkdown) &&
!agreementConfirmed
) {
setPolicyError("Agree to the Terms of Service and Privacy Policy.");
return;
}
const receipt = await acceptJoinPolicy(
relayWsUrl,
parsed.code,
policy.version,
ageConfirmed,
);
onRedeem(relayWsUrl, parsed.code, receipt);
} catch (policyFetchError) {
setPolicyError(inviteErrorMessage(policyFetchError));
} finally {
setIsLoadingPolicy(false);
} }
}, },
[bareCodeRelayUrl, onRedeem, parsed], [
ageConfirmed,
agreementConfirmed,
bareCodeRelayUrl,
joinPolicy,
onRedeem,
parsed,
policyInvite,
],
); );
return ( return (
@@ -74,7 +181,14 @@ export function InviteRedeemForm({
data-testid="invite-redeem-input" data-testid="invite-redeem-input"
disabled={isRedeeming} disabled={isRedeeming}
id="invite-input" id="invite-input"
onChange={(event) => setInviteInput(event.target.value)} onChange={(event) => {
setInviteInput(event.target.value);
setJoinPolicy(null);
setPolicyInvite(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
}}
placeholder="https://relay.example.com/invite/abc123 or paste a code" placeholder="https://relay.example.com/invite/abc123 or paste a code"
spellCheck={false} spellCheck={false}
type="text" type="text"
@@ -94,7 +208,14 @@ export function InviteRedeemForm({
className="h-10 bg-background" className="h-10 bg-background"
disabled={isRedeeming} disabled={isRedeeming}
id="invite-relay-url" id="invite-relay-url"
onChange={(event) => setBareCodeRelayUrl(event.target.value)} onChange={(event) => {
setBareCodeRelayUrl(event.target.value);
setJoinPolicy(null);
setPolicyInvite(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
}}
placeholder="wss://relay.example.com" placeholder="wss://relay.example.com"
type="text" type="text"
value={bareCodeRelayUrl} value={bareCodeRelayUrl}
@@ -102,18 +223,92 @@ export function InviteRedeemForm({
</div> </div>
) : null} ) : null}
{policyError ? (
<p className="text-center text-sm text-destructive">{policyError}</p>
) : null}
{error ? ( {error ? (
<p className="text-center text-sm text-destructive">{error}</p> <p className="text-center text-sm text-destructive">{error}</p>
) : null} ) : null}
<AnimatePresence initial={false}>
{joinPolicy && policyInvite ? (
<motion.div
animate={{
height: "auto",
marginTop: 0,
opacity: 1,
transform: "translateY(0rem)",
}}
className="overflow-hidden"
exit={
shouldReduceMotion
? { height: 0, marginTop: "-0.75rem", opacity: 0 }
: {
height: 0,
marginTop: "-0.75rem",
opacity: 0,
transform: "translateY(-0.25rem)",
}
}
initial={
shouldReduceMotion
? false
: {
height: 0,
marginTop: "-0.75rem",
opacity: 0,
transform: "translateY(-0.25rem)",
}
}
key={`${policyInvite.relayWsUrl}:${joinPolicy.version}`}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.22, ease: POLICY_REVEAL_EASE }
}
>
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
agreementConfirmed={agreementConfirmed}
onAgeConfirmedChange={(confirmed) => {
setAgeConfirmed(confirmed);
setPolicyError(null);
}}
onAgreementConfirmedChange={(confirmed) => {
setAgreementConfirmed(confirmed);
setPolicyError(null);
}}
policy={joinPolicy}
relayWsUrl={policyInvite.relayWsUrl}
/>
</motion.div>
) : null}
</AnimatePresence>
<Button <Button
className="h-10 w-full" className="h-10 w-full"
data-testid="invite-redeem-submit" data-testid="invite-redeem-submit"
disabled={!canSubmit || isRedeeming} disabled={
!canSubmit ||
isRedeeming ||
isLoadingPolicy ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
Boolean(
joinPolicy &&
(joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
!agreementConfirmed,
)
}
type="submit" type="submit"
> >
{isRedeeming ? ( {isRedeeming || isLoadingPolicy ? (
<Spinner aria-label="Redeeming invite" className="h-4 w-4 border-2" /> <Spinner
aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
className="h-4 w-4 border-2"
/>
) : joinPolicy ? (
"Accept and redeem invite"
) : ( ) : (
"Redeem invite" "Redeem invite"
)} )}

View File

@@ -0,0 +1,108 @@
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
type JoinPolicyNoticeProps = {
ageConfirmed: boolean;
agreementConfirmed: boolean;
onAgeConfirmedChange: (confirmed: boolean) => void;
onAgreementConfirmedChange: (confirmed: boolean) => void;
policy: JoinPolicy;
/** Relay hosting the policy documents the links below point at. */
relayWsUrl: string;
};
/**
* Join-policy consent block shown on every join surface.
*
* The Terms/Privacy links open the relay-hosted document pages
* (`/api/join-policy/terms|privacy`) in the system browser via the OS
* opener. They must NOT navigate or render in-app: these surfaces exist
* before onboarding completes, where the router (required by the message
* Markdown component) is not mounted — an in-app render tears down the
* whole React tree.
*/
export function JoinPolicyNotice({
ageConfirmed,
agreementConfirmed,
onAgeConfirmedChange,
onAgreementConfirmedChange,
policy,
relayWsUrl,
}: JoinPolicyNoticeProps) {
const ageConfirmationId = React.useId();
const agreementConfirmationId = React.useId();
return (
<div className="space-y-3 rounded-xl border border-border/70 bg-muted/30 p-4 text-left">
{policy.ageAttestationRequired ? (
<div className="flex items-start gap-3">
<Checkbox
checked={ageConfirmed}
className="mt-0.5"
id={ageConfirmationId}
onCheckedChange={(checked) =>
onAgeConfirmedChange(checked === true)
}
/>
<label
className="cursor-pointer text-xs leading-5 text-muted-foreground"
htmlFor={ageConfirmationId}
>
I am 18 years of age or older.
</label>
</div>
) : null}
{policy.termsMarkdown || policy.privacyMarkdown ? (
<div className="flex items-start gap-3">
<Checkbox
checked={agreementConfirmed}
className="mt-0.5"
id={agreementConfirmationId}
onCheckedChange={(checked) =>
onAgreementConfirmedChange(checked === true)
}
/>
<label
className="cursor-pointer text-xs leading-5 text-muted-foreground"
htmlFor={agreementConfirmationId}
>
I agree to the Buzz{" "}
{policy.termsMarkdown ? (
<Button
className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
onClick={(event) => {
event.preventDefault();
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
}}
type="button"
variant="link"
>
Terms of Service
</Button>
) : null}
{policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
{policy.privacyMarkdown ? (
<Button
className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
onClick={(event) => {
event.preventDefault();
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
}}
type="button"
variant="link"
>
Privacy Policy
</Button>
) : null}
.
</label>
</div>
) : null}
</div>
);
}

View File

@@ -84,11 +84,12 @@ export function MembershipDenied({
}, [onImportKey, previewNpub, trimmedNsec]); }, [onImportKey, previewNpub, trimmedNsec]);
const handleInviteRedeem = React.useCallback( const handleInviteRedeem = React.useCallback(
(relayWsUrl: string, code: string) => { (relayWsUrl: string, code: string, policyReceipt?: string) => {
communityOnboarding.start({ communityOnboarding.start({
source: "membership-recovery", source: "membership-recovery",
relayUrl: relayWsUrl, relayUrl: relayWsUrl,
inviteCode: code, inviteCode: code,
policyReceipt,
}); });
}, },
[communityOnboarding], [communityOnboarding],

View File

@@ -41,7 +41,7 @@ export function NostrKeyImportForm({
const trimmedInput = nsecInput.trim(); const trimmedInput = nsecInput.trim();
const hasInput = trimmedInput.length > 0; const hasInput = trimmedInput.length > 0;
const isValid = previewNpub !== null; const isValid = previewNpub !== null;
const isBusy = disabled || isImporting; const isInteractionDisabled = disabled || isImporting;
const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5; const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
const errorMessage = importError ?? externalErrorMessage; const errorMessage = importError ?? externalErrorMessage;
@@ -50,12 +50,12 @@ export function NostrKeyImportForm({
}, []); }, []);
const openFilePicker = React.useCallback(() => { const openFilePicker = React.useCallback(() => {
if (isBusy) { if (isInteractionDisabled) {
return; return;
} }
fileInputRef.current?.click(); fileInputRef.current?.click();
}, [isBusy]); }, [isInteractionDisabled]);
const handleFiles = React.useCallback(async (files: FileList | null) => { const handleFiles = React.useCallback(async (files: FileList | null) => {
const file = files?.[0]; const file = files?.[0];
@@ -98,7 +98,7 @@ export function NostrKeyImportForm({
await onImport(trimmedInput); await onImport(trimmedInput);
} catch (error) { } catch (error) {
setImportError( setImportError(
error instanceof Error ? error.message : "Failed to import key.", error instanceof Error ? error.message : "Couldn't import this key.",
); );
} finally { } finally {
setIsImporting(false); setIsImporting(false);
@@ -141,7 +141,7 @@ export function NostrKeyImportForm({
<input <input
accept=".key,text/plain" accept=".key,text/plain"
className="sr-only" className="sr-only"
disabled={isBusy} disabled={isInteractionDisabled}
onChange={(event) => { onChange={(event) => {
void handleFiles(event.currentTarget.files); void handleFiles(event.currentTarget.files);
event.currentTarget.value = ""; event.currentTarget.value = "";
@@ -159,12 +159,12 @@ export function NostrKeyImportForm({
)} )}
data-dragging={isDragging ? "true" : undefined} data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop" data-testid="nostr-import-drop"
disabled={isBusy} disabled={isInteractionDisabled}
onClick={openFilePicker} onClick={openFilePicker}
onDragEnter={(event) => { onDragEnter={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!isBusy) { if (!isInteractionDisabled) {
setIsDragging(true); setIsDragging(true);
} }
}} }}
@@ -181,7 +181,7 @@ export function NostrKeyImportForm({
onDragOver={(event) => { onDragOver={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!isBusy) { if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy"; event.dataTransfer.dropEffect = "copy";
} }
}} }}
@@ -189,7 +189,7 @@ export function NostrKeyImportForm({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragging(false); setIsDragging(false);
if (isBusy) { if (isInteractionDisabled) {
return; return;
} }
void handleFiles(event.dataTransfer.files); void handleFiles(event.dataTransfer.files);
@@ -250,10 +250,10 @@ export function NostrKeyImportForm({
<Button <Button
className="h-10 w-full" className="h-10 w-full"
data-testid="nostr-import-submit" data-testid="nostr-import-submit"
disabled={!isValid || isBusy} disabled={!isValid || isInteractionDisabled}
type="submit" type="submit"
> >
{isBusy ? ( {isImporting ? (
<Spinner aria-label="Importing key" className="h-4 w-4 border-2" /> <Spinner aria-label="Importing key" className="h-4 w-4 border-2" />
) : ( ) : (
"Continue with this key" "Continue with this key"
@@ -262,7 +262,7 @@ export function NostrKeyImportForm({
<Button <Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground" className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
disabled={isBusy} disabled={isImporting}
onClick={onBack} onClick={onBack}
type="button" type="button"
variant="ghost" variant="ghost"

View File

@@ -1,7 +1,10 @@
import * as React from "react"; import * as React from "react";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers"; import {
inviteErrorMessage,
isInviteExpiredError,
} from "@/shared/api/inviteHelpers";
import { claimInvite } from "@/shared/api/invites"; import { claimInvite } from "@/shared/api/invites";
/** /**
@@ -23,12 +26,23 @@ export function useClaimInvite() {
return; return;
} }
setIsPending(true); setIsPending(true);
void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "") void claimInvite(
transaction.relayUrl,
transaction.inviteCode ?? "",
transaction.policyReceipt,
)
.then(() => { .then(() => {
update({ stage: "connecting", error: undefined }, transaction.id); update({ stage: "connecting", error: undefined }, transaction.id);
}) })
.catch((error: unknown) => .catch((error: unknown) =>
update({ error: inviteErrorMessage(error) }, transaction.id), update(
{
error: isInviteExpiredError(error)
? "This invite code has expired — ask for a new one."
: inviteErrorMessage(error),
},
transaction.id,
),
) )
.finally(() => setIsPending(false)); .finally(() => setIsPending(false));
}, [isPending, transaction, update]); }, [isPending, transaction, update]);

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/),
);
});

View File

@@ -23,6 +23,13 @@ export type MintedInvite = {
url: string; url: string;
}; };
export type JoinPolicy = {
termsMarkdown?: string;
privacyMarkdown?: string;
ageAttestationRequired: boolean;
version: string;
};
export type ClaimResult = { export type ClaimResult = {
status: "joined" | "already_member"; status: "joined" | "already_member";
communityId: string; communityId: string;
@@ -90,6 +97,81 @@ async function invitePost<T>(
return json as T; return json as T;
} }
/** Absolute URL of a relay-hosted policy document page (system-browser target). */
export function joinPolicyDocumentUrl(
relayWsUrl: string,
document: "terms" | "privacy",
): string {
const base = relayHttpFromWs(relayWsUrl);
return `${base.replace(/\/+$/, "")}/api/join-policy/${document}`;
}
/** Whether a normalized relay URL is complete enough for background policy discovery. */
export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
try {
const url = new URL(relayWsUrl);
if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
return (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname.includes(".")
);
} catch {
return false;
}
}
/** Fetch relay-hosted policy content for any join surface. */
export async function getJoinPolicy(
relayWsUrl: string,
): Promise<JoinPolicy | null> {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
// Relays predating join-policy support have no configured policy.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = (await response.json()) as {
policy?: {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
};
return raw.policy
? {
termsMarkdown: raw.policy.terms_markdown,
privacyMarkdown: raw.policy.privacy_markdown,
ageAttestationRequired: raw.policy.age_attestation_required,
version: raw.policy.version,
}
: null;
}
/** Accept the current join policy for an invite and receive a bound receipt. */
export async function acceptJoinPolicy(
relayWsUrl: string,
code: string,
policyVersion: string,
ageConfirmed: boolean,
): Promise<string> {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(
`${base.replace(/\/+$/, "")}/api/invites/accept-policy`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
policy_version: policyVersion,
age_confirmed: ageConfirmed,
}),
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return ((await response.json()) as { receipt: string }).receipt;
}
/** Mint an invite code on the active community's relay (owner/admin only). */ /** Mint an invite code on the active community's relay (owner/admin only). */
export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> { export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
const base = await getRelayHttpUrl(); const base = await getRelayHttpUrl();
@@ -109,9 +191,10 @@ export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
export async function claimInvite( export async function claimInvite(
relayWsUrl: string, relayWsUrl: string,
code: string, code: string,
policyReceipt?: string,
): Promise<ClaimResult> { ): Promise<ClaimResult> {
const base = relayHttpFromWs(relayWsUrl); const base = relayHttpFromWs(relayWsUrl);
const body = JSON.stringify({ code }); const body = JSON.stringify({ code, policy_receipt: policyReceipt });
const raw = await invitePost<{ const raw = await invitePost<{
status: "joined" | "already_member"; status: "joined" | "already_member";
community_id: string; community_id: string;

View File

@@ -37,6 +37,7 @@ export type NostrBindDeepLinkPayload = {
export type JoinDeepLinkPayload = { export type JoinDeepLinkPayload = {
relayUrl: string; relayUrl: string;
code: string; code: string;
policyReceipt: string | null;
}; };
type PendingCommunityDeepLink = { type PendingCommunityDeepLink = {
@@ -44,6 +45,7 @@ type PendingCommunityDeepLink = {
kind: "connect" | "join"; kind: "connect" | "join";
relayUrl: string; relayUrl: string;
code: string | null; code: string | null;
policyReceipt: string | null;
}; };
function acceptPendingCommunityDeepLink( function acceptPendingCommunityDeepLink(
@@ -54,6 +56,7 @@ function acceptPendingCommunityDeepLink(
source: pending.kind === "join" ? "deep-link-join" : "deep-link-connect", source: pending.kind === "join" ? "deep-link-join" : "deep-link-connect",
relayUrl: pending.relayUrl, relayUrl: pending.relayUrl,
inviteCode: pending.code ?? undefined, inviteCode: pending.code ?? undefined,
policyReceipt: pending.policyReceipt ?? undefined,
}); });
return accepted return accepted
? invoke<boolean>("acknowledge_pending_community_deep_link", { ? invoke<boolean>("acknowledge_pending_community_deep_link", {

View File

@@ -1,28 +1,53 @@
import * as React from "react"; import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react"; import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => {
<CheckboxPrimitive.Root const shouldReduceMotion = useReducedMotion();
ref={ref}
className={cn( return (
"peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", <CheckboxPrimitive.Root
className, ref={ref}
)} className={cn(
{...props} "peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
> className,
<CheckboxPrimitive.Indicator )}
className={cn("flex items-center justify-center text-current")} {...props}
> >
<Check className="h-4 w-4" /> <CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
</CheckboxPrimitive.Indicator> <svg
</CheckboxPrimitive.Root> aria-hidden="true"
)); className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<motion.path
animate={{ opacity: 1, pathLength: 1 }}
d="m5 12 4 4L19 6"
initial={shouldReduceMotion ? false : { opacity: 0, pathLength: 0 }}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
}
}
/>
</svg>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
Checkbox.displayName = CheckboxPrimitive.Root.displayName; Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }; export { Checkbox };

View File

@@ -2539,6 +2539,7 @@ let mockPersonas: RawPersona[] = [];
let mockTeams: RawTeam[] = []; let mockTeams: RawTeam[] = [];
// Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name. // Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name.
const tauriEventListeners = new Map<string, Set<() => void>>(); const tauriEventListeners = new Map<string, Set<() => void>>();
const openedExternalUrls: string[] = [];
const defaultMockRelayAgents: RawRelayAgent[] = [ const defaultMockRelayAgents: RawRelayAgent[] = [
{ {
pubkey: ALICE_PUBKEY, pubkey: ALICE_PUBKEY,
@@ -8587,6 +8588,11 @@ export function maybeInstallE2eTauriMocks() {
return importMockIdentity( return importMockIdentity(
(payload as { nsec?: string } | null)?.nsec ?? "", (payload as { nsec?: string } | null)?.nsec ?? "",
); );
case "validate_repos_dir":
// The browser harness has no host filesystem to validate. Treat the
// seeded empty/default path as valid so Add Community can continue to
// relay-policy discovery.
return;
case "apply_workspace": { case "apply_workspace": {
const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0;
if (applyDelayMs > 0) { if (applyDelayMs > 0) {
@@ -9528,6 +9534,12 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof sendToMockSocket>[0], payload as Parameters<typeof sendToMockSocket>[0],
); );
case "plugin:opener|open_url": case "plugin:opener|open_url":
openedExternalUrls.push(String((payload as { url: string | URL }).url));
return null;
case "get_e2e_opened_external_urls":
return [...openedExternalUrls];
case "clear_e2e_opened_external_urls":
openedExternalUrls.length = 0;
return null; return null;
case "plugin:window|show": case "plugin:window|show":
case "plugin:window|unminimize": case "plugin:window|unminimize":

View File

@@ -578,6 +578,16 @@ test("first-community choices expose npub and invite input", async ({
skipOnboardingSeed: true, skipOnboardingSeed: true,
skipCommunitySeed: 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.goto("/");
await expect( await expect(
@@ -1713,7 +1723,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. // Intercept the claimInvite POST to relay B so it succeeds.
const relayBUrl = "wss://relay-b.example.com"; const relayBUrl = "wss://relay-b.example.com";
const relayBHttpUrl = "https://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) => { await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
expect(route.request().postDataJSON()).toMatchObject({
code: "test-invite-code",
policy_receipt: policyReceipt,
});
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -1732,6 +1776,12 @@ test("denied on relay A then paste relay B invite URL switches community to B",
.getByTestId("invite-redeem-input") .getByTestId("invite-redeem-input")
.fill(`${relayBHttpUrl}/invite/test-invite-code`); .fill(`${relayBHttpUrl}/invite/test-invite-code`);
await page.getByTestId("invite-redeem-submit").click(); await page.getByTestId("invite-redeem-submit").click();
await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
await page.getByLabel("I am 18 years of age or older.").check();
await page
.getByLabel("I agree to the Buzz Terms of Service and Privacy Policy.")
.check();
await page.getByTestId("invite-redeem-submit").click();
// After successful claim, relay B is added and becomes active; relay A remains // After successful claim, relay B is added and becomes active; relay A remains
// in the community list for future switching. // in the community list for future switching.

View File

@@ -57,6 +57,60 @@ async function dragSidebarRail(page: Page, deltaX: number) {
await page.mouse.up(); await page.mouse.up();
} }
test("automatically shows relay join requirements near the relay URL", async ({
page,
}) => {
await page.route(
"https://policy.example.com/api/join-policy",
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
policy: {
terms_markdown: "# Terms",
privacy_markdown: "# Privacy",
age_attestation_required: true,
version: "policy-v1",
},
}),
});
},
);
await page.goto("/");
await page.getByTestId("sidebar-profile-card").click();
await page.getByText("Add Community", { exact: true }).click();
await page.getByLabel("Relay URL").fill("wss://policy.example.com");
const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
const agreementConfirmation = page.getByLabel(
"I agree to the Buzz Terms of Service and Privacy Policy.",
);
await expect(ageConfirmation).toBeVisible();
await expect(agreementConfirmation).toBeVisible();
await expect(
page.getByText("Review this relay's join policy below."),
).toHaveCount(0);
await expect(page.getByText(/By continuing, you agree/)).toHaveCount(0);
const addCommunityButton = page.getByRole("button", {
name: "Add Community",
});
await expect(addCommunityButton).toBeDisabled();
await ageConfirmation.check();
await expect(ageConfirmation.locator("svg path")).toBeVisible();
await expect(addCommunityButton).toBeDisabled();
await agreementConfirmation.check();
await expect(addCommunityButton).toBeEnabled();
const consentBox = await agreementConfirmation.boundingBox();
const reposInput = await page.locator("#ws-repos-dir").boundingBox();
const addButtonBox = await addCommunityButton.boundingBox();
expect(consentBox?.y).toBeGreaterThan(reposInput?.y ?? Number.MAX_VALUE);
expect(consentBox?.y).toBeLessThan(addButtonBox?.y ?? 0);
});
test("leaving a channel from the context menu never freezes the app", async ({ test("leaving a channel from the context menu never freezes the app", async ({
page, page,
}) => { }) => {

View File

@@ -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
);

View File

@@ -0,0 +1,161 @@
import * as React from "react";
type InviteJoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
};
type PolicyCheckboxProps = {
accessibleLabel: string;
checked: boolean;
children: React.ReactNode;
onCheckedChange: (checked: boolean) => void;
};
function PolicyCheckbox({
accessibleLabel,
checked,
children,
onCheckedChange,
}: PolicyCheckboxProps) {
const id = React.useId();
return (
<div className="relative">
<input
aria-label={accessibleLabel}
checked={checked}
className="peer sr-only"
id={id}
onChange={(event) => onCheckedChange(event.target.checked)}
type="checkbox"
/>
<label
className="flex cursor-pointer items-start gap-3 rounded-sm text-left text-xs leading-5 text-black/60 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-black"
htmlFor={id}
>
<span
aria-hidden="true"
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-[background-color,border-color] duration-150 motion-reduce:transition-none ${
checked ? "border-black bg-black" : "border-black/40 bg-white"
}`}
>
<svg
aria-hidden="true"
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 16 16"
>
<path
className={
checked
? "transition-[stroke-dashoffset] duration-[180ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
: "transition-none"
}
d="M3 8.25 6.25 11.5 13 4.75"
pathLength="16"
stroke="currentColor"
strokeDasharray="16"
strokeDashoffset={checked ? 0 : 16}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</span>
<span>{children}</span>
</label>
</div>
);
}
/** The invite page's age and legal confirmations. */
export function InviteJoinPolicyNotice({
ageConfirmed,
agreementConfirmed,
onAgeConfirmedChange,
onAgreementConfirmedChange,
onShowDocument,
policy,
}: {
ageConfirmed: boolean;
agreementConfirmed: boolean;
onAgeConfirmedChange: (checked: boolean) => void;
onAgreementConfirmedChange: (checked: boolean) => void;
onShowDocument: (title: string, markdown: string) => void;
policy: InviteJoinPolicy;
}) {
const stopLabelActivation = (
event: React.MouseEvent<HTMLButtonElement>,
title: string,
markdown: string,
) => {
event.preventDefault();
event.stopPropagation();
onShowDocument(title, markdown);
};
return (
<div
className="w-full space-y-3 rounded-xl border border-black/10 bg-black/[0.03] p-4 text-left"
data-testid="invite-join-policy-notice"
>
{policy.age_attestation_required ? (
<PolicyCheckbox
accessibleLabel="I am 18 years of age or older."
checked={ageConfirmed}
onCheckedChange={onAgeConfirmedChange}
>
I am 18 years of age or older.
</PolicyCheckbox>
) : null}
{policy.terms_markdown || policy.privacy_markdown ? (
<PolicyCheckbox
accessibleLabel="I agree to the Buzz Terms of Service and Privacy Policy."
checked={agreementConfirmed}
onCheckedChange={onAgreementConfirmedChange}
>
I agree to the Buzz{" "}
{policy.terms_markdown ? (
<button
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
type="button"
onClick={(event) =>
stopLabelActivation(
event,
"Terms of Service",
policy.terms_markdown ?? "",
)
}
>
Terms of Service
</button>
) : (
"Terms of Service"
)}{" "}
and{" "}
{policy.privacy_markdown ? (
<button
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
type="button"
onClick={(event) =>
stopLabelActivation(
event,
"Privacy Policy",
policy.privacy_markdown ?? "",
)
}
>
Privacy Policy
</button>
) : (
"Privacy Policy"
)}
.
</PolicyCheckbox>
) : null}
</div>
);
}

View File

@@ -1,21 +1,72 @@
import buzzAppIcon from "@/assets/app-icon@3x.png"; import buzzAppIcon from "@/assets/app-icon@3x.png";
import { relayWsUrl } from "@/shared/lib/relay-url"; import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button"; 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"; const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
type JoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
/** type PolicyDocument = { title: string; markdown: string };
* Landing page for a community invite link (`/invite/<code>`).
* /** 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).
*/
export function InvitePage({ code }: { code: string }) { export function InvitePage({ code }: { code: string }) {
const relay = relayWsUrl(); const relay = relayWsUrl();
const host = relay.replace(/^wss?:\/\//, ""); 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 ( return (
<div <div
@@ -24,38 +75,94 @@ export function InvitePage({ code }: { code: string }) {
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)", backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
}} }}
> >
<div <div className="w-full max-w-xl space-y-4">
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" <div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
style={{ <div
boxShadow: className="h-12 w-12 overflow-hidden bg-black"
"0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)", style={{ borderRadius: "22.37%" }}
}}
>
<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"
> >
<a href={deepLink}>Accept invite in Buzz</a> <img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
</Button> </div>
</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?{" "} Don&apos;t have the app?{" "}
<a <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} href={DOWNLOAD_URL}
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
@@ -64,6 +171,37 @@ export function InvitePage({ code }: { code: string }) {
</a> </a>
</p> </p>
</div> </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> </div>
); );
} }

View File

@@ -9,3 +9,67 @@ test("home page shows repositories section", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.getByText("Repositories")).toBeVisible(); await expect(page.getByText("Repositories")).toBeVisible();
}); });
test("invite requires age and legal consent before opening Buzz", async ({
page,
}) => {
await page.route("**/api/join-policy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
policy: {
terms_markdown: "# Terms",
privacy_markdown: "# Privacy",
age_attestation_required: true,
version: "policy-v1",
},
}),
});
});
await page.goto("/invite/demo-code");
const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
const agreementConfirmation = page.getByLabel(
"I agree to the Buzz Terms of Service and Privacy Policy.",
);
const acceptInvite = page.getByRole("button", {
name: "Accept invite in Buzz",
});
await expect(ageConfirmation).toBeVisible();
await expect(agreementConfirmation).toBeVisible();
await expect(acceptInvite).toBeDisabled();
const termsLink = page.getByRole("button", { name: "Terms of Service" });
const privacyLink = page.getByRole("button", { name: "Privacy Policy" });
await expect(termsLink).toHaveCSS("text-decoration-line", "none");
await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
await termsLink.hover();
await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
await page.mouse.move(0, 0);
await privacyLink.hover();
await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
await page
.locator("label")
.filter({ hasText: "I am 18 years of age or older." })
.click();
await expect(ageConfirmation).toBeChecked();
await expect(acceptInvite).toBeDisabled();
await page
.locator("label")
.filter({
hasText: "I agree to the Buzz Terms of Service and Privacy Policy.",
})
.click({ position: { x: 8, y: 8 } });
await expect(agreementConfirmation).toBeChecked();
await expect(acceptInvite).toBeEnabled();
const consentBox = await page
.getByTestId("invite-join-policy-notice")
.boundingBox();
const acceptButtonBox = await acceptInvite.boundingBox();
expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
expect(consentBox?.width).toBe(acceptButtonBox?.width);
});