mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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
45
Cargo.lock
generated
@@ -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]]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
)}
|
)}
|
||||||
|
|||||||
108
desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
Normal file
108
desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
54
desktop/src/shared/api/invites.test.mjs
Normal file
54
desktop/src/shared/api/invites.test.mjs
Normal 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/),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
12
migrations/0020_join_policy_acceptances.sql
Normal file
12
migrations/0020_join_policy_acceptances.sql
Normal 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
|
||||||
|
);
|
||||||
161
web/src/features/invite/ui/InviteJoinPolicyNotice.tsx
Normal file
161
web/src/features/invite/ui/InviteJoinPolicyNotice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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?{" "}
|
Don'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user