mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Gate invite acceptance on join policy
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
7fbdacb03f
commit
a58fb065d8
@@ -197,3 +197,10 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
|
||||
# These are accepted for backward compatibility but the canonical names above
|
||||
# are preferred:
|
||||
# BUZZ_ACP_PRIVATE_KEY → BUZZ_PRIVATE_KEY
|
||||
|
||||
# Optional relay join policy. Markdown is served by the relay so every join
|
||||
# surface can present the same documents. Each document and the independent age
|
||||
# attestation are optional; configuring any one enables policy acceptance.
|
||||
# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
|
||||
# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
|
||||
# BUZZ_AGE_ATTESTATION_REQUIRED=true
|
||||
|
||||
@@ -2689,6 +2689,30 @@ impl Db {
|
||||
relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
|
||||
}
|
||||
|
||||
/// Claims relay membership via an invite and atomically persists the
|
||||
/// accepted policy version when a policy is configured.
|
||||
pub async fn claim_relay_membership(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
role: &str,
|
||||
policy_version: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns whether a member has persisted acceptance evidence for a policy version.
|
||||
pub async fn has_join_policy_acceptance(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
policy_version: &str,
|
||||
) -> Result<bool> {
|
||||
relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes a relay member from `community` atomically, refusing to delete the owner.
|
||||
pub async fn remove_relay_member(
|
||||
&self,
|
||||
|
||||
@@ -549,7 +549,7 @@ mod tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 19);
|
||||
assert_eq!(migrations.len(), 20);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(migrations[0]
|
||||
@@ -812,6 +812,18 @@ mod tests {
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("purge_soft_deleted_buzz_mesh_status"));
|
||||
|
||||
// Join policy acceptances landed concurrently with mesh status retention;
|
||||
// keep both additive migrations in a single, unambiguous sequence.
|
||||
assert_eq!(migrations[19].version, 20);
|
||||
assert!(migrations[19]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("CREATE TABLE join_policy_acceptances"));
|
||||
assert!(!migrations[0]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("join_policy_acceptances"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,6 +114,67 @@ pub async fn add_relay_member(
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Claims relay membership via an invite and atomically persists policy evidence.
|
||||
///
|
||||
/// Returns `true` when membership was inserted, or `false` when the pubkey was
|
||||
/// already a member. A configured `policy_version` is recorded in the same
|
||||
/// transaction, so membership cannot be granted without its acceptance record.
|
||||
pub async fn claim_relay_membership(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
role: &str,
|
||||
policy_version: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
|
||||
VALUES ($1, $2, $3, 'invite') \
|
||||
ON CONFLICT (community_id, pubkey) DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(role)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
|
||||
if let Some(version) = policy_version {
|
||||
sqlx::query(
|
||||
"INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
/// Returns whether a member has persisted acceptance evidence for a policy version.
|
||||
pub async fn has_join_policy_acceptance(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
policy_version: &str,
|
||||
) -> Result<bool> {
|
||||
let row = sqlx::query(
|
||||
"SELECT 1 FROM join_policy_acceptances \
|
||||
WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(policy_version)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// The result of a relay member removal attempt.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RemoveResult {
|
||||
@@ -544,6 +605,38 @@ mod tests {
|
||||
(community, owner)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn invite_claim_persists_policy_version_and_legacy_claim_does_not() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let policy_member = test_pubkey();
|
||||
let legacy_member = test_pubkey();
|
||||
let version = "a".repeat(64);
|
||||
|
||||
assert!(
|
||||
claim_relay_membership(&pool, community, &policy_member, "member", Some(&version),)
|
||||
.await
|
||||
.expect("claim membership with policy")
|
||||
);
|
||||
assert!(
|
||||
has_join_policy_acceptance(&pool, community, &policy_member, &version)
|
||||
.await
|
||||
.expect("policy acceptance lookup")
|
||||
);
|
||||
|
||||
assert!(
|
||||
claim_relay_membership(&pool, community, &legacy_member, "member", None)
|
||||
.await
|
||||
.expect("legacy claim membership")
|
||||
);
|
||||
assert!(
|
||||
!has_join_policy_acceptance(&pool, community, &legacy_member, &version)
|
||||
.await
|
||||
.expect("legacy acceptance lookup")
|
||||
);
|
||||
}
|
||||
|
||||
/// NIP-43 admission confinement: a pubkey admitted to community A is *not*
|
||||
/// admitted to community B. This is the exact mutation #1285 targets — a
|
||||
/// `WHERE pubkey = $1` membership check (no community predicate) would let an
|
||||
|
||||
@@ -54,6 +54,66 @@ pub struct MintInviteRequest {
|
||||
pub struct ClaimInviteRequest {
|
||||
/// The invite code to redeem.
|
||||
pub code: String,
|
||||
/// Relay-issued proof of accepting the configured terms, when required.
|
||||
#[serde(default)]
|
||||
pub policy_receipt: Option<String>,
|
||||
}
|
||||
|
||||
/// Body for `POST /api/invites/accept-policy`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AcceptPolicyRequest {
|
||||
/// Invite code the acceptance receipt will be bound to.
|
||||
pub code: String,
|
||||
/// Policy revision displayed by the client.
|
||||
pub policy_version: String,
|
||||
/// Minimum-age assertion, required only when configured by the operator.
|
||||
#[serde(default)]
|
||||
pub age_confirmed: bool,
|
||||
}
|
||||
|
||||
/// Public join policy shared by every client-side join surface.
|
||||
pub async fn join_policy(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
match &state.config.join_policy {
|
||||
Some(policy) => Json(serde_json::json!({
|
||||
"policy": {
|
||||
"terms_markdown": policy.terms_markdown,
|
||||
"privacy_markdown": policy.privacy_markdown,
|
||||
"age_attestation_required": policy.age_attestation_required,
|
||||
"version": policy.version
|
||||
}
|
||||
})),
|
||||
None => Json(serde_json::json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
|
||||
pub async fn accept_policy(
|
||||
State(state): State<Arc<AppState>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let Some(policy) = &state.config.join_policy else {
|
||||
return Err(api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"join_policy_not_configured",
|
||||
));
|
||||
};
|
||||
let request: AcceptPolicyRequest = serde_json::from_slice(&body).map_err(|e| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid policy acceptance JSON: {e}"),
|
||||
)
|
||||
})?;
|
||||
if request.policy_version != policy.version
|
||||
|| (policy.age_attestation_required && !request.age_confirmed)
|
||||
{
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"join_policy_not_accepted",
|
||||
));
|
||||
}
|
||||
let key = invite_token::derive_invite_key(&state.relay_keypair);
|
||||
let receipt = invite_token::mint_policy_acceptance(&key, &request.code, &policy.version);
|
||||
Ok(Json(serde_json::json!({ "receipt": receipt })))
|
||||
}
|
||||
|
||||
/// Shared prelude: bind the tenant from the Host header and verify the NIP-98
|
||||
@@ -186,9 +246,27 @@ pub async fn claim_invite(
|
||||
)?;
|
||||
|
||||
let claimer_hex = pubkey.to_hex();
|
||||
if let Some(policy) = &state.config.join_policy {
|
||||
let receipt = request
|
||||
.policy_receipt
|
||||
.as_deref()
|
||||
.ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
||||
invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
|
||||
.map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
||||
}
|
||||
|
||||
let was_inserted = state
|
||||
.db
|
||||
.add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite"))
|
||||
.claim_relay_membership(
|
||||
tenant.community(),
|
||||
&claimer_hex,
|
||||
&payload.r,
|
||||
state
|
||||
.config
|
||||
.join_policy
|
||||
.as_ref()
|
||||
.map(|policy| policy.version.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("invite claim insert: {e}")))?;
|
||||
|
||||
@@ -361,14 +439,17 @@ mod tests {
|
||||
/// a fresh community on `host`; returns `None` when Postgres is unavailable.
|
||||
async fn invite_test_state(host: &str) -> Option<Arc<AppState>> {
|
||||
let mut config = crate::config::Config::from_env().ok()?;
|
||||
config.database_url = TEST_DB_URL.to_string();
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_string());
|
||||
config.database_url = database_url.clone();
|
||||
config.redis_url = "redis://127.0.0.1:1".to_string();
|
||||
config.relay_url = format!("wss://{host}");
|
||||
// The claim route must work on relays where membership is enforced —
|
||||
// that is the entire point of an invite.
|
||||
config.require_relay_membership = true;
|
||||
|
||||
let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.ok()?;
|
||||
let db = buzz_db::Db::from_pool(pool.clone());
|
||||
db.ensure_configured_community(host).await.ok()?;
|
||||
|
||||
@@ -505,6 +586,215 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn join_policy_gate_end_to_end() {
|
||||
let host = format!("invites-policy-{}.example", Uuid::new_v4().simple());
|
||||
let owner = Keys::generate();
|
||||
let joiner = Keys::generate();
|
||||
let Some(state) = invite_test_state(&host).await else {
|
||||
return;
|
||||
};
|
||||
// Force the join policy on regardless of env.
|
||||
let mut state_inner = (*state).clone();
|
||||
let mut config = state_inner.config.as_ref().clone();
|
||||
config.join_policy = Some(crate::config::JoinPolicyConfig {
|
||||
terms_markdown: Some("# Terms".to_string()),
|
||||
privacy_markdown: Some("# Privacy".to_string()),
|
||||
age_attestation_required: true,
|
||||
version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
|
||||
});
|
||||
state_inner.config = Arc::new(config);
|
||||
let state = Arc::new(state_inner);
|
||||
|
||||
let community = state
|
||||
.db
|
||||
.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community exists");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
|
||||
// Mint an invite.
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites",
|
||||
&owner,
|
||||
"{}".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = read_json(response).await;
|
||||
let code = json
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.expect("code")
|
||||
.to_string();
|
||||
|
||||
// 1. Claim WITHOUT receipt -> 403 (checkbox bypass).
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code }).to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"no-receipt claim must fail"
|
||||
);
|
||||
|
||||
// 2. Forged receipt (wrong key) -> 403.
|
||||
let forged = crate::invite_token::mint_policy_acceptance(
|
||||
&[9u8; 32],
|
||||
&code,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
);
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_receipt": forged }).to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"forged receipt must fail"
|
||||
);
|
||||
|
||||
// 3. Receipt bound to a DIFFERENT invite code -> 403.
|
||||
let key = crate::invite_token::derive_invite_key(&state.relay_keypair);
|
||||
let other = crate::invite_token::mint_policy_acceptance(
|
||||
&key,
|
||||
"some-other-code",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
);
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_receipt": other }).to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"cross-invite receipt must fail"
|
||||
);
|
||||
|
||||
// 4. Receipt for a STALE policy version -> 403.
|
||||
let stale = crate::invite_token::mint_policy_acceptance(
|
||||
&key,
|
||||
&code,
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
);
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_receipt": stale }).to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"stale-version receipt must fail"
|
||||
);
|
||||
|
||||
// 5. accept-policy without age confirmation -> 400.
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/accept-policy",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": false })
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"age not confirmed must be rejected when required"
|
||||
);
|
||||
|
||||
// 5b. accept-policy with stale version -> 400.
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/accept-policy",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_version": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "age_confirmed": true })
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// 6. Legit flow: accept-policy -> receipt -> claim OK.
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/accept-policy",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": true })
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let receipt = read_json(response)
|
||||
.await
|
||||
.get("receipt")
|
||||
.and_then(Value::as_str)
|
||||
.expect("receipt")
|
||||
.to_string();
|
||||
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code, "policy_receipt": receipt }).to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"legit receipt claim must succeed"
|
||||
);
|
||||
let json = read_json(response).await;
|
||||
assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
|
||||
|
||||
let member = state
|
||||
.db
|
||||
.get_relay_member(community.id, &joiner.public_key().to_hex())
|
||||
.await
|
||||
.expect("member lookup")
|
||||
.expect("joiner is now a member");
|
||||
assert_eq!(member.role, "member");
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.has_join_policy_acceptance(
|
||||
community.id,
|
||||
&joiner.public_key().to_hex(),
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
)
|
||||
.await
|
||||
.expect("policy acceptance lookup"),
|
||||
"accepted policy version must be persisted",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn non_admin_cannot_mint() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -23,6 +24,19 @@ pub enum ConfigError {
|
||||
InvalidValue(String),
|
||||
}
|
||||
|
||||
/// Relay-hosted policy content presented on join surfaces.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JoinPolicyConfig {
|
||||
/// Operator-provided Terms of Service document in Markdown.
|
||||
pub terms_markdown: Option<String>,
|
||||
/// Operator-provided Privacy Policy document in Markdown.
|
||||
pub privacy_markdown: Option<String>,
|
||||
/// Whether join surfaces must collect an 18+ attestation.
|
||||
pub age_attestation_required: bool,
|
||||
/// Content-derived identifier binding receipts to the exact policy revision.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Relay runtime configuration, loaded from environment variables.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -197,6 +211,10 @@ pub struct Config {
|
||||
/// Hard timeout for one gateway delivery request.
|
||||
pub push_gateway_timeout: Duration,
|
||||
|
||||
/// Optional relay-hosted policy shown on join surfaces. Disabled when no
|
||||
/// documents or age attestation are configured.
|
||||
pub join_policy: Option<JoinPolicyConfig>,
|
||||
|
||||
/// Optional path to the web UI `dist/` directory.
|
||||
/// When set, the relay serves the invite landing page and its static assets.
|
||||
/// When unset, no static file serving happens (relay behaves as before).
|
||||
@@ -256,6 +274,22 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
|
||||
match std::env::var(name) {
|
||||
Err(std::env::VarError::NotPresent) => Ok(false),
|
||||
Err(error) => Err(ConfigError::InvalidValue(format!(
|
||||
"{name} must be valid UTF-8: {error}"
|
||||
))),
|
||||
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "on" => Ok(true),
|
||||
"false" | "0" | "off" | "" => Ok(false),
|
||||
_ => Err(ConfigError::InvalidValue(format!(
|
||||
"{name} must be true or false"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_git_repo_path(
|
||||
raw: impl Into<std::path::PathBuf>,
|
||||
) -> Result<std::path::PathBuf, ConfigError> {
|
||||
@@ -602,6 +636,44 @@ impl Config {
|
||||
};
|
||||
let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
|
||||
|
||||
const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024;
|
||||
let read_policy_markdown = |name: &str| -> Result<Option<String>, ConfigError> {
|
||||
let value = std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if value
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.len() > MAX_POLICY_MARKDOWN_BYTES)
|
||||
{
|
||||
return Err(ConfigError::InvalidValue(format!(
|
||||
"{name} must contain at most {MAX_POLICY_MARKDOWN_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
};
|
||||
let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?;
|
||||
let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?;
|
||||
let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?;
|
||||
let join_policy = if terms_markdown.is_none()
|
||||
&& privacy_markdown.is_none()
|
||||
&& !age_attestation_required
|
||||
{
|
||||
None
|
||||
} else {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(terms_markdown.as_deref().unwrap_or_default().as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(privacy_markdown.as_deref().unwrap_or_default().as_bytes());
|
||||
hasher.update([0, u8::from(age_attestation_required)]);
|
||||
Some(JoinPolicyConfig {
|
||||
terms_markdown,
|
||||
privacy_markdown,
|
||||
age_attestation_required,
|
||||
version: hex::encode(hasher.finalize()),
|
||||
})
|
||||
};
|
||||
|
||||
// Web UI static file serving
|
||||
let web_dir = std::env::var("BUZZ_WEB_DIR")
|
||||
.ok()
|
||||
@@ -673,6 +745,7 @@ impl Config {
|
||||
push_executor_key_id,
|
||||
push_gateway_delivery_url,
|
||||
push_gateway_timeout,
|
||||
join_policy,
|
||||
web_dir,
|
||||
serve_git_web_gui,
|
||||
})
|
||||
@@ -729,6 +802,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_policy_age_attestation_rejects_invalid_boolean() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
|
||||
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
|
||||
let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
|
||||
} else {
|
||||
std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
|
||||
}
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ConfigError::InvalidValue(ref message))
|
||||
if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
|
||||
@@ -327,3 +327,79 @@ mod tests {
|
||||
assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole));
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-lived proof that the browser accepted the configured join policy.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PolicyAcceptancePayload {
|
||||
/// SHA-256 of the invite code this acceptance is bound to.
|
||||
pub c: String,
|
||||
/// Configured policy version.
|
||||
pub v: String,
|
||||
/// Receipt expiry (unix seconds).
|
||||
pub e: u64,
|
||||
}
|
||||
|
||||
/// Mint a relay-authenticated, invite-bound policy acceptance receipt.
|
||||
pub fn mint_policy_acceptance(key: &[u8; 32], code: &str, version: &str) -> String {
|
||||
let payload = PolicyAcceptancePayload {
|
||||
c: hex::encode(Sha256::digest(code.as_bytes())),
|
||||
v: version.to_string(),
|
||||
e: now_unix() + 10 * 60,
|
||||
};
|
||||
let bytes = serde_json::to_vec(&payload).expect("policy acceptance serializes");
|
||||
format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(&bytes),
|
||||
URL_SAFE_NO_PAD.encode(sign_payload(key, &bytes))
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify a policy receipt and bind it to the submitted invite and current policy.
|
||||
pub fn verify_policy_acceptance(
|
||||
key: &[u8; 32],
|
||||
receipt: &str,
|
||||
code: &str,
|
||||
version: &str,
|
||||
) -> Result<PolicyAcceptancePayload, InviteError> {
|
||||
if receipt.len() > 2048 {
|
||||
return Err(InviteError::Malformed);
|
||||
}
|
||||
let (payload, signature) = receipt.split_once('.').ok_or(InviteError::Malformed)?;
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| InviteError::Malformed)?;
|
||||
let signature = URL_SAFE_NO_PAD
|
||||
.decode(signature)
|
||||
.map_err(|_| InviteError::Malformed)?;
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
|
||||
mac.update(&bytes);
|
||||
mac.verify_slice(&signature)
|
||||
.map_err(|_| InviteError::BadSignature)?;
|
||||
let payload: PolicyAcceptancePayload =
|
||||
serde_json::from_slice(&bytes).map_err(|_| InviteError::Malformed)?;
|
||||
if payload.e < now_unix() {
|
||||
return Err(InviteError::Expired);
|
||||
}
|
||||
let expected_code = hex::encode(Sha256::digest(code.as_bytes()));
|
||||
if payload.c != expected_code || payload.v != version {
|
||||
return Err(InviteError::Malformed);
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod policy_acceptance_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn policy_receipt_is_bound_to_invite_and_version() {
|
||||
let key = [7_u8; 32];
|
||||
let receipt = mint_policy_acceptance(&key, "invite-a", "v1");
|
||||
let payload =
|
||||
verify_policy_acceptance(&key, &receipt, "invite-a", "v1").expect("valid receipt");
|
||||
assert_eq!(payload.v, "v1");
|
||||
assert!(verify_policy_acceptance(&key, &receipt, "invite-b", "v1").is_err());
|
||||
assert!(verify_policy_acceptance(&key, &receipt, "invite-a", "v2").is_err());
|
||||
assert!(verify_policy_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
)
|
||||
// Relay invites: mint (owner/admin) + claim (membership-gate exempt)
|
||||
.route("/api/invites", post(api::invites::mint_invite))
|
||||
.route("/api/join-policy", get(api::invites::join_policy))
|
||||
.route(
|
||||
"/api/invites/accept-policy",
|
||||
post(api::invites::accept_policy),
|
||||
)
|
||||
.route("/api/invites/claim", post(api::invites::claim_invite))
|
||||
// Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
|
||||
.route("/moderation/reports", get(api::bridge::moderation_reports))
|
||||
|
||||
@@ -59,6 +59,7 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
let mut relay: Option<String> = None;
|
||||
let mut code: Option<String> = None;
|
||||
let mut policy_receipt: Option<String> = None;
|
||||
for (k, v) in url.query_pairs() {
|
||||
let v = v.into_owned();
|
||||
if v.is_empty() {
|
||||
@@ -67,6 +68,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
match k.as_ref() {
|
||||
"relay" => relay = Some(v),
|
||||
"code" => code = Some(v),
|
||||
"policy_receipt" => policy_receipt = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +80,7 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
Some(serde_json::json!({
|
||||
"relayUrl": relay_url,
|
||||
"code": code,
|
||||
"policyReceipt": policy_receipt,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -337,6 +340,17 @@ mod tests {
|
||||
let payload = parse_join_deep_link(&url).expect("required params present");
|
||||
assert_eq!(payload["relayUrl"], "wss://relay.example");
|
||||
assert_eq!(payload["code"], "abc.def");
|
||||
assert!(payload["policyReceipt"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_deep_link_extracts_policy_receipt() {
|
||||
let url = Url::parse(
|
||||
"buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value",
|
||||
)
|
||||
.unwrap();
|
||||
let payload = parse_join_deep_link(&url).expect("required params present");
|
||||
assert_eq!(payload["policyReceipt"], "receipt.value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
inviteErrorMessage,
|
||||
isInviteExpiredError,
|
||||
} from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
import {
|
||||
acceptJoinPolicy,
|
||||
claimInvite,
|
||||
getJoinPolicy,
|
||||
type JoinPolicy,
|
||||
} from "@/shared/api/invites";
|
||||
import { validateReposDir } from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
|
||||
|
||||
type AddCommunityDialogProps = {
|
||||
open: boolean;
|
||||
@@ -38,6 +44,8 @@ export function AddCommunityDialog({
|
||||
const [token, setToken] = React.useState("");
|
||||
const [inviteCode, setInviteCode] = React.useState("");
|
||||
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
||||
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [reposDir, setReposDir] = React.useState("");
|
||||
const [reposDirError, setReposDirError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -48,6 +56,8 @@ export function AddCommunityDialog({
|
||||
setToken("");
|
||||
setInviteCode("");
|
||||
setInviteError(null);
|
||||
setJoinPolicy(null);
|
||||
setAgeConfirmed(false);
|
||||
setReposDir("");
|
||||
setReposDirError(null);
|
||||
}, [onOpenChange]);
|
||||
@@ -71,21 +81,45 @@ export function AddCommunityDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// If the relay handed out an invite code, claim it before saving the
|
||||
// community — a closed relay would otherwise reject the connection.
|
||||
const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
|
||||
if (inviteCode.trim()) {
|
||||
try {
|
||||
await claimInvite(normalizedRelayUrl, inviteCode.trim());
|
||||
} catch (error) {
|
||||
const message = inviteErrorMessage(error);
|
||||
setInviteError(
|
||||
isInviteExpiredError(error)
|
||||
? "This invite code has expired — ask for a new one."
|
||||
: `Invite rejected: ${message}`,
|
||||
);
|
||||
try {
|
||||
const policy = await getJoinPolicy(normalizedRelayUrl);
|
||||
if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) {
|
||||
setJoinPolicy(policy);
|
||||
setAgeConfirmed(false);
|
||||
setInviteError("Review this relay's join policy below.");
|
||||
return;
|
||||
}
|
||||
if (policy?.ageAttestationRequired && !ageConfirmed) {
|
||||
setInviteError("Confirm that you are at least 18 years old.");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the relay handed out an invite code, claim it before saving the
|
||||
// community — a closed relay would otherwise reject the connection.
|
||||
if (inviteCode.trim()) {
|
||||
const policyReceipt = policy
|
||||
? await acceptJoinPolicy(
|
||||
normalizedRelayUrl,
|
||||
inviteCode.trim(),
|
||||
policy.version,
|
||||
ageConfirmed,
|
||||
)
|
||||
: undefined;
|
||||
await claimInvite(
|
||||
normalizedRelayUrl,
|
||||
inviteCode.trim(),
|
||||
policyReceipt,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = inviteErrorMessage(error);
|
||||
setInviteError(
|
||||
isInviteExpiredError(error)
|
||||
? "This invite code has expired — ask for a new one."
|
||||
: `Community rejected: ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const community: Community = {
|
||||
@@ -100,7 +134,17 @@ export function AddCommunityDialog({
|
||||
onSubmit(community);
|
||||
handleClose();
|
||||
},
|
||||
[name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose],
|
||||
[
|
||||
name,
|
||||
relayUrl,
|
||||
token,
|
||||
inviteCode,
|
||||
reposDir,
|
||||
joinPolicy,
|
||||
ageConfirmed,
|
||||
onSubmit,
|
||||
handleClose,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -127,7 +171,12 @@ export function AddCommunityDialog({
|
||||
<Input
|
||||
autoFocus
|
||||
id="ws-relay-url"
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setRelayUrl(e.target.value);
|
||||
setInviteError(null);
|
||||
setJoinPolicy(null);
|
||||
setAgeConfirmed(false);
|
||||
}}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
value={relayUrl}
|
||||
@@ -184,6 +233,8 @@ export function AddCommunityDialog({
|
||||
onChange={(e) => {
|
||||
setInviteCode(e.target.value);
|
||||
setInviteError(null);
|
||||
setJoinPolicy(null);
|
||||
setAgeConfirmed(false);
|
||||
}}
|
||||
placeholder="Paste an invite code for a members-only relay"
|
||||
type="text"
|
||||
@@ -192,6 +243,16 @@ export function AddCommunityDialog({
|
||||
{inviteError ? (
|
||||
<p className="text-xs text-destructive">{inviteError}</p>
|
||||
) : null}
|
||||
{joinPolicy ? (
|
||||
<JoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
onAgeConfirmedChange={(confirmed) => {
|
||||
setAgeConfirmed(confirmed);
|
||||
setInviteError(null);
|
||||
}}
|
||||
policy={joinPolicy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
@@ -230,7 +291,13 @@ export function AddCommunityDialog({
|
||||
<Button onClick={handleClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!relayUrl.trim()} type="submit">
|
||||
<Button
|
||||
disabled={
|
||||
!relayUrl.trim() ||
|
||||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
Add Community
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
|
||||
import { getJoinPolicy, type JoinPolicy } from "@/shared/api/invites";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
|
||||
import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
|
||||
|
||||
export type CommunityEditFormProps = {
|
||||
@@ -9,6 +12,7 @@ export type CommunityEditFormProps = {
|
||||
initialName: string;
|
||||
initialRelayUrl: string;
|
||||
isSubmitting?: boolean;
|
||||
joinPolicyRequired?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string, relayUrl: string) => void;
|
||||
submitLabel: string;
|
||||
@@ -19,6 +23,7 @@ export function CommunityEditForm({
|
||||
initialName,
|
||||
initialRelayUrl,
|
||||
isSubmitting = false,
|
||||
joinPolicyRequired = false,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
@@ -29,6 +34,11 @@ export function CommunityEditForm({
|
||||
const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
|
||||
const [isProbing, setIsProbing] = React.useState(false);
|
||||
const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
|
||||
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
|
||||
const [policyRelayUrl, setPolicyRelayUrl] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
|
||||
const cancelRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -52,6 +62,33 @@ export function CommunityEditForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (joinPolicyRequired) {
|
||||
try {
|
||||
const policy = await getJoinPolicy(normalizedUrl);
|
||||
if (!policy) {
|
||||
onSubmit(trimmedName, normalizedUrl);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!joinPolicy ||
|
||||
joinPolicy.version !== policy.version ||
|
||||
policyRelayUrl !== normalizedUrl
|
||||
) {
|
||||
setJoinPolicy(policy);
|
||||
setPolicyRelayUrl(normalizedUrl);
|
||||
setAgeConfirmed(false);
|
||||
return;
|
||||
}
|
||||
if (policy.ageAttestationRequired && !ageConfirmed) {
|
||||
setError("Confirm that you are at least 18 years old.");
|
||||
return;
|
||||
}
|
||||
} catch (policyError) {
|
||||
setError(inviteErrorMessage(policyError));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (useAnywayOverride) {
|
||||
onSubmit(trimmedName, normalizedUrl);
|
||||
return;
|
||||
@@ -79,7 +116,16 @@ export function CommunityEditForm({
|
||||
|
||||
onSubmit(trimmedName, normalizedUrl);
|
||||
},
|
||||
[name, onSubmit, relayUrl, useAnywayOverride],
|
||||
[
|
||||
ageConfirmed,
|
||||
joinPolicy,
|
||||
joinPolicyRequired,
|
||||
name,
|
||||
onSubmit,
|
||||
policyRelayUrl,
|
||||
relayUrl,
|
||||
useAnywayOverride,
|
||||
],
|
||||
);
|
||||
|
||||
const handleUseAnyway = React.useCallback(() => {
|
||||
@@ -139,6 +185,9 @@ export function CommunityEditForm({
|
||||
setError(null);
|
||||
setProbeWarning(null);
|
||||
setUseAnywayOverride(false);
|
||||
setJoinPolicy(null);
|
||||
setPolicyRelayUrl(null);
|
||||
setAgeConfirmed(false);
|
||||
}}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
@@ -146,10 +195,26 @@ export function CommunityEditForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{joinPolicy ? (
|
||||
<JoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
onAgeConfirmedChange={(confirmed) => {
|
||||
setAgeConfirmed(confirmed);
|
||||
setError(null);
|
||||
}}
|
||||
policy={joinPolicy}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full flex-col gap-3 pt-1">
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
disabled={isBusy || !name.trim() || !relayUrl.trim()}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!name.trim() ||
|
||||
!relayUrl.trim() ||
|
||||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{isProbing ? (
|
||||
|
||||
@@ -5,9 +5,14 @@ import {
|
||||
getIdentity,
|
||||
importIdentity as tauriImportIdentity,
|
||||
} from "@/shared/api/tauriIdentity";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
import {
|
||||
claimInvite,
|
||||
getJoinPolicy,
|
||||
type JoinPolicy,
|
||||
} from "@/shared/api/invites";
|
||||
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
|
||||
import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
|
||||
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
|
||||
import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
|
||||
import {
|
||||
type OnboardingTransitionDirection,
|
||||
@@ -48,13 +53,19 @@ function wait(ms: number) {
|
||||
}
|
||||
|
||||
function NostrKeyImportPage({
|
||||
ageConfirmed,
|
||||
connectionError,
|
||||
disabled,
|
||||
joinPolicy,
|
||||
onAgeConfirmedChange,
|
||||
onBack,
|
||||
onImport,
|
||||
}: {
|
||||
ageConfirmed: boolean;
|
||||
connectionError: string | null;
|
||||
disabled: boolean;
|
||||
joinPolicy: JoinPolicy | null | undefined;
|
||||
onAgeConfirmedChange: (confirmed: boolean) => void;
|
||||
onBack: () => void;
|
||||
onImport: (nsec: string) => Promise<void>;
|
||||
}) {
|
||||
@@ -75,8 +86,22 @@ function NostrKeyImportPage({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{joinPolicy ? (
|
||||
<div className="mt-6 w-full">
|
||||
<JoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
onAgeConfirmedChange={onAgeConfirmedChange}
|
||||
policy={joinPolicy}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NostrKeyImportForm
|
||||
disabled={disabled}
|
||||
disabled={
|
||||
disabled ||
|
||||
joinPolicy === undefined ||
|
||||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
|
||||
}
|
||||
errorMessage={connectionError}
|
||||
onBack={onBack}
|
||||
onImport={onImport}
|
||||
@@ -97,8 +122,36 @@ export function WelcomeSetup({
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isRedeeming, setIsRedeeming] = React.useState(false);
|
||||
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
||||
const [defaultJoinPolicy, setDefaultJoinPolicy] = React.useState<
|
||||
JoinPolicy | null | undefined
|
||||
>(undefined);
|
||||
const [defaultAgeConfirmed, setDefaultAgeConfirmed] = React.useState(false);
|
||||
const systemColorScheme = useSystemColorScheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDefaultAgeConfirmed(false);
|
||||
if (isLocalDevRelayUrl(defaultRelayUrl)) {
|
||||
setDefaultJoinPolicy(null);
|
||||
return;
|
||||
}
|
||||
setDefaultJoinPolicy(undefined);
|
||||
void getJoinPolicy(defaultRelayUrl)
|
||||
.then((policy) => {
|
||||
if (!cancelled) {
|
||||
setDefaultJoinPolicy(policy);
|
||||
}
|
||||
})
|
||||
.catch((policyError) => {
|
||||
if (!cancelled) {
|
||||
setError(inviteErrorMessage(policyError));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [defaultRelayUrl]);
|
||||
|
||||
const handleConnect = React.useCallback(
|
||||
async (relayUrl: string, communityName?: string, pubkey?: string) => {
|
||||
const trimmedUrl = relayUrl.trim();
|
||||
@@ -159,11 +212,11 @@ export function WelcomeSetup({
|
||||
);
|
||||
|
||||
const handleWelcomeInviteRedeem = React.useCallback(
|
||||
async (relayWsUrl: string, code: string) => {
|
||||
async (relayWsUrl: string, code: string, policyReceipt?: string) => {
|
||||
setIsRedeeming(true);
|
||||
setInviteError(null);
|
||||
try {
|
||||
await claimInvite(relayWsUrl, code);
|
||||
await claimInvite(relayWsUrl, code, policyReceipt);
|
||||
await handleConnect(relayWsUrl);
|
||||
} catch (err) {
|
||||
setInviteError(inviteErrorMessage(err));
|
||||
@@ -249,10 +302,24 @@ export function WelcomeSetup({
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col gap-3">
|
||||
{defaultJoinPolicy ? (
|
||||
<JoinPolicyNotice
|
||||
ageConfirmed={defaultAgeConfirmed}
|
||||
onAgeConfirmedChange={setDefaultAgeConfirmed}
|
||||
policy={defaultJoinPolicy}
|
||||
/>
|
||||
) : null}
|
||||
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
aria-disabled={isConnecting}
|
||||
disabled={
|
||||
defaultJoinPolicy === undefined ||
|
||||
Boolean(
|
||||
defaultJoinPolicy?.ageAttestationRequired &&
|
||||
!defaultAgeConfirmed,
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (isConnecting) {
|
||||
return;
|
||||
@@ -341,6 +408,7 @@ export function WelcomeSetup({
|
||||
initialName=""
|
||||
initialRelayUrl=""
|
||||
isSubmitting={isConnecting}
|
||||
joinPolicyRequired
|
||||
onCancel={showWelcomePage}
|
||||
onSubmit={(name, url) => {
|
||||
void handleConnect(url, name);
|
||||
@@ -380,16 +448,23 @@ export function WelcomeSetup({
|
||||
error={inviteError}
|
||||
isRedeeming={isRedeeming}
|
||||
onCancel={showWelcomePage}
|
||||
onRedeem={(relayWsUrl, code) => {
|
||||
void handleWelcomeInviteRedeem(relayWsUrl, code);
|
||||
onRedeem={(relayWsUrl, code, policyReceipt) => {
|
||||
void handleWelcomeInviteRedeem(
|
||||
relayWsUrl,
|
||||
code,
|
||||
policyReceipt,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
) : (
|
||||
<NostrKeyImportPage
|
||||
ageConfirmed={defaultAgeConfirmed}
|
||||
connectionError={error}
|
||||
disabled={isConnecting}
|
||||
joinPolicy={defaultJoinPolicy}
|
||||
onAgeConfirmedChange={setDefaultAgeConfirmed}
|
||||
onBack={showWelcomePage}
|
||||
onImport={handleNostrImport}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { parseInviteInput } from "@/shared/api/inviteHelpers";
|
||||
import {
|
||||
inviteErrorMessage,
|
||||
parseInviteInput,
|
||||
} from "@/shared/api/inviteHelpers";
|
||||
import {
|
||||
acceptJoinPolicy,
|
||||
getJoinPolicy,
|
||||
type JoinPolicy,
|
||||
} from "@/shared/api/invites";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { JoinPolicyNotice } from "./JoinPolicyNotice";
|
||||
|
||||
type InviteRedeemFormProps = {
|
||||
/**
|
||||
@@ -16,7 +25,7 @@ type InviteRedeemFormProps = {
|
||||
error: string | null;
|
||||
isRedeeming: boolean;
|
||||
onCancel: () => void;
|
||||
onRedeem: (relayWsUrl: string, code: string) => void;
|
||||
onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
|
||||
};
|
||||
|
||||
export function InviteRedeemForm({
|
||||
@@ -30,6 +39,14 @@ export function InviteRedeemForm({
|
||||
const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
|
||||
defaultRelayUrl ?? "",
|
||||
);
|
||||
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
|
||||
const [policyInvite, setPolicyInvite] = React.useState<{
|
||||
relayWsUrl: string;
|
||||
code: string;
|
||||
} | null>(null);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [policyError, setPolicyError] = React.useState<string | null>(null);
|
||||
const [isLoadingPolicy, setIsLoadingPolicy] = React.useState(false);
|
||||
|
||||
const parsed = React.useMemo(
|
||||
() => parseInviteInput(inviteInput),
|
||||
@@ -44,17 +61,61 @@ export function InviteRedeemForm({
|
||||
(isBareCode && bareCodeRelayUrl.trim().length > 0));
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!parsed) return;
|
||||
|
||||
if ("relayWsUrl" in parsed) {
|
||||
onRedeem(parsed.relayWsUrl, parsed.code);
|
||||
} else if (bareCodeRelayUrl.trim()) {
|
||||
onRedeem(bareCodeRelayUrl.trim(), parsed.code);
|
||||
const relayWsUrl =
|
||||
"relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
|
||||
if (!relayWsUrl) return;
|
||||
|
||||
setPolicyError(null);
|
||||
setIsLoadingPolicy(true);
|
||||
try {
|
||||
const policy = await getJoinPolicy(relayWsUrl);
|
||||
if (!policy) {
|
||||
onRedeem(relayWsUrl, parsed.code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!joinPolicy ||
|
||||
joinPolicy.version !== policy.version ||
|
||||
policyInvite?.relayWsUrl !== relayWsUrl ||
|
||||
policyInvite.code !== parsed.code
|
||||
) {
|
||||
setJoinPolicy(policy);
|
||||
setPolicyInvite({ relayWsUrl, code: parsed.code });
|
||||
setAgeConfirmed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.ageAttestationRequired && !ageConfirmed) {
|
||||
setPolicyError("Confirm that you are at least 18 years old.");
|
||||
return;
|
||||
}
|
||||
|
||||
const receipt = await acceptJoinPolicy(
|
||||
relayWsUrl,
|
||||
parsed.code,
|
||||
policy.version,
|
||||
ageConfirmed,
|
||||
);
|
||||
onRedeem(relayWsUrl, parsed.code, receipt);
|
||||
} catch (policyFetchError) {
|
||||
setPolicyError(inviteErrorMessage(policyFetchError));
|
||||
} finally {
|
||||
setIsLoadingPolicy(false);
|
||||
}
|
||||
},
|
||||
[bareCodeRelayUrl, onRedeem, parsed],
|
||||
[
|
||||
ageConfirmed,
|
||||
bareCodeRelayUrl,
|
||||
joinPolicy,
|
||||
onRedeem,
|
||||
parsed,
|
||||
policyInvite,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -74,7 +135,13 @@ export function InviteRedeemForm({
|
||||
data-testid="invite-redeem-input"
|
||||
disabled={isRedeeming}
|
||||
id="invite-input"
|
||||
onChange={(event) => setInviteInput(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setInviteInput(event.target.value);
|
||||
setJoinPolicy(null);
|
||||
setPolicyInvite(null);
|
||||
setAgeConfirmed(false);
|
||||
setPolicyError(null);
|
||||
}}
|
||||
placeholder="https://relay.example.com/invite/abc123 or paste a code"
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
@@ -94,7 +161,13 @@ export function InviteRedeemForm({
|
||||
className="h-10 bg-background"
|
||||
disabled={isRedeeming}
|
||||
id="invite-relay-url"
|
||||
onChange={(event) => setBareCodeRelayUrl(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setBareCodeRelayUrl(event.target.value);
|
||||
setJoinPolicy(null);
|
||||
setPolicyInvite(null);
|
||||
setAgeConfirmed(false);
|
||||
setPolicyError(null);
|
||||
}}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
value={bareCodeRelayUrl}
|
||||
@@ -102,6 +175,21 @@ export function InviteRedeemForm({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{joinPolicy ? (
|
||||
<JoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
onAgeConfirmedChange={(confirmed) => {
|
||||
setAgeConfirmed(confirmed);
|
||||
setPolicyError(null);
|
||||
}}
|
||||
policy={joinPolicy}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{policyError ? (
|
||||
<p className="text-center text-sm text-destructive">{policyError}</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-center text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
@@ -109,11 +197,21 @@ export function InviteRedeemForm({
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
data-testid="invite-redeem-submit"
|
||||
disabled={!canSubmit || isRedeeming}
|
||||
disabled={
|
||||
!canSubmit ||
|
||||
isRedeeming ||
|
||||
isLoadingPolicy ||
|
||||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{isRedeeming ? (
|
||||
<Spinner aria-label="Redeeming invite" className="h-4 w-4 border-2" />
|
||||
{isRedeeming || isLoadingPolicy ? (
|
||||
<Spinner
|
||||
aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
|
||||
className="h-4 w-4 border-2"
|
||||
/>
|
||||
) : joinPolicy ? (
|
||||
"Accept and redeem invite"
|
||||
) : (
|
||||
"Redeem invite"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { JoinPolicy } from "@/shared/api/invites";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
|
||||
type JoinPolicyNoticeProps = {
|
||||
ageConfirmed: boolean;
|
||||
onAgeConfirmedChange: (confirmed: boolean) => void;
|
||||
policy: JoinPolicy;
|
||||
};
|
||||
|
||||
export function JoinPolicyNotice({
|
||||
ageConfirmed,
|
||||
onAgeConfirmedChange,
|
||||
policy,
|
||||
}: JoinPolicyNoticeProps) {
|
||||
const [openDocument, setOpenDocument] = React.useState<
|
||||
"terms" | "privacy" | null
|
||||
>(null);
|
||||
const markdown =
|
||||
openDocument === "terms" ? policy.termsMarkdown : policy.privacyMarkdown;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3 text-left text-sm">
|
||||
{policy.ageAttestationRequired ? (
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
checked={ageConfirmed}
|
||||
className="mt-1"
|
||||
onChange={(event) => onAgeConfirmedChange(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>I am 18 years of age or older.</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{policy.termsMarkdown || policy.privacyMarkdown ? (
|
||||
<p className="text-muted-foreground">
|
||||
By proceeding you agree to the Buzz{" "}
|
||||
{policy.termsMarkdown ? (
|
||||
<Button
|
||||
className="h-auto p-0 align-baseline"
|
||||
onClick={() => setOpenDocument("terms")}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
Terms of Service
|
||||
</Button>
|
||||
) : null}
|
||||
{policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
|
||||
{policy.privacyMarkdown ? (
|
||||
<Button
|
||||
className="h-auto p-0 align-baseline"
|
||||
onClick={() => setOpenDocument("privacy")}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
) : null}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenDocument(null);
|
||||
}}
|
||||
open={openDocument !== null}
|
||||
>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{openDocument === "terms" ? "Terms of Service" : "Privacy Policy"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{markdown ? <Markdown content={markdown} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,11 +89,11 @@ export function MembershipDenied({
|
||||
}, [onImportKey, previewNpub, trimmedNsec]);
|
||||
|
||||
const handleInviteRedeem = React.useCallback(
|
||||
async (relayWsUrl: string, code: string) => {
|
||||
async (relayWsUrl: string, code: string, policyReceipt?: string) => {
|
||||
setIsRedeeming(true);
|
||||
setInviteError(null);
|
||||
try {
|
||||
await claimInvite(relayWsUrl, code);
|
||||
await claimInvite(relayWsUrl, code, policyReceipt);
|
||||
onInviteRedeemed(relayWsUrl);
|
||||
} catch (error) {
|
||||
setInviteError(inviteErrorMessage(error));
|
||||
@@ -170,8 +170,8 @@ export function MembershipDenied({
|
||||
setInviteError(null);
|
||||
setIsInviteFormOpen(false);
|
||||
}}
|
||||
onRedeem={(relayWsUrl, code) => {
|
||||
void handleInviteRedeem(relayWsUrl, code);
|
||||
onRedeem={(relayWsUrl, code, policyReceipt) => {
|
||||
void handleInviteRedeem(relayWsUrl, code, policyReceipt);
|
||||
}}
|
||||
/>
|
||||
) : isImportFormOpen ? (
|
||||
|
||||
@@ -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/),
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,13 @@ export type MintedInvite = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type JoinPolicy = {
|
||||
termsMarkdown?: string;
|
||||
privacyMarkdown?: string;
|
||||
ageAttestationRequired: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type ClaimResult = {
|
||||
status: "joined" | "already_member";
|
||||
communityId: string;
|
||||
@@ -84,6 +91,57 @@ async function invitePost<T>(
|
||||
return json as T;
|
||||
}
|
||||
|
||||
/** Fetch relay-hosted policy content for any join surface. */
|
||||
export async function getJoinPolicy(
|
||||
relayWsUrl: string,
|
||||
): Promise<JoinPolicy | null> {
|
||||
const base = relayHttpFromWs(relayWsUrl);
|
||||
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
|
||||
// Relays predating join-policy support have no configured policy.
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const raw = (await response.json()) as {
|
||||
policy?: {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
age_attestation_required: boolean;
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
return raw.policy
|
||||
? {
|
||||
termsMarkdown: raw.policy.terms_markdown,
|
||||
privacyMarkdown: raw.policy.privacy_markdown,
|
||||
ageAttestationRequired: raw.policy.age_attestation_required,
|
||||
version: raw.policy.version,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Accept the current join policy for an invite and receive a bound receipt. */
|
||||
export async function acceptJoinPolicy(
|
||||
relayWsUrl: string,
|
||||
code: string,
|
||||
policyVersion: string,
|
||||
ageConfirmed: boolean,
|
||||
): Promise<string> {
|
||||
const base = relayHttpFromWs(relayWsUrl);
|
||||
const response = await fetch(
|
||||
`${base.replace(/\/+$/, "")}/api/invites/accept-policy`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
policy_version: policyVersion,
|
||||
age_confirmed: ageConfirmed,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return ((await response.json()) as { receipt: string }).receipt;
|
||||
}
|
||||
|
||||
/** Mint an invite code on the active community's relay (owner/admin only). */
|
||||
export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
|
||||
const base = await getRelayHttpUrl();
|
||||
@@ -103,9 +161,10 @@ export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
|
||||
export async function claimInvite(
|
||||
relayWsUrl: string,
|
||||
code: string,
|
||||
policyReceipt?: string,
|
||||
): Promise<ClaimResult> {
|
||||
const base = relayHttpFromWs(relayWsUrl);
|
||||
const body = JSON.stringify({ code });
|
||||
const body = JSON.stringify({ code, policy_receipt: policyReceipt });
|
||||
const raw = await invitePost<{
|
||||
status: "joined" | "already_member";
|
||||
community_id: string;
|
||||
|
||||
@@ -49,6 +49,7 @@ export type NostrBindDeepLinkPayload = {
|
||||
export type JoinDeepLinkPayload = {
|
||||
relayUrl: string;
|
||||
code: string;
|
||||
policyReceipt: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -90,8 +91,8 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
|
||||
});
|
||||
|
||||
const joinPromise = listen<JoinDeepLinkPayload>("deep-link-join", (event) => {
|
||||
const { relayUrl, code } = event.payload;
|
||||
void claimInvite(relayUrl, code)
|
||||
const { relayUrl, code, policyReceipt } = event.payload;
|
||||
void claimInvite(relayUrl, code, policyReceipt ?? undefined)
|
||||
.then((result) => {
|
||||
const name = addAndSwitch(relayUrl);
|
||||
toast.success(
|
||||
|
||||
@@ -568,6 +568,16 @@ test("first-run default community handoff gives immediate stepper feedback", asy
|
||||
skipCommunitySeed: true,
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
"https://default.example.com/api/join-policy",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "{}",
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByText("Welcome to Buzz")).toBeVisible();
|
||||
@@ -609,6 +619,16 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => {
|
||||
skipOnboardingSeed: true,
|
||||
skipCommunitySeed: true,
|
||||
});
|
||||
await page.route(
|
||||
"https://default.example.com/api/join-policy",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "{}",
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("welcome-continue-nostr").click();
|
||||
@@ -1824,7 +1844,41 @@ test("denied on relay A then paste relay B invite URL switches community to B",
|
||||
// Intercept the claimInvite POST to relay B so it succeeds.
|
||||
const relayBUrl = "wss://relay-b.example.com";
|
||||
const relayBHttpUrl = "https://relay-b.example.com";
|
||||
const policyReceipt = "relay-signed-policy-receipt";
|
||||
await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
policy: {
|
||||
terms_markdown: "# Terms",
|
||||
privacy_markdown: "# Privacy",
|
||||
age_attestation_required: true,
|
||||
version: "policy-v1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
`${relayBHttpUrl}/api/invites/accept-policy`,
|
||||
async (route) => {
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
code: "test-invite-code",
|
||||
policy_version: "policy-v1",
|
||||
age_confirmed: true,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ receipt: policyReceipt }),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
|
||||
expect(route.request().postDataJSON()).toMatchObject({
|
||||
code: "test-invite-code",
|
||||
policy_receipt: policyReceipt,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -1843,6 +1897,9 @@ test("denied on relay A then paste relay B invite URL switches community to B",
|
||||
.getByTestId("invite-redeem-input")
|
||||
.fill(`${relayBHttpUrl}/invite/test-invite-code`);
|
||||
await page.getByTestId("invite-redeem-submit").click();
|
||||
await expect(page.getByText("I am 18 years of age or older.")).toBeVisible();
|
||||
await page.getByLabel("I am 18 years of age or older.").check();
|
||||
await page.getByTestId("invite-redeem-submit").click();
|
||||
|
||||
// After successful claim, the community should switch to relay B's URL.
|
||||
await expect
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Durable evidence of the policy version accepted when an invite claim grants
|
||||
-- relay membership. Rows are scoped to the same community and member identity
|
||||
-- as relay_members and are deleted with that membership.
|
||||
CREATE TABLE join_policy_acceptances (
|
||||
community_id UUID NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
|
||||
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, pubkey, policy_version),
|
||||
FOREIGN KEY (community_id, pubkey)
|
||||
REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,21 +1,72 @@
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
|
||||
type JoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
age_attestation_required: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Landing page for a community invite link (`/invite/<code>`).
|
||||
*
|
||||
* The code is not validated here — validation happens in the desktop app when
|
||||
* the invite is claimed against `POST /api/invites/claim`, signed by the
|
||||
* joining key. This page only hands the code off via the `buzz://join` deep
|
||||
* link (or tells the visitor where to get the app first).
|
||||
*/
|
||||
type PolicyDocument = { title: string; markdown: string };
|
||||
|
||||
/** Landing page for a community invite link (`/invite/<code>`). */
|
||||
export function InvitePage({ code }: { code: string }) {
|
||||
const relay = relayWsUrl();
|
||||
const host = relay.replace(/^wss?:\/\//, "");
|
||||
const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
|
||||
const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [document, setDocument] = React.useState<PolicyDocument | null>(null);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch("/api/join-policy")
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const config = (await response.json()) as { policy?: JoinPolicy };
|
||||
setPolicy(config.policy ?? null);
|
||||
})
|
||||
.catch(() => setPolicy(undefined));
|
||||
}, []);
|
||||
|
||||
const openInvite = async () => {
|
||||
setOpening(true);
|
||||
try {
|
||||
let receipt: string | undefined;
|
||||
if (policy) {
|
||||
const response = await fetch("/api/invites/accept-policy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
policy_version: policy.version,
|
||||
age_confirmed: ageConfirmed,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
receipt = ((await response.json()) as { receipt: string }).receipt;
|
||||
}
|
||||
const query = new URLSearchParams({ relay, code });
|
||||
if (receipt) query.set("policy_receipt", receipt);
|
||||
window.location.href = `buzz://join?${query.toString()}`;
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disabled =
|
||||
policy === undefined ||
|
||||
opening ||
|
||||
Boolean(policy?.age_attestation_required && !ageConfirmed);
|
||||
const showDocument = (title: string, markdown: string) =>
|
||||
setDocument({ title, markdown });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -24,38 +75,94 @@ export function InvitePage({ code }: { code: string }) {
|
||||
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-xl flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-16 w-16 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-black">
|
||||
You're invited to join
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-lg text-black/70">{host}</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
size="lg"
|
||||
<div className="w-full max-w-xl space-y-4">
|
||||
<div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
|
||||
<div
|
||||
className="h-12 w-12 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<a href={deepLink}>Accept invite in Buzz</a>
|
||||
</Button>
|
||||
</div>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
|
||||
You'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't have the app?{" "}
|
||||
<a
|
||||
className="font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
|
||||
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
href={DOWNLOAD_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@@ -64,6 +171,37 @@ export function InvitePage({ code }: { code: string }) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{document && (
|
||||
<div
|
||||
aria-label={document.title}
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
|
||||
role="dialog"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) setDocument(null);
|
||||
}}
|
||||
>
|
||||
<div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{document.title}</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="text-2xl leading-none text-black/60 hover:text-black"
|
||||
type="button"
|
||||
onClick={() => setDocument(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{document.markdown}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user