Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,16 @@ jobs:
RELAY_URL=ws://localhost:3000 \
SPROUT_BIND_ADDR=0.0.0.0:3000 \
SPROUT_REQUIRE_AUTH_TOKEN=false \
SPROUT_RECONCILE_CHANNELS=true \
./target/ci/sprout-relay > /tmp/sprout-relay.log 2>&1 &
echo $! > /tmp/sprout-relay.pid
for attempt in $(seq 1 60); do
if ! kill -0 "$(cat /tmp/sprout-relay.pid)" 2>/dev/null; then
cat /tmp/sprout-relay.log
exit 1
fi
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/api/channels || true)
if [ "${status_code}" != "000" ]; then
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
if [ "${status_code}" = "200" ]; then
exit 0
fi
sleep 1
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 133 additions & 11 deletions crates/sprout-acp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10);
///
/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence
/// updates must be routed through the WS path.
///
/// Content is a bare status string (`"online"`, `"away"`, `"offline"`) matching
/// the desktop client's format. The relay stores this in Redis and synthesizes
/// it back on presence queries.
async fn publish_presence(
publisher: &relay::RelayEventPublisher,
keys: &nostr::Keys,
Expand All @@ -67,8 +71,7 @@ async fn publish_presence(
use nostr::{EventBuilder, Kind};
use sprout_core::kind::KIND_PRESENCE_UPDATE;

let content = serde_json::json!({ "status": status }).to_string();
let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), &content, [])
let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status, [])
.sign_with_keys(keys)
.map_err(|e| relay::RelayError::Http(format!("presence sign error: {e}")))?;
publisher.publish_event(event).await?;
Expand Down Expand Up @@ -110,30 +113,149 @@ fn resolve_agent_owner(config: &Config) -> Option<String> {
/// Cache for the agent's owner pubkey.
///
/// Owner is now provided via `--agent-owner` config flag (no REST lookup).
/// Kept as a struct for API compatibility with the sibling cache.
/// Cache for the agent's owner pubkey + sibling lookups.
///
/// Siblings are other agents whose NIP-OA auth tag proves the same owner.
/// Lookup results are cached for the process lifetime (attestations are immutable).
struct OwnerCache {
pubkey: Option<String>,
/// author_hex → is_sibling (true = same owner, false = not)
siblings: std::sync::Mutex<HashMap<String, bool>>,
}

impl OwnerCache {
fn new(initial: Option<String>) -> Self {
Self { pubkey: initial }
Self {
pubkey: initial,
siblings: std::sync::Mutex::new(HashMap::new()),
}
}

/// Return the cached owner pubkey.
fn get(&self) -> Option<&str> {
self.pubkey.as_deref()
}

/// Check if author is a known sibling (cached result).
fn is_known_sibling(&self, author: &str) -> Option<bool> {
self.siblings.lock().ok()?.get(author).copied()
}

/// Cache a sibling lookup result.
fn cache_sibling(&self, author: String, is_sibling: bool) {
if let Ok(mut map) = self.siblings.lock() {
// Cap at 256 entries to prevent unbounded growth.
if map.len() >= 256 {
map.clear();
}
map.insert(author, is_sibling);
}
}
}

/// Check if `author` is the owner.
/// Check if `author` is the owner OR a sibling (same owner via NIP-OA).
///
/// Used by the `OwnerOnly` author gate mode. Owner is provided via config.
fn is_owner(author: &str, owner_cache: &OwnerCache) -> bool {
match owner_cache.get() {
Some(o) => author == o,
None => false, // no owner configured — fail closed
/// For unknown authors, queries their kind:0 profile to extract the NIP-OA
/// auth tag and verify the owner matches. Result is cached.
async fn is_owner_or_sibling(
author: &str,
owner_cache: &OwnerCache,
rest_client: &relay::RestClient,
) -> bool {
let my_owner = match owner_cache.get() {
Some(o) => o,
None => return false, // no owner configured — fail closed
};

// Direct owner check.
if author == my_owner {
return true;
}

// Check sibling cache.
if let Some(cached) = owner_cache.is_known_sibling(author) {
return cached;
}

// Query the author's kind:0 profile to check for NIP-OA auth tag.
let is_sibling = check_sibling_via_profile(author, my_owner, rest_client).await;
owner_cache.cache_sibling(author.to_string(), is_sibling);
is_sibling
}

/// Query an author's kind:0 profile and check if their NIP-OA auth tag
/// proves the same owner as us.
async fn check_sibling_via_profile(
author: &str,
expected_owner: &str,
rest_client: &relay::RestClient,
) -> bool {
let filter = nostr::Filter::new()
.kind(nostr::Kind::Metadata)
.author(match nostr::PublicKey::from_hex(author) {
Ok(pk) => pk,
Err(_) => return false,
})
.limit(1);

let resp = match tokio::time::timeout(Duration::from_millis(2000), rest_client.query(&[filter]))
.await
{
Ok(Ok(v)) => v,
_ => return false, // timeout or error — fail closed
};

// Look for an "auth" tag in the profile event.
let events = match resp.as_array() {
Some(arr) => arr,
None => return false,
};
let event = match events.first() {
Some(e) => e,
None => return false,
};
let tags = match event.get("tags").and_then(|t| t.as_array()) {
Some(t) => t,
None => return false,
};

// Find ["auth", owner_pk, conditions, sig] and verify the Schnorr signature.
// Don't trust the relay — verify ourselves.
let agent_pk = match nostr::PublicKey::from_hex(author) {
Ok(pk) => pk,
Err(_) => return false,
};

for tag in tags {
let parts = match tag.as_array() {
Some(p) if p.len() >= 4 => p,
_ => continue,
};
if parts[0].as_str() != Some("auth") {
continue;
}
let tag_owner = match parts[1].as_str() {
Some(o) => o,
None => continue,
};
// Only verify if the owner field matches ours.
if !tag_owner.eq_ignore_ascii_case(expected_owner) {
continue;
}
// Cryptographically verify the NIP-OA attestation signature.
let tag_json = serde_json::to_string(tag).unwrap_or_default();
match sprout_sdk::nip_oa::verify_auth_tag(&tag_json, &agent_pk) {
Ok(_) => {
tracing::debug!(author, expected_owner, "sibling verified via NIP-OA");
return true;
}
Err(e) => {
tracing::debug!(author, "NIP-OA auth tag verification failed: {e}");
}
}
}

false
}

fn spawn_relay_observer_publisher(
Expand Down Expand Up @@ -1392,7 +1514,7 @@ async fn tokio_main() -> Result<()> {
RespondTo::Anyone => true,
RespondTo::Nobody => false,
RespondTo::OwnerOnly => {
is_owner(&author, &owner_cache)
is_owner_or_sibling(&author, &owner_cache, &ctx.rest_client).await
}
RespondTo::Allowlist => {
let owner = owner_cache.get();
Expand Down
5 changes: 4 additions & 1 deletion crates/sprout-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ impl RestClient {
.map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?;
let method_tag = Tag::parse(&["method", method])
.map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?;
let mut tags = vec![u_tag, method_tag];
// Nonce prevents replay rejection for rapid-fire requests with identical bodies.
let nonce_tag = Tag::parse(&["nonce", &uuid::Uuid::new_v4().to_string()])
.map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?;
let mut tags = vec![u_tag, method_tag, nonce_tag];

if let Some(b) = body {
let hash = hex::encode(Sha256::digest(b));
Expand Down
1 change: 1 addition & 0 deletions crates/sprout-admin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ path = "src/main.rs"

[dependencies]
sprout-db = { workspace = true }
sprout-core = { workspace = true }
sprout-auth = { workspace = true }
nostr = { workspace = true }
tokio = { workspace = true }
Expand Down
138 changes: 138 additions & 0 deletions crates/sprout-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ enum Command {
ListMembers,
/// Generate a new Nostr keypair (for bootstrapping).
GenerateKey,
/// Emit kind:39000/39002 events for channels missing them.
///
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
/// have Nostr discovery events. This command creates them so pure-nostr
/// clients can see those channels. Idempotent — safe to run multiple times.
ReconcileChannels {
/// Relay private key (hex) for signing events. Falls back to
/// SPROUT_RELAY_PRIVATE_KEY env var. If neither is set, generates
/// an ephemeral key (events will be unverifiable after restart).
#[arg(long)]
relay_key: Option<String>,
},
}

#[tokio::main]
Expand Down Expand Up @@ -71,6 +83,9 @@ async fn main() -> Result<()> {
}
}
}
Command::ReconcileChannels { relay_key } => {
reconcile_channels(relay_key).await?;
}
}

Ok(())
Expand All @@ -86,3 +101,126 @@ async fn connect_db() -> Result<Db> {
.await?;
Ok(db)
}

async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
use nostr::{EventBuilder, Kind, Tag};
use sprout_core::kind::KIND_NIP29_GROUP_ADMINS;
use sprout_db::event::EventQuery;

let db = connect_db().await?;

// Resolve relay signing key: arg > env > ephemeral
let relay_keys = match relay_key_arg.or_else(|| std::env::var("SPROUT_RELAY_PRIVATE_KEY").ok())
{
Some(key_hex) => {
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
}
None => {
let k = Keys::generate();
eprintln!(
"Warning: no relay key provided — using ephemeral key {}",
k.public_key().to_hex()
);
eprintln!("Events signed with this key won't be verifiable after this run.");
eprintln!("Pass --relay-key or set SPROUT_RELAY_PRIVATE_KEY for production use.");
k
}
};

let channels = db.list_channels(None).await?;
if channels.is_empty() {
println!("No channels in database.");
return Ok(());
}

let mut reconciled = 0u32;
let mut skipped = 0u32;

for channel in &channels {
let channel_id_str = channel.id.to_string();

// Check if kind:39000 already exists
let existing = db
.query_events(&EventQuery {
kinds: Some(vec![39000]),
d_tag: Some(channel_id_str.clone()),
limit: Some(1),
..Default::default()
})
.await
.unwrap_or_default();

if !existing.is_empty() {
skipped += 1;
continue;
}

let members = db.get_members(channel.id).await?;

// kind:39000 — channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &channel_id_str])?];
tags.push(Tag::parse(&["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(&["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(&["private"])?);
} else {
tags.push(Tag::parse(&["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(&["hidden"])?);
}
tags.push(Tag::parse(&["closed"])?);
tags.push(Tag::parse(&["t", &channel.channel_type])?);

let event = EventBuilder::new(Kind::Custom(39000), "", tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
.await?;
}

// kind:39001 — admins
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(&["p", &pk, &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "", tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
.await?;
}

// kind:39002 — members
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &channel_id_str])?];
for m in &members {
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(&["p", &pk, "", &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(39002), "", tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
.await?;
}

reconciled += 1;
}

println!(
"Reconciled {reconciled} channels ({skipped} already had events, {} total).",
channels.len()
);
Ok(())
}
Loading