feat: relay membership with NIP-43 compliance (#448)

This commit is contained in:
tlongwell-block
2026-05-01 19:11:32 -04:00
committed by GitHub
parent 05ba08412c
commit 3cabe94c2b
23 changed files with 2254 additions and 18 deletions
+34 -1
View File
@@ -57,7 +57,25 @@ pub const KIND_NIP29_JOIN_REQUEST: u32 = 9021;
/// NIP-29: Request to leave a group.
pub const KIND_NIP29_LEAVE_REQUEST: u32 = 9022;
// System / admin (90319999)
// NIP-43 relay membership admin commands
/// NIP-43: Add a pubkey to the relay member list.
pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030;
/// NIP-43: Remove a pubkey from the relay member list.
pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031;
/// NIP-43: Change the role of an existing relay member.
pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032;
// NIP-43 relay membership announcement events (relay-signed)
/// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention).
pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534;
/// NIP-43: Member added announcement (relay-signed).
pub const KIND_NIP43_MEMBER_ADDED: u32 = 8000;
/// NIP-43: Member removed announcement (relay-signed).
pub const KIND_NIP43_MEMBER_REMOVED: u32 = 8001;
/// NIP-43: User leave request (user-signed, ephemeral).
pub const KIND_NIP43_LEAVE_REQUEST: u32 = 28936;
// System / admin (91009999)
/// V1 used kind:9001 — moved here due to NIP-29 conflict.
pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100;
/// V1 used kind:9010 — moved here for NIP-29 range safety.
@@ -280,6 +298,13 @@ pub const ALL_KINDS: &[u32] = &[
KIND_NIP29_CREATE_INVITE,
KIND_NIP29_JOIN_REQUEST,
KIND_NIP29_LEAVE_REQUEST,
RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_CHANGE_ROLE,
KIND_NIP43_MEMBERSHIP_LIST,
KIND_NIP43_MEMBER_ADDED,
KIND_NIP43_MEMBER_REMOVED,
KIND_NIP43_LEAVE_REQUEST,
KIND_SYSTEM_TIMER_FIRED,
KIND_SYSTEM_SLASH_COMMAND,
KIND_SYSTEM_FLAG,
@@ -388,6 +413,14 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool {
kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED
}
/// Returns `true` if `kind` is a NIP-43 relay membership admin command (90309032).
pub const fn is_relay_admin_kind(kind: u32) -> bool {
matches!(
kind,
RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE
)
}
/// Extract the kind from a nostr Event as u32.
/// NIP-01 specifies kind as an unsigned integer; u32 covers the full range.
pub fn event_kind_u32(event: &nostr::Event) -> u32 {
+71
View File
@@ -25,6 +25,8 @@ pub mod feed;
pub mod partition;
/// Reaction persistence.
pub mod reaction;
/// Relay-level membership persistence (NIP-43).
pub mod relay_members;
/// Thread metadata persistence.
pub mod thread;
/// User profile persistence.
@@ -1311,6 +1313,75 @@ impl Db {
Ok(out)
}
// ── Relay Members (NIP-43) ───────────────────────────────────────────────
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
pub async fn is_relay_member(&self, pubkey: &str) -> Result<bool> {
relay_members::is_relay_member(&self.pool, pubkey).await
}
/// Returns the relay member record for `pubkey`, or `None` if not found.
pub async fn get_relay_member(
&self,
pubkey: &str,
) -> Result<Option<relay_members::RelayMember>> {
relay_members::get_relay_member(&self.pool, pubkey).await
}
/// Returns all relay members ordered by `created_at` ascending.
pub async fn list_relay_members(&self) -> Result<Vec<relay_members::RelayMember>> {
relay_members::list_relay_members(&self.pool).await
}
/// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent).
/// Adds a new relay member.
///
/// Returns `true` if the row was actually inserted, `false` if the pubkey
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
pub async fn add_relay_member(
&self,
pubkey: &str,
role: &str,
added_by: Option<&str>,
) -> Result<bool> {
relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await
}
/// Removes a relay member atomically, refusing to delete the owner.
pub async fn remove_relay_member(&self, pubkey: &str) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member(&self.pool, pubkey).await
}
/// Removes a relay member only if their current role matches `expected_role`.
///
/// Atomic conditional delete — eliminates the TOCTOU race between a
/// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`].
pub async fn remove_relay_member_if_role(
&self,
pubkey: &str,
expected_role: &str,
) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await
}
/// Updates the role of an existing relay member. Returns `true` if updated.
pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result<bool> {
relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await
}
/// Ensures the owner pubkey exists with role `"owner"`. Called at startup.
pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> {
relay_members::bootstrap_owner(&self.pool, owner_pubkey).await
}
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
///
/// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows
/// inserted, or 0 if the `pubkey_allowlist` table doesn't exist.
pub async fn backfill_from_allowlist(&self) -> Result<u64> {
relay_members::backfill_from_allowlist(&self.pool).await
}
// ── Discovery events ─────────────────────────────────────────────────────
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
+283
View File
@@ -0,0 +1,283 @@
//! Relay-level membership persistence (NIP-43).
//!
//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata.
//! All pubkey values are 64-char lowercase hex strings.
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row as _};
use crate::error::Result;
/// A single relay member record.
#[derive(Debug, Clone)]
pub struct RelayMember {
/// 64-char lowercase hex pubkey.
pub pubkey: String,
/// Role: `"owner"`, `"admin"`, or `"member"`.
pub role: String,
/// Hex pubkey of who added this member, or `None` for bootstrap entries.
pub added_by: Option<String>,
/// When the member was added.
pub created_at: DateTime<Utc>,
/// When the record was last updated.
pub updated_at: DateTime<Utc>,
}
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
.bind(pubkey)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Returns the relay member record for `pubkey`, or `None` if not found.
pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<RelayMember>> {
let row = sqlx::query(
"SELECT pubkey, role, added_by, created_at, updated_at \
FROM relay_members WHERE pubkey = $1",
)
.bind(pubkey)
.fetch_optional(pool)
.await?;
row.map(|r| -> std::result::Result<RelayMember, sqlx::Error> {
Ok(RelayMember {
pubkey: r.try_get("pubkey")?,
role: r.try_get("role")?,
added_by: r.try_get("added_by")?,
created_at: r.try_get("created_at")?,
updated_at: r.try_get("updated_at")?,
})
})
.transpose()
.map_err(crate::error::DbError::from)
}
/// Returns all relay members ordered by `created_at` ascending.
pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
let rows = sqlx::query(
"SELECT pubkey, role, added_by, created_at, updated_at \
FROM relay_members ORDER BY created_at ASC",
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(|r| -> std::result::Result<RelayMember, sqlx::Error> {
Ok(RelayMember {
pubkey: r.try_get("pubkey")?,
role: r.try_get("role")?,
added_by: r.try_get("added_by")?,
created_at: r.try_get("created_at")?,
updated_at: r.try_get("updated_at")?,
})
})
.collect::<std::result::Result<Vec<_>, sqlx::Error>>()
.map_err(crate::error::DbError::from)
}
/// Adds a new relay member.
///
/// Returns `true` if the row was actually inserted, `false` if the pubkey
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
pub async fn add_relay_member(
pool: &PgPool,
pubkey: &str,
role: &str,
added_by: Option<&str>,
) -> Result<bool> {
let result = sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by) \
VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING",
)
.bind(pubkey)
.bind(role)
.bind(added_by)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// The result of a relay member removal attempt.
#[derive(Debug, PartialEq)]
pub enum RemoveResult {
/// Member was successfully removed.
Removed,
/// The pubkey belongs to the relay owner — removal is forbidden.
IsOwner,
/// No member with the given pubkey exists.
NotFound,
/// The member exists but their role doesn't match the expected role.
RoleMismatch,
}
/// Removes a relay member atomically, refusing to delete the owner.
///
/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the
/// owner-protection check and the deletion are one atomic operation —
/// no TOCTOU race between a separate read and delete.
pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveResult> {
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'")
.bind(pubkey)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
return Ok(RemoveResult::Removed);
}
// rows_affected == 0: either not found or is owner. One cheap read to
// distinguish the two cases so callers can return the right error message.
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
.bind(pubkey)
.fetch_optional(pool)
.await?;
if exists.is_some() {
Ok(RemoveResult::IsOwner)
} else {
Ok(RemoveResult::NotFound)
}
}
/// Removes a relay member only if their current role matches `expected_role`.
///
/// The delete and the role check are collapsed into a single
/// `DELETE … WHERE pubkey = $1 AND role = $2`, making the operation atomic —
/// no TOCTOU race between a prior read and this delete.
///
/// Returns:
/// - `Removed` — row was deleted.
/// - `NotFound` — no member with that pubkey exists.
/// - `IsOwner` — member exists with role `"owner"` (cannot be removed).
/// - `RoleMismatch` — member exists but their role no longer matches
/// `expected_role` (e.g., they were promoted between the caller's read and
/// this delete).
pub async fn remove_relay_member_if_role(
pool: &PgPool,
pubkey: &str,
expected_role: &str,
) -> Result<RemoveResult> {
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2")
.bind(pubkey)
.bind(expected_role)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
return Ok(RemoveResult::Removed);
}
// rows_affected == 0: either not found or role changed. One cheap read to
// distinguish the cases so callers can return the right error message.
let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1")
.bind(pubkey)
.fetch_optional(pool)
.await?;
match row {
None => Ok(RemoveResult::NotFound),
Some(r) => {
let role: String = r.try_get("role")?;
if role == "owner" {
Ok(RemoveResult::IsOwner)
} else {
// Role changed between the caller's check and this delete
// (e.g., target was promoted to admin). Signal that the
// caller no longer has authority to remove this target.
Ok(RemoveResult::RoleMismatch)
}
}
}
}
/// Updates the role of an existing relay member. Returns `true` if updated.
pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result<bool> {
let result = sqlx::query(
"UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'",
)
.bind(new_role)
.bind(pubkey)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes
/// any other owners to `"admin"`. This handles owner rotation: if
/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted.
///
/// Runs in a single transaction. Safe to call at every startup — idempotent.
pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
let pubkey = owner_pubkey.to_ascii_lowercase();
let mut tx = pool.begin().await?;
// 1. Upsert the configured owner.
sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by) \
VALUES ($1, 'owner', NULL) \
ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
)
.bind(&pubkey)
.execute(&mut *tx)
.await?;
// 2. Demote any other owners to admin.
sqlx::query(
"UPDATE relay_members SET role = 'admin', updated_at = now() \
WHERE role = 'owner' AND pubkey <> $1",
)
.bind(&pubkey)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
///
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members.
/// Returns the number of rows inserted, or 0 if:
/// - the `pubkey_allowlist` table doesn't exist, or
/// - `relay_members` already has rows (migration ran in a prior startup).
///
/// The empty-table guard prevents re-adding members that were intentionally
/// removed by an admin after the initial backfill.
pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
// Check if pubkey_allowlist table exists.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')",
)
.fetch_one(pool)
.await?;
if !exists {
return Ok(0);
}
// Only backfill if relay_members is empty — once the table has rows
// (from a previous backfill or manual admin commands), we must not
// re-add members that were intentionally removed.
let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)")
.fetch_one(pool)
.await?;
if has_members {
return Ok(0);
}
let result = sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by, created_at) \
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \
FROM pubkey_allowlist \
ON CONFLICT (pubkey) DO NOTHING",
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
+3
View File
@@ -50,6 +50,8 @@ pub enum MediaError {
Unauthorized,
#[error("insufficient scope")]
InsufficientScope,
#[error("relay membership required")]
RelayMembershipRequired,
#[error("token revoked")]
TokenRevoked,
#[error("pubkey mismatch")]
@@ -130,6 +132,7 @@ impl IntoResponse for MediaError {
)
}
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
Self::RelayMembershipRequired => (StatusCode::FORBIDDEN, self.to_string()),
Self::UnsupportedContainer => (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()),
Self::WrongCodec
| Self::DurationTooLong
@@ -170,6 +170,15 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
// The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient
// replay protection for v1. Per-request signing requires protocol changes.
// Relay membership gate (NIP-43).
if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize())
.await
.is_err()
{
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response());
}
Ok(GitAuth { pubkey })
}
}
+5
View File
@@ -86,6 +86,11 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
sprout_auth::require_scope(&scopes, Scope::FilesWrite)
.map_err(|_| MediaError::InsufficientScope)?;
// 5. Relay membership gate (NIP-43).
crate::api::relay_members::enforce_relay_membership(state, &auth_event.pubkey.serialize())
.await
.map_err(|_| MediaError::RelayMembershipRequired)?;
Ok(AuthenticatedUpload { auth_event, scopes })
}
}
+22 -2
View File
@@ -40,6 +40,8 @@ pub mod nip05;
pub mod presence;
/// Reaction endpoints.
pub mod reactions;
/// Relay membership enforcement and read endpoints.
pub mod relay_members;
/// Full-text search endpoint.
pub mod search;
/// Self-service API token minting, listing, and revocation endpoints.
@@ -64,6 +66,7 @@ pub use members::list_members;
pub use messages::{get_thread, list_messages, validate_imeta_tags, verify_imeta_blobs};
pub use presence::{presence_handler, set_presence_handler};
pub use reactions::list_reactions_handler;
pub use relay_members::{enforce_relay_membership, get_my_relay_membership, list_relay_members};
pub use search::search_handler;
pub use users::{
get_contact_list, get_profile, get_user_notes, get_user_profile, get_users_batch,
@@ -133,7 +136,7 @@ pub struct RestAuthContext {
pub channel_ids: Option<Vec<Uuid>>,
}
/// Extract the full auth context from request headers.
/// Extract the full auth context from request headers and enforce relay membership.
///
/// Auth resolution order:
/// 1. `Authorization: Bearer sprout_*` — API token; revocation + expiry checked here
@@ -145,10 +148,27 @@ pub struct RestAuthContext {
/// request that sends a `Nostr` auth header to a non-token endpoint will receive
/// a 401 with `"nip98_not_supported"`.
///
/// Returns a populated [`RestAuthContext`] on success, or a 401 response on failure.
/// After successful authentication, relay membership is enforced when
/// `config.require_relay_membership` is enabled.
///
/// Returns a populated [`RestAuthContext`] on success, or a 401/403 response on failure.
pub(crate) async fn extract_auth_context(
headers: &HeaderMap,
state: &AppState,
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context_inner(headers, state).await?;
relay_members::enforce_relay_membership(state, &ctx.pubkey_bytes).await?;
Ok(ctx)
}
/// Inner auth extraction — no relay membership check.
///
/// Used by `extract_auth_context` (which layers the membership gate on top)
/// and by handlers that need auth without the membership gate (e.g. the
/// `/api/relay/members/me` endpoint which must work for non-members to return 404).
pub(crate) async fn extract_auth_context_inner(
headers: &HeaderMap,
state: &AppState,
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
let require_auth = state.config.require_auth_token;
@@ -0,0 +1,142 @@
//! Relay membership enforcement and read endpoints.
//!
//! ## Enforcement
//! [`enforce_relay_membership`] is the single gate — called at every authenticated
//! entry point. When `require_relay_membership` is disabled, it's a no-op.
//!
//! ## Routes
//! - `GET /api/relay/members` — list all relay members (any authenticated member)
//! - `GET /api/relay/members/me` — get own membership record (or 404)
use std::sync::Arc;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::Json,
};
use sprout_auth::Scope;
use super::{extract_auth_context, extract_auth_context_inner, internal_error};
use crate::state::AppState;
// ── Enforcement ───────────────────────────────────────────────────────────────
/// Enforce relay membership for a pubkey.
///
/// - If `config.require_relay_membership` is false → always Ok (no-op).
/// - If enabled → checks `relay_members` table. Returns 403 if not a member.
///
/// `pubkey_bytes` is the 32-byte compressed pubkey; it is hex-encoded before
/// the DB lookup (the `relay_members` table stores 64-char hex strings).
pub async fn enforce_relay_membership(
state: &AppState,
pubkey_bytes: &[u8],
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if !state.config.require_relay_membership {
return Ok(());
}
let pubkey_hex = hex::encode(pubkey_bytes);
let is_member = state
.db
.is_relay_member(&pubkey_hex)
.await
.map_err(|e| internal_error(&format!("relay membership check failed: {e}")))?;
if is_member {
Ok(())
} else {
Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
))
}
}
// ── REST read handlers ────────────────────────────────────────────────────────
/// `GET /api/relay/members` — list all relay members.
///
/// Any authenticated relay member can call this. The membership gate is
/// enforced by `extract_auth_context` (which wraps the inner extractor).
pub async fn list_relay_members(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
// extract_auth_context enforces relay membership
let ctx = extract_auth_context(&headers, &state).await?;
// Require at least UsersRead scope to enumerate relay members.
// Empty scopes means NIP-98 auth (implicit full access) — skip the check.
if !ctx.scopes.is_empty()
&& !ctx.scopes.contains(&Scope::UsersRead)
&& !ctx.scopes.contains(&Scope::AdminUsers)
{
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "insufficient_scope",
"message": "Requires users:read or admin:users scope"
})),
));
}
let members = state
.db
.list_relay_members()
.await
.map_err(|e| internal_error(&format!("list relay members: {e}")))?;
let items: Vec<serde_json::Value> = members
.into_iter()
.map(|m| {
serde_json::json!({
"pubkey": m.pubkey,
"role": m.role,
"added_by": m.added_by,
"created_at": m.created_at.to_rfc3339(),
})
})
.collect();
Ok(Json(serde_json::json!({ "members": items })))
}
/// `GET /api/relay/members/me` — get own membership record.
///
/// Uses the inner auth extractor (no membership gate) so non-members
/// get a proper 404 instead of 403.
pub async fn get_my_relay_membership(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context_inner(&headers, &state).await?;
let pubkey_hex = hex::encode(&ctx.pubkey_bytes);
let member = state
.db
.get_relay_member(&pubkey_hex)
.await
.map_err(|e| internal_error(&format!("get relay member: {e}")))?;
match member {
Some(m) => Ok(Json(serde_json::json!({
"pubkey": m.pubkey,
"role": m.role,
"added_by": m.added_by,
"created_at": m.created_at.to_rfc3339(),
}))),
None => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "not_a_member",
"message": "You are not a relay member"
})),
)),
}
}
+3
View File
@@ -290,6 +290,9 @@ pub async fn post_tokens(
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
tracing::warn!("ensure_user failed for NIP-98 pubkey: {e}");
}
// NIP-98 path builds auth context directly — enforce membership here.
// Bearer/JWT paths go through extract_auth_context which already checks.
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes).await?;
super::RestAuthContext {
pubkey,
pubkey_bytes,
+16
View File
@@ -142,6 +142,22 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
let pubkey_bytes = pubkey.serialize().to_vec();
let parent_channel_id = auth_msg.parent_channel_id;
// ── Relay membership gate (NIP-43) ────────────────────────────────────────
if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize())
.await
.is_err()
{
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied");
let _ = ws_send
.send(WsMessage::Text(
serde_json::json!({"type": "error", "message": "restricted: not a relay member"})
.to_string()
.into(),
))
.await;
return;
}
// ── Step 3: membership check / auto-add ───────────────────────────────────
if let Err(e) = ensure_membership(&state, channel_id, &pubkey_bytes, parent_channel_id).await {
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}");
+46
View File
@@ -62,6 +62,18 @@ pub struct Config {
/// API tokens or Okta JWTs bypass the allowlist entirely.
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
pub pubkey_allowlist_enabled: bool,
/// When true, every authenticated request must also pass a relay-level
/// membership check against the `relay_members` table.
/// When false (default), the check is a no-op and all authenticated callers
/// are permitted regardless of auth method (API token, JWT, NIP-42).
pub require_relay_membership: bool,
/// Optional hex-encoded pubkey of the relay owner.
/// When set, this pubkey is automatically bootstrapped into `relay_members`
/// with the `owner` role on first startup.
pub relay_owner_pubkey: Option<String>,
/// Media storage configuration (S3/MinIO).
pub media: sprout_media::MediaConfig,
@@ -133,6 +145,30 @@ impl Config {
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let require_relay_membership = std::env::var("SPROUT_REQUIRE_RELAY_MEMBERSHIP")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Note: intentionally not prefixed with SPROUT_ — this is a relay-identity
// config that may be shared across multiple services (e.g., ACP agent).
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.and_then(|s| {
// Must be exactly 64 lowercase hex characters (32-byte pubkey).
let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit());
if valid {
Some(s)
} else {
warn!(
"RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — ignoring. \
Got: {s:?}"
);
None
}
});
let mut auth = sprout_auth::AuthConfig::default();
auth.okta.require_token = require_auth_token;
@@ -284,6 +320,8 @@ impl Config {
health_port,
metrics_port,
pubkey_allowlist_enabled,
require_relay_membership,
relay_owner_pubkey,
media,
ephemeral_ttl_override,
git_repo_path,
@@ -317,6 +355,14 @@ mod tests {
!config.pubkey_allowlist_enabled,
"pubkey_allowlist_enabled should default to false"
);
assert!(
!config.require_relay_membership,
"require_relay_membership should default to false"
);
assert!(
config.relay_owner_pubkey.is_none(),
"relay_owner_pubkey should default to None"
);
}
#[test]
+58
View File
@@ -9,6 +9,51 @@ use crate::connection::{AuthState, ConnectionState};
use crate::protocol::RelayMessage;
use crate::state::AppState;
/// Check relay membership for a pubkey during NIP-42 auth.
///
/// Returns `true` if the pubkey is a relay member (or if membership enforcement
/// is disabled). Returns `false` and sends a rejection message if not a member.
async fn enforce_ws_relay_membership(
state: &AppState,
conn: &Arc<ConnectionState>,
conn_id: uuid::Uuid,
pubkey: &nostr::PublicKey,
event_id_hex: &str,
) -> bool {
if !state.config.require_relay_membership {
return true;
}
let pubkey_hex = pubkey.to_hex();
let is_member = match state.db.is_relay_member(&pubkey_hex).await {
Ok(v) => v,
Err(e) => {
warn!(
conn_id = %conn_id,
pubkey = %pubkey_hex,
error = %e,
"relay membership check failed, denying (fail-closed)"
);
false
}
};
if !is_member {
warn!(conn_id = %conn_id, pubkey = %pubkey_hex, "not a relay member");
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
.increment(1);
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
event_id_hex,
false,
"restricted: not a relay member",
));
return false;
}
true
}
fn verify_api_token_nip42_binding(
event: &nostr::Event,
challenge: &str,
@@ -170,6 +215,14 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
};
// API token users have already proven authorization via their token —
// the pubkey allowlist does not apply here.
// Relay membership gate (NIP-43) — applies to ALL auth methods.
if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex)
.await
{
return;
}
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
state
.conn_manager
@@ -225,6 +278,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
return;
}
}
// Relay membership gate (NIP-43) — applies to ALL auth methods.
if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex).await {
return;
}
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
state
+1 -1
View File
@@ -30,7 +30,7 @@ fn reject(reason: &'static str) {
fn bounded_kind_label(kind: u32) -> String {
match kind {
0..=9 | 1059 | 1063 => kind.to_string(),
9000..=9022 | 9100 | 9110 | 9900 => kind.to_string(),
9000..=9022 | 9030..=9032 | 9100 | 9110 | 9900 => kind.to_string(),
20000..=29999 => kind.to_string(),
30023 | 30315 | 39000..=39003 => kind.to_string(),
40002..=40100 => kind.to_string(),
+144 -8
View File
@@ -12,9 +12,9 @@ use uuid::Uuid;
use nostr::Event;
use sprout_auth::Scope;
use sprout_core::kind::{
event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AUTH, KIND_CANVAS,
KIND_CONTACT_LIST, KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE,
KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE,
@@ -22,10 +22,11 @@ use sprout_core::kind::{
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP,
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
};
use sprout_core::verification::verify_event;
@@ -173,6 +174,13 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => {
Ok(Scope::AdminChannels)
}
// NIP-43: relay membership admin commands (90309032)
k if k == RELAY_ADMIN_ADD_MEMBER
|| k == RELAY_ADMIN_REMOVE_MEMBER
|| k == RELAY_ADMIN_CHANGE_ROLE =>
{
Ok(Scope::AdminUsers)
}
KIND_NIP29_EDIT_METADATA => {
// kind:9002 scope split: archived tag → AdminChannels, else ChannelsWrite
let has_archived = event
@@ -186,7 +194,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
}
}
KIND_NIP29_CREATE_GROUP | KIND_CANVAS => Ok(Scope::ChannelsWrite),
KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => Ok(Scope::ChannelsRead),
KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST => {
Ok(Scope::ChannelsRead)
}
// Huddle lifecycle events + guidelines
KIND_HUDDLE_STARTED
| KIND_HUDDLE_PARTICIPANT_JOINED
@@ -303,6 +313,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_GIT_STATUS_MERGED
| KIND_GIT_STATUS_CLOSED
| KIND_GIT_STATUS_DRAFT
// NIP-43: relay admin commands and leave requests are global — they
// must never be channel-scoped, even if the event carries a stray `h` tag.
| RELAY_ADMIN_ADD_MEMBER
| RELAY_ADMIN_REMOVE_MEMBER
| RELAY_ADMIN_CHANGE_ROLE
| KIND_NIP43_LEAVE_REQUEST
)
}
@@ -840,6 +856,29 @@ pub async fn ingest_event(
Ok(scope) => scope,
Err(msg) => return Err(IngestError::Rejected(msg.into())),
};
// NIP-43: relay admin commands must NOT be submitted via proxy — they require
// the actual admin's signed event for authorization.
if auth.has_proxy_scope() && is_relay_admin_kind(event.kind.as_u16() as u32) {
return Err(IngestError::Rejected(
"invalid: relay admin commands cannot be submitted via proxy".into(),
));
}
// NIP-43: relay admin commands are global — channel-scoped tokens cannot
// issue them even if the event has no `h` tag (is_global_only_kind strips
// channel_id, but we still need to reject the token itself).
if is_relay_admin_kind(kind_u32) && auth.channel_ids().is_some() {
return Err(IngestError::AuthFailed(
"restricted: relay admin commands require a global token, not a channel-scoped token"
.into(),
));
}
// NIP-43: leave requests are also global — channel-scoped tokens cannot
// issue them.
if kind_u32 == KIND_NIP43_LEAVE_REQUEST && auth.channel_ids().is_some() {
return Err(IngestError::AuthFailed(
"restricted: leave requests require a global token".into(),
));
}
if !auth.has_proxy_scope() && !auth.scopes().contains(&required) {
return Err(IngestError::AuthFailed(format!(
"restricted: insufficient scope (need {})",
@@ -949,6 +988,103 @@ pub async fn ingest_event(
}
}
// ── 9a. Relay admin commands (kinds 90309032) ───────────────────────
// Handled directly — these mutate relay_members and do NOT get stored.
if is_relay_admin_kind(event.kind.as_u16() as u32) {
crate::handlers::relay_admin::handle_relay_admin_event(state, &event)
.await
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
return Ok(IngestResult {
event_id: event_id_hex,
accepted: true,
message: String::new(),
});
}
// ── 9b. NIP-43 leave request (kind 28936) ────────────────────────────
// Handled directly — removes the sender from relay_members. NOT stored.
if kind_u32 == KIND_NIP43_LEAVE_REQUEST {
if !state.config.require_relay_membership {
return Err(IngestError::Rejected(
"invalid: relay membership is not enabled".into(),
));
}
// Freshness check: reject events outside ±120s of now (same as admin commands).
{
let event_ts = event.created_at.as_u64() as i64;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (event_ts - now).abs() > 120 {
return Err(IngestError::Rejected(format!(
"invalid: leave request timestamp out of range (delta={}s, max ±120s)",
event_ts - now
)));
}
}
// NIP-43 spec: "This event MUST include a NIP-70 `-` tag."
let has_protected_tag = event
.tags
.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("-"));
if !has_protected_tag {
return Err(IngestError::Rejected(
"invalid: leave request must include NIP-70 protected event tag [\"-\"]".into(),
));
}
let sender_hex = event.pubkey.to_hex();
// remove_relay_member handles both the NotFound and IsOwner cases atomically.
let remove_result = state
.db
.remove_relay_member(&sender_hex)
.await
.map_err(|e| IngestError::Internal(format!("database error: {e}")))?;
match remove_result {
sprout_db::relay_members::RemoveResult::Removed => {}
sprout_db::relay_members::RemoveResult::NotFound => {
return Err(IngestError::Rejected(
"invalid: you are not a relay member".into(),
));
}
sprout_db::relay_members::RemoveResult::IsOwner => {
return Err(IngestError::Rejected(
"invalid: relay owner cannot leave".into(),
));
}
sprout_db::relay_members::RemoveResult::RoleMismatch => {
// remove_relay_member (no role filter) never returns RoleMismatch —
// this arm is unreachable but exhaustiveness requires it.
return Err(IngestError::Internal(
"unexpected RoleMismatch from remove_relay_member".into(),
));
}
}
// Publish NIP-43 announcements — fire-and-forget.
if let Err(e) =
crate::handlers::side_effects::publish_nip43_member_removed(state, &sender_hex).await
{
warn!(error = %e, "failed to publish NIP-43 member removed event");
}
if let Err(e) = crate::handlers::side_effects::publish_nip43_membership_list(state).await {
warn!(error = %e, "failed to publish NIP-43 membership list");
}
info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request");
return Ok(IngestResult {
event_id: event_id_hex,
accepted: true,
message: "info: you have left this relay".into(),
});
}
// ── 9. Admin validation (kinds 90009022) ────────────────────────────
if crate::handlers::side_effects::is_admin_kind(kind_u32) {
crate::handlers::side_effects::validate_admin_event(kind_u32, &event, state)
+2
View File
@@ -5,6 +5,8 @@ pub mod close;
pub mod event;
/// Transport-neutral event ingestion pipeline.
pub mod ingest;
/// NIP-43 relay membership admin command handler (kinds 90309032).
pub mod relay_admin;
pub mod req;
/// NIP-29 and NIP-25 side-effect handlers.
pub mod side_effects;
@@ -0,0 +1,375 @@
//! NIP-43 relay membership admin command handler (kinds 90309032).
//!
//! These events are processed directly — they mutate the `relay_members` table
//! and return without being stored as regular Nostr events.
//!
//! ## Permission matrix
//!
//! | Kind | Operation | Required sender role |
//! |------|-----------------|----------------------|
//! | 9030 | Add member | admin or owner |
//! | 9031 | Remove member | admin or owner |
//! | 9032 | Change role | owner only |
use std::sync::Arc;
use nostr::Event;
use tracing::{info, warn};
use sprout_core::kind::{
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
};
use sprout_db::relay_members::RemoveResult;
use crate::handlers::side_effects::{
publish_nip43_member_added, publish_nip43_member_removed, publish_nip43_membership_list,
};
use crate::state::AppState;
// ── Tag extraction helpers ────────────────────────────────────────────────────
/// Extract the hex pubkey from the first `p` tag, returning it as a `String`.
fn extract_p_tag_hex(event: &Event) -> Option<String> {
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) == Some("p") {
if let Some(val) = parts.get(1).map(|s| s.as_str()) {
// Must be exactly 64 hex chars (uncompressed pubkey representation).
if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(val.to_string());
}
}
}
}
None
}
/// Extract the value of the first tag with the given name.
fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) == Some(name) {
return parts.get(1).map(|s| s.to_string());
}
}
None
}
// ── Public handler ────────────────────────────────────────────────────────────
/// Validate and execute a relay admin command (kinds 90309032).
///
/// The handler:
/// 1. Extracts the target pubkey from the `["p", ...]` tag.
/// 2. Extracts the role from the `["role", ...]` tag (kinds 9030 and 9032).
/// 3. Looks up the sender's current role in `relay_members`.
/// 4. Enforces the permission matrix.
/// 5. Applies the change via the DB.
///
/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is a
/// human-readable rejection reason — on any validation failure.
pub async fn handle_relay_admin_event(state: &Arc<AppState>, event: &Event) -> Result<(), String> {
let kind = event.kind.as_u16() as u32;
let sender_hex = event.pubkey.to_hex();
// ── Replay protection: reject events outside ±120s of now ────────────
// This mirrors the NIP-42 auth event freshness check and prevents replay
// of captured admin commands. The window is intentionally tight — admin
// events should be freshly signed.
{
let event_ts = event.created_at.as_u64() as i64;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (event_ts - now).abs() > 120 {
return Err(format!(
"event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)",
event_ts - now
));
}
}
// ── Extract target pubkey ─────────────────────────────────────────────
let target_hex = extract_p_tag_hex(event)
.ok_or_else(|| "missing or invalid p tag".to_string())?
.to_ascii_lowercase();
// ── Look up sender's relay role ───────────────────────────────────────
let sender_member = state
.db
.get_relay_member(&sender_hex)
.await
.map_err(|e| format!("database error: {e}"))?;
let sender_role = sender_member
.as_ref()
.map(|m| m.role.as_str())
.unwrap_or("");
// ── Dispatch by kind ──────────────────────────────────────────────────
match kind {
// kind:9030 — Add relay member
k if k == RELAY_ADMIN_ADD_MEMBER => {
// Sender must be admin or owner.
if sender_role != "admin" && sender_role != "owner" {
return Err("actor not authorized: must be admin or owner".to_string());
}
// Default role is "member" when no role tag is present.
let role = extract_tag_value(event, "role").unwrap_or_else(|| "member".to_string());
// Owners can add admins or members; admins can only add members.
if role == "owner" {
return Err("invalid role: use kind:9032 to promote to owner".to_string());
}
if role == "admin" && sender_role != "owner" {
return Err("actor not authorized: only owner can grant admin role".to_string());
}
if role != "admin" && role != "member" {
return Err(format!("invalid role: {role}"));
}
// Note: idempotent — if target already exists at any role, this is a
// silent no-op. The existing role is NOT overwritten. Use kind:9032
// to change an existing member's role.
let was_inserted = state
.db
.add_relay_member(&target_hex, &role, Some(&sender_hex))
.await
.map_err(|e| format!("database error: {e}"))?;
info!(
sender = %sender_hex,
target = %target_hex,
role = %role,
was_inserted,
"relay member add attempted"
);
// Only publish NIP-43 announcements when the row was actually inserted —
// skip on no-op re-adds to avoid spurious kind:8000 events.
if was_inserted {
if let Err(e) = publish_nip43_member_added(state, &target_hex).await {
warn!(error = %e, "failed to publish NIP-43 member added event");
}
if let Err(e) = publish_nip43_membership_list(state).await {
warn!(error = %e, "failed to publish NIP-43 membership list");
}
}
}
// kind:9031 — Remove relay member
k if k == RELAY_ADMIN_REMOVE_MEMBER => {
// Sender must be admin or owner.
if sender_role != "admin" && sender_role != "owner" {
return Err("actor not authorized: must be admin or owner".to_string());
}
// Cannot remove yourself.
if target_hex == sender_hex {
return Err("cannot remove yourself".to_string());
}
// Dispatch removal by sender role:
// - Admins: atomic conditional delete, only removes 'member' targets.
// This eliminates the TOCTOU race where the target could be promoted
// between a prior role read and the delete.
// - Owners: can remove admins and members, not other owners.
let remove_result = if sender_role == "admin" {
state
.db
.remove_relay_member_if_role(&target_hex, "member")
.await
.map_err(|e| format!("database error: {e}"))?
} else {
// Owner path — atomic delete that refuses to remove other owners.
state
.db
.remove_relay_member(&target_hex)
.await
.map_err(|e| format!("database error: {e}"))?
};
match remove_result {
RemoveResult::Removed => {}
RemoveResult::IsOwner => {
return Err("cannot remove the relay owner".to_string());
}
RemoveResult::NotFound => {
return Err(format!("member not found: {target_hex}"));
}
RemoveResult::RoleMismatch => {
return Err("actor not authorized: admins can only remove members".to_string());
}
}
info!(
sender = %sender_hex,
target = %target_hex,
"relay member removed"
);
if let Err(e) = publish_nip43_member_removed(state, &target_hex).await {
warn!(error = %e, "failed to publish NIP-43 member removed event");
}
if let Err(e) = publish_nip43_membership_list(state).await {
warn!(error = %e, "failed to publish NIP-43 membership list");
}
}
// kind:9032 — Change relay member role
k if k == RELAY_ADMIN_CHANGE_ROLE => {
// Only owners may change roles.
if sender_role != "owner" {
return Err("actor not authorized: must be owner".to_string());
}
// Cannot change your own role.
if target_hex == sender_hex {
return Err("cannot change your own role".to_string());
}
let new_role =
extract_tag_value(event, "role").ok_or_else(|| "missing role tag".to_string())?;
// DESIGN: Ownership transfer via kind:9032 is intentionally blocked.
// Transferring ownership is a high-risk operation that could permanently
// lock out the current owner. Use RELAY_OWNER_PUBKEY config to change ownership.
if new_role == "owner" {
return Err("cannot set role to owner".to_string());
}
if new_role != "admin" && new_role != "member" {
return Err(format!("invalid role: {new_role}"));
}
let updated = state
.db
.update_relay_member_role(&target_hex, &new_role)
.await
.map_err(|e| format!("database error: {e}"))?;
if !updated {
// Distinguish "owner (protected)" from "doesn't exist"
let exists = state
.db
.get_relay_member(&target_hex)
.await
.map_err(|e| format!("database error: {e}"))?;
return Err(if exists.is_some() {
"cannot change the relay owner's role".to_string()
} else {
format!("member not found: {target_hex}")
});
}
info!(
sender = %sender_hex,
target = %target_hex,
new_role = %new_role,
"relay member role changed"
);
if let Err(e) = publish_nip43_membership_list(state).await {
warn!(error = %e, "failed to publish NIP-43 membership list");
}
}
other => {
return Err(format!("unexpected relay admin kind: {other}"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
/// Build a minimal signed Event with the given kind and tags.
/// The pubkey will be randomly generated — sufficient for tag extraction tests.
fn make_test_event(kind: u16, tags: Vec<Vec<&'static str>>) -> Event {
let keys = Keys::generate();
let nostr_tags: Vec<Tag> = tags
.into_iter()
.map(|parts| Tag::parse(&parts).expect("valid tag"))
.collect();
EventBuilder::new(Kind::from(kind), "", nostr_tags)
.sign_with_keys(&keys)
.expect("signing failed")
}
// ── extract_p_tag_hex ─────────────────────────────────────────────────
#[test]
fn extract_p_tag_valid_hex() {
let hex = "a".repeat(64);
let event = make_test_event(
9030,
vec![vec!["p", Box::leak(hex.clone().into_boxed_str())]],
);
assert_eq!(extract_p_tag_hex(&event), Some(hex));
}
#[test]
fn extract_p_tag_rejects_short_hex() {
let event = make_test_event(9030, vec![vec!["p", "abcd"]]);
assert_eq!(extract_p_tag_hex(&event), None);
}
#[test]
fn extract_p_tag_rejects_non_hex() {
// 'g' is not a hex digit
let event = make_test_event(
9030,
vec![vec![
"p",
"gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
]],
);
assert_eq!(extract_p_tag_hex(&event), None);
}
#[test]
fn extract_p_tag_missing() {
let event = make_test_event(9030, vec![]);
assert_eq!(extract_p_tag_hex(&event), None);
}
#[test]
fn extract_p_tag_ignores_non_p_tags() {
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
assert_eq!(extract_p_tag_hex(&event), None);
}
// ── extract_tag_value ─────────────────────────────────────────────────
#[test]
fn extract_tag_value_found() {
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
assert_eq!(extract_tag_value(&event, "role"), Some("admin".to_string()));
}
#[test]
fn extract_tag_value_missing() {
let event = make_test_event(9030, vec![]);
assert_eq!(extract_tag_value(&event, "role"), None);
}
#[test]
fn extract_tag_value_returns_first_match() {
let event = make_test_event(9030, vec![vec!["role", "member"], vec!["role", "admin"]]);
assert_eq!(
extract_tag_value(&event, "role"),
Some("member".to_string())
);
}
#[test]
fn extract_tag_value_wrong_name() {
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
assert_eq!(extract_tag_value(&event, "p"), None);
}
}
@@ -9,7 +9,7 @@ use uuid::Uuid;
use sprout_core::kind::{
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
KIND_NIP29_GROUP_METADATA, KIND_REACTION,
KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
};
use sprout_db::channel::MemberRole;
@@ -1640,3 +1640,118 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
Ok(())
}
// ── NIP-43 relay-level membership announcement events ────────────────────────
/// Publish a kind:13534 relay membership list event (NIP-43).
///
/// Queries all current relay members and emits a relay-signed, NIP-70-protected
/// addressable event listing every member pubkey. Replaces any previous list.
pub async fn publish_nip43_membership_list(state: &Arc<AppState>) -> anyhow::Result<()> {
let members = state.db.list_relay_members().await?;
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
tags.push(Tag::parse(&["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
for member in &members {
tags.push(
Tag::parse(&["member", &member.pubkey])
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "", tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
// NOTE: kind 13534 is technically a regular event (not in the NIP-16 replaceable
// range), but we intentionally use replace_addressable_event to get replacement
// semantics — only the latest membership snapshot matters. This function keys on
// (kind, pubkey, channel_id) and atomically replaces older events, which is exactly
// what Pyramid (the reference NIP-43 implementation) does with store.ReplaceEvent().
let (stored, was_inserted) = state.db.replace_addressable_event(&event, None).await?;
if was_inserted {
dispatch_persistent_event(
state,
&stored,
KIND_NIP43_MEMBERSHIP_LIST,
&relay_pubkey_hex,
)
.await;
}
info!(
member_count = members.len(),
"NIP-43 membership list published"
);
Ok(())
}
/// Shared helper: publish a NIP-43 membership delta event (kind 8000 or 8001).
///
/// Signs a relay event with `["-"]` (NIP-70) + `["p", target]` tags, stores it
/// globally, and fans out to matching subscribers.
async fn publish_nip43_delta(
state: &Arc<AppState>,
kind: u16,
target_pubkey_hex: &str,
label: &str,
) -> anyhow::Result<()> {
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
let tags = vec![
Tag::parse(&["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?,
Tag::parse(&["p", target_pubkey_hex])
.map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?,
];
let event = EventBuilder::new(Kind::Custom(kind), "", tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?;
let (stored, was_inserted) = state.db.insert_event(&event, None).await?;
if !was_inserted {
return Ok(());
}
let matches = state.sub_registry.fan_out(&stored);
if !matches.is_empty() {
let event_json = match serde_json::to_string(&stored.event) {
Ok(json) => json,
Err(e) => {
warn!("failed to serialize kind:{kind} for fan-out: {e}");
return Ok(());
}
};
for (target_conn_id, sub_id) in &matches {
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
state.conn_manager.send_to(*target_conn_id, msg);
}
}
info!(
target = %target_pubkey_hex,
relay = %relay_pubkey_hex,
"NIP-43 {label} event published"
);
Ok(())
}
/// Publish a kind:8000 relay member-added announcement event (NIP-43).
pub async fn publish_nip43_member_added(
state: &Arc<AppState>,
target_pubkey_hex: &str,
) -> anyhow::Result<()> {
publish_nip43_delta(state, 8000, target_pubkey_hex, "member-added").await
}
/// Publish a kind:8001 relay member-removed announcement event (NIP-43).
pub async fn publish_nip43_member_removed(
state: &Arc<AppState>,
target_pubkey_hex: &str,
) -> anyhow::Result<()> {
publish_nip43_delta(state, 8001, target_pubkey_hex, "member-removed").await
}
+83
View File
@@ -59,6 +59,73 @@ async fn main() -> anyhow::Result<()> {
error!("Failed to ensure partitions: {e}");
}
// NIP-43: if membership enforcement is on, a valid owner pubkey is required.
// config.rs already strips invalid values with a warning; catch the resulting
// None here so we fail fast with a clear message rather than starting a relay
// that no one can administer.
if config.require_relay_membership && config.relay_owner_pubkey.is_none() {
error!(
"SPROUT_REQUIRE_RELAY_MEMBERSHIP=true but RELAY_OWNER_PUBKEY is not set or invalid. \
Set RELAY_OWNER_PUBKEY to a valid 64-char hex pubkey."
);
return Err(anyhow::anyhow!(
"RELAY_OWNER_PUBKEY required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true"
));
}
// NIP-43: relay membership requires a stable signing key.
// Check this before any DB mutations so we fail fast — no point backfilling
// or bootstrapping if we'll reject the config anyway.
if config.require_relay_membership && config.relay_private_key.is_none() {
return Err(anyhow::anyhow!(
"SPROUT_RELAY_PRIVATE_KEY is required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true. \
NIP-43 events signed with an ephemeral key become unverifiable after restart."
));
}
// NIP-43: migrate any existing pubkey_allowlist entries to relay_members.
// Idempotent — safe to run every startup. Must run before bootstrap_owner
// so that existing allowlist users become relay members before the owner
// is promoted (otherwise enabling membership locks everyone out).
match db.backfill_from_allowlist().await {
Ok(0) => {}
Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"),
Err(e) => {
if config.require_relay_membership {
error!(
"Fatal: failed to backfill allowlist with membership enforcement enabled: {e}"
);
return Err(anyhow::anyhow!(
"Failed to backfill pubkey_allowlist (required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
));
} else {
error!("Failed to backfill pubkey_allowlist (non-fatal): {e}");
}
}
}
// NIP-43: ensure the configured relay owner always holds the owner role.
if let Some(ref owner_pubkey) = config.relay_owner_pubkey {
match db.bootstrap_owner(owner_pubkey).await {
Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"),
Err(e) => {
if config.require_relay_membership {
// Membership enforcement is on — a missing owner means no one
// can administer the relay. Fail fast rather than silently start
// in a broken state.
error!("Fatal: failed to bootstrap relay owner with membership enforcement enabled: {e}");
return Err(anyhow::anyhow!(
"Failed to bootstrap relay owner (required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
));
} else {
error!(
"Failed to bootstrap relay owner (non-fatal, membership not required): {e}"
);
}
}
}
}
// NIP-33: backfill d_tag for any existing parameterized replaceable events
// that predate the column addition. Idempotent — no-ops when fully populated.
match db.backfill_d_tags().await {
@@ -144,6 +211,22 @@ async fn main() -> anyhow::Result<()> {
);
let state = Arc::new(app_state);
// NIP-43: publish the initial membership list on startup so clients can
// REQ kind:13534 immediately without waiting for the next membership change.
if config.require_relay_membership {
let startup_state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(e) =
sprout_relay::handlers::side_effects::publish_nip43_membership_list(&startup_state)
.await
{
tracing::warn!(error = %e, "failed to publish initial NIP-43 membership list on startup");
} else {
tracing::info!("NIP-43 membership list published on startup");
}
});
}
// Wire the action sink — must happen after AppState (which creates
// sub_registry, conn_manager) and before the cron loop starts.
let action_sink = Arc::new(sprout_relay::workflow_sink::RelayActionSink::new(&state));
+20 -3
View File
@@ -7,7 +7,7 @@ use crate::connection::MAX_FRAME_BYTES;
/// NIPs supported by this relay, advertised in the NIP-11 document.
/// Kept as a module-level constant so tests can verify it without constructing
/// a full `Config` (which reads env vars and races with config.rs tests).
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50];
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 43, 50];
/// Relay information document served at `GET /` with `Accept: application/nostr+json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -28,6 +28,9 @@ pub struct RelayInfo {
pub version: String,
/// Protocol and resource limits advertised to clients.
pub limitation: Option<RelayLimitation>,
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
pub relay_self: Option<String>,
}
/// Protocol and resource limits advertised in the NIP-11 document.
@@ -55,7 +58,10 @@ pub struct RelayLimitation {
impl RelayInfo {
/// Builds a `RelayInfo` document from the relay's runtime config.
pub fn from_config(config: &crate::config::Config) -> Self {
///
/// `relay_pubkey` is the relay's own signing pubkey (hex), advertised as the
/// NIP-11 `self` field for NIP-43 membership verification.
pub fn from_config(config: &crate::config::Config, relay_pubkey: Option<&str>) -> Self {
Self {
name: "Sprout Relay".to_string(),
description: "Sprout — private team communication relay".to_string(),
@@ -75,6 +81,7 @@ impl RelayInfo {
payment_required: false,
restricted_writes: true,
}),
relay_self: relay_pubkey.map(|s| s.to_string()),
}
}
}
@@ -83,7 +90,17 @@ impl RelayInfo {
pub async fn relay_info_handler(
axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
) -> axum::response::Json<RelayInfo> {
axum::response::Json(RelayInfo::from_config(&state.config))
// Only advertise the NIP-11 `self` field when a stable relay key is configured.
// Ephemeral (auto-generated) keys change on restart, making signed events unverifiable.
let relay_pubkey = if state.config.relay_private_key.is_some() {
Some(state.relay_keypair.public_key().to_hex())
} else {
None
};
axum::response::Json(RelayInfo::from_config(
&state.config,
relay_pubkey.as_deref(),
))
}
#[cfg(test)]
+18 -2
View File
@@ -116,6 +116,15 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/huddle/{channel_id}/audio",
get(audio::handler::ws_audio_handler),
)
// Relay membership routes (NIP-43)
.route(
"/api/relay/members",
get(api::relay_members::list_relay_members),
)
.route(
"/api/relay/members/me",
get(api::relay_members::get_my_relay_membership),
)
// Membership routes
.route("/api/channels/{channel_id}/members", get(api::list_members))
// Channel detail + metadata routes
@@ -215,8 +224,15 @@ async fn nip11_or_ws_handler(
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// Only advertise NIP-11 `self` when a stable relay key is configured.
let relay_pubkey = if state.config.relay_private_key.is_some() {
Some(state.relay_keypair.public_key().to_hex())
} else {
None
};
if accept.contains("application/nostr+json") {
let info = RelayInfo::from_config(&state.config);
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
return Json(info).into_response();
}
@@ -226,7 +242,7 @@ async fn nip11_or_ws_handler(
.into_response(),
Err(_) => {
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
let info = RelayInfo::from_config(&state.config);
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
Json(info).into_response()
}
}
+51
View File
@@ -0,0 +1,51 @@
-- Migration 0001: relay_members
--
-- Introduces the relay-level membership table (NIP-43).
-- Replaces the old pubkey_allowlist with a richer model that tracks role
-- (owner / admin / member), who added the entry, and timestamps.
--
-- Idempotent: safe to run more than once.
-- ── 1. Create relay_members ───────────────────────────────────────────────────
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'relay_members'
) THEN
CREATE TABLE relay_members (
pubkey TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
added_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_relay_members_role ON relay_members(role);
END IF;
END $$;
-- ── 2. Migrate existing allowlist rows ────────────────────────────────────────
--
-- pubkey_allowlist stores pubkeys as BYTEA and timestamps as added_at.
-- Convert BYTEA to lowercase hex text via encode(..., 'hex').
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'pubkey_allowlist'
) THEN
INSERT INTO relay_members (pubkey, role, added_by, created_at)
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at
FROM pubkey_allowlist
ON CONFLICT (pubkey) DO NOTHING;
END IF;
END $$;
-- NOTE: pubkey_allowlist is intentionally NOT dropped here.
-- The old allowlist code still references it. Drop it in a future migration
-- once all references have been removed.
+12
View File
@@ -329,3 +329,15 @@ CREATE TABLE pubkey_allowlist (
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT
);
-- ── Relay members (NIP-43) ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS relay_members (
pubkey TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
added_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role);
+740
View File
@@ -0,0 +1,740 @@
#!/usr/bin/env bash
# =============================================================================
# e2e-relay-membership.sh — End-to-end test for NIP-43 relay membership
# =============================================================================
# Tests relay-level membership enforcement and admin commands.
#
# Prerequisites:
# - Docker services running (postgres, redis, typesense)
# - Relay built: cargo build --release --bin sprout-relay
# - nak available on PATH (for event signing)
#
# What it tests:
# 1. Non-member REST calls are rejected (401/403)
# 2. Owner adds admin via kind:9030
# 3. Admin adds member via kind:9030
# 4. Owner changes member role via kind:9032
# 5. Admin removes member via kind:9031
# 6. Members cannot add others (permission denied)
# 7. Admins cannot change roles (permission denied)
# 8. Owner cannot be removed
# 9. GET /api/relay/members returns correct list
# 10. GET /api/relay/members/me returns correct role
# 11. NIP-11 self field is advertised
# 12. kind:13534 membership list published after add
# 13. kind:8000 member-added announcement published
# 14. kind:28936 leave request (happy path)
# 15. kind:8001 member-removed announcement after leave
# 16. Leave request without NIP-70 - tag is rejected
# 17. Stale admin command rejected (replay protection)
# 18. Owner cannot leave (lockout prevention)
# 19. Admin cannot add another admin
# 20. Admin cannot remove the owner
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${BLUE}[e2e-membership]${NC} $*"; }
success() { echo -e "${GREEN}[e2e-membership]${NC}$*"; }
warn() { echo -e "${YELLOW}[e2e-membership]${NC}$*"; }
# Failure counter — tests record failures and continue; fatal() exits immediately.
FAILURES=0
fail() {
echo -e "${RED}[e2e-membership]${NC}$*" >&2
FAILURES=$((FAILURES + 1))
}
fatal() {
echo -e "${RED}[e2e-membership]${NC} ✗ FATAL: $*" >&2
cleanup
exit 1
}
# ── Cleanup ───────────────────────────────────────────────────────────────────
RELAY_PID=""
cleanup() {
if [[ -n "$RELAY_PID" ]]; then
kill "$RELAY_PID" 2>/dev/null || true
wait "$RELAY_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# ── Helpers: keypair generation ───────────────────────────────────────────────
generate_keypair() {
openssl rand -hex 32
}
derive_pubkey() {
local privkey="$1"
python3 -c "
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
def point_add(p1, p2):
if p1 is None: return p2
if p2 is None: return p1
x1, y1 = p1; x2, y2 = p2
if x1 == x2 and y1 != y2: return None
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
x3 = (lam*lam - x1 - x2) % P
y3 = (lam*(x1-x3) - y1) % P
return (x3, y3)
def scalar_mult(k, point):
result = None; addend = point
while k:
if k & 1: result = point_add(result, addend)
addend = point_add(addend, addend)
k >>= 1
return result
pub = scalar_mult(int('$privkey', 16), (Gx, Gy))
print(format(pub[0], '064x'))
"
}
# ── Helper: sign and send a Nostr event via nak ───────────────────────────────
#
# Usage: send_event <privkey> <kind> <content> [tag ...]
# Each tag is passed as a separate argument in nak --tag format: "key=value"
#
# Returns the relay response on stdout.
send_event() {
local privkey="$1"
local kind="$2"
local content="$3"
shift 3
local tag_args=()
for t in "$@"; do
tag_args+=(--tag "$t")
done
nak event \
--sec "$privkey" \
--kind "$kind" \
--content "$content" \
"${tag_args[@]}" \
ws://localhost:3000 2>&1
}
# ── Helper: REST call with X-Pubkey header (dev mode) ────────────────────────
#
# When SPROUT_REQUIRE_AUTH_TOKEN=false the relay accepts an X-Pubkey header
# containing the caller's hex pubkey — no token minting required.
# This is the correct pattern for dev-mode E2E tests.
#
# Returns the HTTP status code on stdout.
rest_call() {
local privkey="$1"
local method="$2"
local path="$3"
local body="${4:-}"
local pubkey
pubkey=$(derive_pubkey "$privkey")
local curl_args=(-s -o /dev/null -w "%{http_code}" -X "$method"
-H "X-Pubkey: $pubkey")
if [[ -n "$body" ]]; then
curl_args+=(-H "Content-Type: application/json" -d "$body")
fi
curl "${curl_args[@]}" "http://localhost:3000${path}"
}
# ── Helper: REST GET, return body ─────────────────────────────────────────────
rest_get_body() {
local privkey="$1"
local path="$2"
local pubkey
pubkey=$(derive_pubkey "$privkey")
curl -s -X GET \
-H "X-Pubkey: $pubkey" \
"http://localhost:3000${path}"
}
# ── Helper: check relay OK response ──────────────────────────────────────────
#
# Returns 0 if the response is ["OK", <id>, true, ...], 1 otherwise.
is_ok() {
echo "$1" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, list) and len(data) >= 3 and data[0] == 'OK' and data[2] is True:
sys.exit(0)
except: pass
sys.exit(1)
" 2>/dev/null
}
# ── Helper: check relay rejection (OK false) ─────────────────────────────────
#
# Returns 0 if the response is ["OK", <id>, false, ...], 1 otherwise.
is_rejected() {
echo "$1" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, list) and len(data) >= 3 and data[0] == 'OK' and data[2] is False:
sys.exit(0)
except: pass
sys.exit(1)
" 2>/dev/null
}
# ── Start relay ───────────────────────────────────────────────────────────────
log "Starting relay with relay membership enforcement enabled..."
if [[ -f .env ]]; then
set -o allexport
source .env
set +o allexport
fi
export SPROUT_BIND_ADDR="0.0.0.0:3000"
export RELAY_URL="ws://localhost:3000"
export RUST_LOG="sprout_relay=warn"
export SPROUT_REQUIRE_AUTH_TOKEN=false
export SPROUT_REQUIRE_RELAY_MEMBERSHIP=true
# Generate owner keypair BEFORE relay start — main.rs requires RELAY_OWNER_PUBKEY
log "Generating owner keypair..."
OWNER_PRIVKEY=$(generate_keypair)
OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY")
export RELAY_OWNER_PUBKEY="$OWNER_PUBKEY"
log "Owner pubkey: $OWNER_PUBKEY"
# Generate a stable relay signing key for NIP-43 self-signed events (kind:13534, etc.)
RELAY_SK=$(generate_keypair)
export SPROUT_RELAY_PRIVATE_KEY="$RELAY_SK"
log "Relay signing key set (NIP-43 self-signed events enabled)"
# Kill any existing relay
pkill -f "sprout-relay" 2>/dev/null || true
sleep 1
./target/release/sprout-relay > /tmp/sprout-relay-membership-e2e.log 2>&1 &
RELAY_PID=$!
for i in $(seq 1 15); do
if curl -s http://localhost:3000/ -H "Accept: application/nostr+json" | grep -q "Sprout"; then
break
fi
if [[ $i -eq 15 ]]; then
fatal "Relay did not start. Check /tmp/sprout-relay-membership-e2e.log"
fi
sleep 1
done
success "Relay started (PID $RELAY_PID)"
# ── Generate identities ───────────────────────────────────────────────────────
# Generate remaining keypairs (after relay is running)
log "Generating remaining keypairs..."
ADMIN_PRIVKEY=$(generate_keypair)
ADMIN_PUBKEY=$(derive_pubkey "$ADMIN_PRIVKEY")
MEMBER_PRIVKEY=$(generate_keypair)
MEMBER_PUBKEY=$(derive_pubkey "$MEMBER_PRIVKEY")
NONMEMBER_PRIVKEY=$(generate_keypair)
NONMEMBER_PUBKEY=$(derive_pubkey "$NONMEMBER_PRIVKEY")
ATTACKER_PRIVKEY=$(generate_keypair)
ATTACKER_PUBKEY=$(derive_pubkey "$ATTACKER_PRIVKEY")
log " Owner: ${OWNER_PUBKEY:0:16}..."
log " Admin: ${ADMIN_PUBKEY:0:16}..."
log " Member: ${MEMBER_PUBKEY:0:16}..."
log " Non-member:${NONMEMBER_PUBKEY:0:16}..."
log " Attacker: ${ATTACKER_PUBKEY:0:16}..."
# ── Test 1: Non-member REST calls are rejected ────────────────────────────────
log "Test 1: Non-member REST calls should be rejected..."
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/channels)
if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then
success "Unauthenticated request rejected ($STATUS)"
else
fail "Expected 401/403 for unauthenticated request, got $STATUS"
fi
# Non-member with valid auth should also be rejected
STATUS=$(rest_call "$NONMEMBER_PRIVKEY" GET /api/channels)
if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then
success "Non-member request rejected ($STATUS)"
else
fail "Non-member got $STATUS — expected 401 or 403 (membership enforcement bypass!)"
fi
# ── Test 1b: Non-member WebSocket event should be rejected ───────────────────
log "Test 1b: Non-member WebSocket event should be rejected..."
# Non-member tries to send a stream message over WebSocket.
# The relay enforces NIP-43 during NIP-42 AUTH, so the event will be rejected
# before it is processed. Channel ID is irrelevant — auth fires first.
RESULT=$(send_event "$NONMEMBER_PRIVKEY" 9 "h=00000000-0000-0000-0000-000000000000" 2>&1 || true)
if echo "$RESULT" | grep -qi 'not a relay member\|restricted\|auth\|false'; then
success "Non-member WebSocket event rejected"
else
warn "Could not verify WebSocket non-member rejection: $RESULT"
fi
# ── Test 2: Owner adds admin (kind:9030) ──────────────────────────────────────
log "Test 2: Owner adds admin via kind:9030..."
RESULT=$(send_event "$OWNER_PRIVKEY" 9030 "" \
"p=$ADMIN_PUBKEY" \
"role=admin")
if is_ok "$RESULT"; then
success "Owner added admin (kind:9030 accepted)"
else
fail "Owner failed to add admin: $RESULT"
fi
sleep 1
# Verify admin appears in member list
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$ADMIN_PUBKEY"; then
success "Admin pubkey appears in /api/relay/members"
else
fail "Admin pubkey not found in /api/relay/members: $MEMBERS"
fi
# ── Test 3: Admin adds member (kind:9030) ─────────────────────────────────────
log "Test 3: Admin adds member via kind:9030..."
RESULT=$(send_event "$ADMIN_PRIVKEY" 9030 "" \
"p=$MEMBER_PUBKEY" \
"role=member")
if is_ok "$RESULT"; then
success "Admin added member (kind:9030 accepted)"
else
fail "Admin failed to add member: $RESULT"
fi
sleep 1
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then
success "Member pubkey appears in /api/relay/members"
else
fail "Member pubkey not found in /api/relay/members: $MEMBERS"
fi
# ── Test 4: Owner changes role (kind:9032) ────────────────────────────────────
log "Test 4: Owner changes member role to admin via kind:9032..."
RESULT=$(send_event "$OWNER_PRIVKEY" 9032 "" \
"p=$MEMBER_PUBKEY" \
"role=admin")
if is_ok "$RESULT"; then
success "Owner changed member role (kind:9032 accepted)"
else
fail "Owner failed to change role: $RESULT"
fi
sleep 1
# Verify role changed
MEMBER_INFO=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBER_INFO" | python3 -c "
import sys, json
data = json.load(sys.stdin)
members = data if isinstance(data, list) else data.get('members', [])
for m in members:
if m.get('pubkey', '').startswith('${MEMBER_PUBKEY:0:8}') or m.get('pubkey') == '${MEMBER_PUBKEY}':
if m.get('role') == 'admin':
sys.exit(0)
sys.exit(1)
" 2>/dev/null; then
success "Member role updated to admin in /api/relay/members"
else
fail "Role change not reflected in /api/relay/members response"
fi
# Reset member back to 'member' role for subsequent tests
send_event "$OWNER_PRIVKEY" 9032 "" \
"p=$MEMBER_PUBKEY" \
"role=member" > /dev/null 2>&1 || true
sleep 1
# ── Test 5: Admin removes member (kind:9031) ──────────────────────────────────
log "Test 5: Admin removes member via kind:9031..."
RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \
"p=$MEMBER_PUBKEY")
if is_ok "$RESULT"; then
success "Admin removed member (kind:9031 accepted)"
else
fail "Admin failed to remove member: $RESULT"
fi
sleep 1
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then
fail "Member pubkey still present in /api/relay/members after removal"
else
success "Member pubkey absent from /api/relay/members after removal"
fi
# Re-add member for permission tests
send_event "$ADMIN_PRIVKEY" 9030 "" \
"p=$MEMBER_PUBKEY" \
"role=member" > /dev/null 2>&1 || true
sleep 1
# ── Test 6: Members cannot add others ────────────────────────────────────────
log "Test 6: Member attempts to add non-member (should be denied)..."
RESULT=$(send_event "$MEMBER_PRIVKEY" 9030 "" \
"p=$NONMEMBER_PUBKEY" \
"role=member")
if is_ok "$RESULT"; then
fail "Member was able to add another user (should be denied)"
elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then
success "Member add attempt rejected"
else
fail "Ambiguous response for member add attempt: $RESULT"
fi
# ── Test 7: Admins cannot change roles ───────────────────────────────────────
log "Test 7: Admin attempts to change owner role (should be denied)..."
RESULT=$(send_event "$ADMIN_PRIVKEY" 9032 "" \
"p=$OWNER_PUBKEY" \
"role=member")
if is_ok "$RESULT"; then
fail "Admin was able to change owner role (should be denied)"
elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then
success "Admin role-change on owner rejected"
else
fail "Ambiguous response for admin role-change attempt: $RESULT"
fi
# ── Test 8: Owner cannot be removed ──────────────────────────────────────────
log "Test 8: Admin attempts to remove owner (should be denied)..."
RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \
"p=$OWNER_PUBKEY")
if is_ok "$RESULT"; then
fail "Admin was able to remove the owner (should be denied)"
elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then
success "Owner removal attempt rejected"
else
fail "Ambiguous response for owner removal attempt: $RESULT"
fi
# Verify owner still present
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then
success "Owner still present in /api/relay/members"
else
fail "Owner missing from /api/relay/members after removal attempt"
fi
# ── Test 9: GET /api/relay/members returns correct list ───────────────────────
log "Test 9: GET /api/relay/members returns expected members..."
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
STATUS=$(rest_call "$OWNER_PRIVKEY" GET /api/relay/members)
if [[ "$STATUS" == "200" ]]; then
success "GET /api/relay/members returned 200"
else
fail "GET /api/relay/members returned $STATUS (expected 200)"
fi
for pubkey in "$OWNER_PUBKEY" "$ADMIN_PUBKEY" "$MEMBER_PUBKEY"; do
if echo "$MEMBERS" | grep -q "$pubkey"; then
success " ${pubkey:0:16}... present in member list"
else
fail " ${pubkey:0:16}... missing from member list"
fi
done
if echo "$MEMBERS" | grep -q "$NONMEMBER_PUBKEY"; then
fail "Non-member ${NONMEMBER_PUBKEY:0:16}... should not appear in member list"
else
success "Non-member correctly absent from member list"
fi
# ── Test 10: GET /api/relay/members/me returns correct role ──────────────────
log "Test 10: GET /api/relay/members/me returns correct role..."
for pair in "${OWNER_PRIVKEY}:owner" "${ADMIN_PRIVKEY}:admin" "${MEMBER_PRIVKEY}:member"; do
privkey="${pair%%:*}"
expected_role="${pair##*:}"
STATUS=$(rest_call "$privkey" GET /api/relay/members/me)
BODY=$(rest_get_body "$privkey" /api/relay/members/me)
if [[ "$STATUS" == "200" ]]; then
if echo "$BODY" | grep -q "\"$expected_role\""; then
success " /api/relay/members/me: $expected_role role confirmed (status $STATUS)"
else
warn " /api/relay/members/me: expected role '$expected_role', got: $BODY"
fi
else
warn " /api/relay/members/me returned $STATUS for $expected_role"
fi
done
# Non-member should get 403/404
STATUS=$(rest_call "$NONMEMBER_PRIVKEY" GET /api/relay/members/me)
if [[ "$STATUS" == "403" || "$STATUS" == "404" ]]; then
success "Non-member /api/relay/members/me correctly rejected ($STATUS)"
else
warn "Non-member /api/relay/members/me returned $STATUS (expected 403/404)"
fi
# ── Test 11: NIP-11 self field is advertised ──────────────────────────────────
log "Test 11: NIP-11 self field is advertised..."
RELAY_HTTP="http://localhost:3000"
SELF_PK=$(curl -s -H "Accept: application/nostr+json" "$RELAY_HTTP" | jq -r '.self // empty')
if [[ -n "$SELF_PK" && "$SELF_PK" != "null" ]]; then
success "NIP-11 self field present: ${SELF_PK:0:16}..."
else
fail "NIP-11 self field missing from relay info document"
fi
# ── Test 12: kind:13534 membership list published after add ───────────────────
#
# After Test 3 added a member, the relay should have published a signed
# kind:13534 membership list event to the DB.
log "Test 12: kind:13534 membership list published after member add..."
COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=13534;" 2>/dev/null | tr -d ' \n')
if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then
success "kind:13534 membership list found in DB ($COUNT row(s))"
else
fail "kind:13534 not found in DB — relay may not be publishing membership lists"
fi
# ── Test 13: kind:8000 member-added announcement published ────────────────────
log "Test 13: kind:8000 member-added announcement published..."
COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=8000;" 2>/dev/null | tr -d ' \n')
if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then
success "kind:8000 member-added announcement found in DB ($COUNT row(s))"
else
fail "kind:8000 not found in DB — relay may not be publishing member-added announcements"
fi
# ── Test 14: kind:28936 leave request (happy path) ────────────────────────────
#
# Member sends a valid leave request with the NIP-70 protected-event `-` tag.
# The relay should accept it and remove the member.
log "Test 14: kind:28936 leave request (happy path)..."
RESULT=$(send_event "$MEMBER_PRIVKEY" 28936 "" "-=")
if is_ok "$RESULT"; then
success "Leave request accepted (kind:28936 with NIP-70 - tag)"
else
fail "Leave request rejected: $RESULT"
fi
sleep 1
# Verify member was removed
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then
fail "Member still present in /api/relay/members after leave request"
else
success "Member correctly absent from /api/relay/members after leave"
fi
# ── Test 15: kind:8001 member-removed announcement after leave ────────────────
log "Test 15: kind:8001 member-removed announcement published after leave..."
COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=8001;" 2>/dev/null | tr -d ' \n')
if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then
success "kind:8001 member-removed announcement found in DB ($COUNT row(s))"
else
fail "kind:8001 not found in DB — relay may not be publishing member-removed announcements"
fi
# ── Test 16: Leave request without NIP-70 - tag is rejected ──────────────────
#
# Re-add the member first, then send a leave WITHOUT the required `-` tag.
# The relay must reject it (NIP-70 enforcement).
log "Test 16: Leave request without NIP-70 - tag is rejected..."
# Re-add member
send_event "$ADMIN_PRIVKEY" 9030 "" \
"p=$MEMBER_PUBKEY" \
"role=member" > /dev/null 2>&1 || true
sleep 1
# Send leave without the - tag
RESULT=$(send_event "$MEMBER_PRIVKEY" 28936 "")
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|nip-70\|protected\|false'; then
success "Leave without NIP-70 - tag correctly rejected"
else
fail "Leave without NIP-70 - tag was accepted (should be rejected): $RESULT"
fi
# ── Test 17: Stale admin command rejected (replay protection) ─────────────────
#
# An admin command with a created_at more than 5 minutes in the past must be
# rejected to prevent replay attacks.
log "Test 17: Stale admin command rejected (replay protection)..."
OLD_TS=$(($(date +%s) - 300))
# Use nak directly to control the timestamp; pipe output to the relay
RESULT=$(nak event \
--sec "$ADMIN_PRIVKEY" \
--kind 9030 \
--created-at "$OLD_TS" \
--tag "p=$ATTACKER_PUBKEY" \
--tag "role=member" \
--content "" \
ws://localhost:3000 2>&1 || true)
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'stale\|old\|expired\|restricted\|false'; then
success "Stale admin command (created_at -5m) rejected"
else
fail "Stale admin command was accepted (replay protection missing): $RESULT"
fi
# Verify attacker was NOT added
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$ATTACKER_PUBKEY"; then
fail "Attacker was added via stale command (replay protection bypass!)"
else
success "Attacker not present in member list after stale command"
fi
# ── Test 18: Owner cannot leave (lockout prevention) ─────────────────────────
#
# The relay must refuse a leave request from the owner to prevent lockout.
log "Test 18: Owner cannot leave (lockout prevention)..."
RESULT=$(send_event "$OWNER_PRIVKEY" 28936 "" "-=")
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'owner\|restricted\|denied\|forbidden\|false'; then
success "Owner leave request correctly rejected"
else
fail "Owner leave request was accepted (lockout prevention missing): $RESULT"
fi
# Verify owner still present
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then
success "Owner still present in /api/relay/members after leave attempt"
else
fail "Owner missing from /api/relay/members after leave attempt"
fi
# ── Test 19: Admin cannot add another admin ───────────────────────────────────
#
# Admins can only add members. Elevating to admin requires owner privilege.
log "Test 19: Admin cannot add another admin..."
RESULT=$(send_event "$ADMIN_PRIVKEY" 9030 "" \
"p=$ATTACKER_PUBKEY" \
"role=admin")
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission\|false'; then
success "Admin cannot add another admin (correctly rejected)"
else
fail "Admin was able to add an admin (privilege escalation!): $RESULT"
fi
# Verify attacker was NOT added as admin
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | python3 -c "
import sys, json
data = json.load(sys.stdin)
members = data if isinstance(data, list) else data.get('members', [])
for m in members:
if m.get('pubkey') == '${ATTACKER_PUBKEY}' and m.get('role') == 'admin':
sys.exit(0)
sys.exit(1)
" 2>/dev/null; then
fail "Attacker has admin role in member list (privilege escalation confirmed!)"
else
success "Attacker not present as admin in member list"
fi
# ── Test 20: Admin cannot remove the owner ────────────────────────────────────
log "Test 20: Admin cannot remove the owner..."
RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \
"p=$OWNER_PUBKEY")
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission\|false'; then
success "Admin cannot remove the owner (correctly rejected)"
else
fail "Admin was able to remove the owner (privilege escalation!): $RESULT"
fi
# Verify owner still present
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then
success "Owner still present in /api/relay/members after admin removal attempt"
else
fail "Owner missing from /api/relay/members — admin removed owner!"
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
echo "Final member list:"
echo "─────────────────────"
rest_get_body "$OWNER_PRIVKEY" /api/relay/members | python3 -m json.tool 2>/dev/null || true
echo ""
if [[ "$FAILURES" -eq 0 ]]; then
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✅ All NIP-43 relay membership E2E tests passed!${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
else
echo -e "${RED}════════════════════════════════════════════════════════${NC}"
echo -e "${RED}${FAILURES} test(s) failed!${NC}"
echo -e "${RED}════════════════════════════════════════════════════════${NC}"
exit 1
fi