feat: public profiles, NIP-05 identity, search & presence tools (#28)

This commit is contained in:
tlongwell-block
2026-03-11 13:48:31 -04:00
committed by GitHub
parent 185c3affe4
commit a3380c33b7
12 changed files with 1374 additions and 75 deletions

View File

@@ -1,13 +1,13 @@
# Sprout Testing Guide
This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 36 MCP tools against a live relay.
This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 40 MCP tools against a live relay.
## Two Test Modes
| Mode | What It Does | When to Use |
|------|-------------|-------------|
| **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions |
| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 36 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features |
| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 40 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features |
Run both modes for a complete regression check. Run automated-only for a fast sanity check.
@@ -27,7 +27,7 @@ Run both modes for a complete regression check. Run automated-only for a fast sa
- [3.7 Expected Results](#37-expected-results)
4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios)
5. [Workflow YAML Reference](#5-workflow-yaml-reference)
6. [The 36 MCP Tools](#6-the-36-mcp-tools)
6. [The 40 MCP Tools](#6-the-40-mcp-tools)
7. [Cleanup](#7-cleanup)
8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting)
@@ -163,15 +163,15 @@ sleep 3 && curl -s http://localhost:3000/health
Then run the integration suites:
```bash
# REST API integration tests (20 tests)
# REST API integration tests (35 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_rest_api -- --ignored
# WebSocket relay integration tests (13 tests)
# WebSocket relay integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_relay -- --ignored
# MCP server integration tests (7 tests)
# MCP server integration tests (10 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_mcp -- --ignored
```
@@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \
### Expected Results
```
test result: ok. 20 passed; 0 failed; 0 ignored ← REST API
test result: ok. 13 passed; 0 failed; 0 ignored ← relay
test result: ok. 7 passed; 0 failed; 0 ignored ← MCP
test result: ok. 35 passed; 0 failed; 0 ignored ← REST API
test result: ok. 14 passed; 0 failed; 0 ignored ← relay
test result: ok. 10 passed; 0 failed; 0 ignored ← MCP
```
All 40 integration tests pass. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.
All 59 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.
---
@@ -202,7 +202,7 @@ Operator (you)
Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose
│
sprout-mcp-server
(36 MCP tools)
(40 MCP tools)
│
Sprout Relay
(send_message, etc.)
@@ -345,6 +345,14 @@ subscribed to channel <uuid>
If you see `discovered 0 channel(s)`, the agent is not yet a member of any channels. Alice will create channels in the first exercise — after that, all three will discover them on subsequent subscriptions (open channels are accessible to any authenticated pubkey).
> **Bootstrap channel timing:** Harnesses discover channels only at startup.
> If you create the bootstrap channel (in exercise A-1) *after* launching
> harnesses, Alice's harness won't be subscribed to it. Two options:
> 1. Create the bootstrap channel *before* launching harnesses (recommended):
> run the `curl -X POST` command from A-1 first, then start all three harnesses.
> 2. Restart Alice's harness after creating the bootstrap channel — it will
> discover and subscribe to it on reconnect.
---
### 3.5 Test Exercises
@@ -428,11 +436,11 @@ mention "$GENERAL" "$ALICE_PUBKEY" \
"Create a workflow named 'alice-notify' with a message_posted trigger on the 'general' channel. The workflow should have one step: send a message to the 'general' channel saying 'Workflow fired!'. Save the workflow ID and report it back."
```
**A-5: Profile and presence**
**A-5: Profile, NIP-05 identity, and presence**
```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your presence to online."
"Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY)."
```
**A-6: Feed, search, and membership**
@@ -487,7 +495,7 @@ mention "$GENERAL" "$BOB_PUBKEY" \
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Try to get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Report whether you can access it or get an error. Then try again — Alice should have invited you by now."
"Get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Alice invited you in exercise A-6 — confirm you have access and report what you find."
```
**B-7: Get presence**
@@ -497,6 +505,13 @@ mention "$GENERAL" "$BOB_PUBKEY" \
"Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both."
```
**B-8: Profile resolution (public profiles)**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list."
```
---
#### Charlie — Edge Case Specialist
@@ -554,6 +569,24 @@ mention "$GENERAL" "$CHARLIE_PUBKEY" \
---
**C-8: NIP-05 identity verification**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Verify the NIP-05 endpoint. Alice set her NIP-05 handle to 'alice@localhost' in exercise A-5. Use curl or an HTTP request to GET http://localhost:3000/.well-known/nostr.json?name=alice — it should return her pubkey in the 'names' map and a relay URL in the 'relays' map. Also try ?name=nonexistent and confirm it returns empty names/relays. Check that the response includes an Access-Control-Allow-Origin: * header. Report your findings."
```
---
**C-9: Profile edge cases**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Test profile edge cases. Use get_user_profile with a pubkey that doesn't exist — report the error. Use get_users_batch with a mix of valid pubkeys, an invalid-length string like 'tooshort', and a string that is 64 chars but not valid hex. Report what ends up in the profiles map vs the missing list."
```
---
### 3.6 Monitoring & Verification
#### Watch harness logs live
@@ -660,6 +693,9 @@ After all exercises complete, the following should be true:
| Bob in private-ops | Yes (Alice invited him in A-6) |
| Workflow | alice-notify created with message_posted trigger |
| Display names | Alice and Bob have display names set |
| Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob with display names, Charlie with null display name (all in profiles map) |
| NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) |
| Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys |
| Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors |
| charlie-lifecycle | Unarchived and final message sent successfully |
@@ -967,16 +1003,16 @@ steps:
---
## 6. The 36 MCP Tools
## 6. The 40 MCP Tools
The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness.
The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness.
### Channels (8)
| Tool | Description |
|------|-------------|
| `list_channels` | List all channels accessible to the agent |
| `get_channel_info` | Get metadata for a specific channel |
| `get_channel` | Get metadata for a specific channel |
| `create_channel` | Create a new channel (`channel_type`: stream\|forum, `visibility`: open\|private) |
| `update_channel` | Update channel name or metadata |
| `archive_channel` | Archive a channel (creator only) |
@@ -984,14 +1020,19 @@ The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surfac
| `join_channel` | Join an open channel |
| `leave_channel` | Leave a channel |
### Messages (4)
### Messages (2)
| Tool | Description |
|------|-------------|
| `send_message` | Post a message to a channel |
| `get_channel_history` | Get recent messages from a channel |
| `send_thread_reply` | Reply within a message thread |
| `get_thread_replies` | Get replies in a thread |
### Threads (2)
| Tool | Description |
|------|-------------|
| `send_reply` | Reply within a message thread |
| `get_thread` | Get replies in a thread |
### Reactions (3)
@@ -999,15 +1040,15 @@ The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surfac
|------|-------------|
| `add_reaction` | Add an emoji reaction to a message |
| `remove_reaction` | Remove a reaction |
| `get_message_reactions` | List all reactions on a message |
| `get_reactions` | List all reactions on a message |
### Direct Messages (3)
| Tool | Description |
|------|-------------|
| `send_dm` | Send a direct message to a user |
| `get_dm_history` | Get DM conversation history |
| `list_dm_conversations` | List all DM conversations |
| `open_dm` | Create or retrieve a DM channel with a user (optionally send an initial message) |
| `add_dm_member` | Add a member to an existing DM conversation |
| `list_dms` | List all DM conversations |
### Canvas (2)
@@ -1016,20 +1057,25 @@ The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surfac
| `get_canvas` | Read the canvas document for a channel |
| `set_canvas` | Write/overwrite the canvas document (last writer wins) |
### Workflows (3)
### Workflows (7)
| Tool | Description |
|------|-------------|
| `create_workflow` | Create a new workflow with trigger and steps |
| `list_workflows` | List all workflows |
| `create_workflow` | Create a new workflow with trigger and steps |
| `update_workflow` | Update an existing workflow |
| `delete_workflow` | Delete a workflow |
| `trigger_workflow` | Manually trigger a webhook workflow |
| `get_workflow_runs` | Get execution history for a workflow |
| `approve_workflow_step` | Approve a pending approval step in a workflow run |
### Feed (2)
### Feed (3)
| Tool | Description |
|------|-------------|
| `get_feed` | Get the agent's personal activity feed |
| `get_channel_feed` | Get the activity feed for a specific channel |
| `get_feed_mentions` | Get mentions from the agent's feed |
| `get_feed_actions` | Get action items from the agent's feed |
### Search (1)
@@ -1041,31 +1087,30 @@ The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surfac
| Tool | Description |
|------|-------------|
| `get_profile` | Get the agent's profile |
| `set_display_name` | Set the agent's display name |
| `set_about` | Set the agent's bio/about text |
| `set_profile` | Set display name, about/bio, avatar URL, and NIP-05 handle |
| `get_user_profile` | Get any user's profile by pubkey (omit pubkey for own profile) |
| `get_users_batch` | Bulk resolve display names and NIP-05 handles for multiple pubkeys |
### Presence (2)
### Presence (1)
| Tool | Description |
|------|-------------|
| `set_presence` | Set the agent's presence status (online/away/etc.) |
| `get_presence` | Get presence status for a user |
| `get_presence` | Bulk presence lookup by pubkey |
### Members (2)
### Members (3)
| Tool | Description |
|------|-------------|
| `add_channel_member` | Add a user (by pubkey) to a channel |
| `remove_channel_member` | Remove a member from a channel |
| `list_channel_members` | List members of a channel |
| `get_user_channels` | Get channels a user belongs to |
### Admin (3)
### Admin (2)
| Tool | Description |
|------|-------------|
| `set_channel_topic` | Set the topic for a channel |
| `set_channel_purpose` | Set the purpose for a channel |
| `invite_to_channel` | Invite a user (by pubkey) to a channel |
---
@@ -1123,11 +1168,11 @@ rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt
### Current Status
All automated tests pass as of 2026-03-10:
All automated tests pass as of 2026-03-11:
- ✅ 20/20 REST API integration tests
- ✅ 32/32 REST API integration tests
- ✅ 13/13 WebSocket relay integration tests
- ✅ 7/7 MCP server integration tests
- ✅ 10/10 MCP server integration tests
- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness
---

View File

@@ -559,15 +559,33 @@ impl Db {
user::get_user(&self.pool, pubkey).await
}
/// Update a user's display_name, avatar_url, and/or about.
/// Update a user's display_name, avatar_url, about, and/or nip05_handle.
pub async fn update_user_profile(
&self,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
nip05_handle: Option<&str>,
) -> Result<()> {
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about).await
user::update_user_profile(
&self.pool,
pubkey,
display_name,
avatar_url,
about,
nip05_handle,
)
.await
}
/// Look up a user by their full NIP-05 handle (exact match).
pub async fn get_user_by_nip05(
&self,
local_part: &str,
domain: &str,
) -> Result<Option<user::UserProfile>> {
user::get_user_by_nip05(&self.pool, local_part, domain).await
}
// ── API Tokens ───────────────────────────────────────────────────────────

View File

@@ -66,17 +66,22 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProf
))
}
/// Update a user's profile fields (display_name, avatar_url, about).
/// Update a user's profile fields (display_name, avatar_url, about, nip05_handle).
/// Only updates fields that are Some — None fields are left unchanged.
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
///
/// Empty strings are treated as "clear to NULL" — this is important for kind:0
/// absolute-state semantics where absent fields must be cleared, and for the
/// `nip05_handle` column which has a UNIQUE constraint (multiple NULLs are allowed,
/// but multiple empty strings would violate uniqueness).
pub async fn update_user_profile(
pool: &MySqlPool,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
nip05_handle: Option<&str>,
) -> Result<()> {
// Build SET clause dynamically to avoid 2^3 match arms.
let mut set_parts: Vec<&str> = Vec::new();
if display_name.is_some() {
set_parts.push("display_name = ?");
@@ -87,24 +92,76 @@ pub async fn update_user_profile(
if about.is_some() {
set_parts.push("about = ?");
}
if nip05_handle.is_some() {
set_parts.push("nip05_handle = ?");
}
if set_parts.is_empty() {
// Nothing to update — caller should have validated at least one field.
return Ok(());
}
// Helper: convert empty string to None (NULL in DB). This ensures UNIQUE
// columns like nip05_handle don't collide on empty strings, and keeps
// semantics clean: absent profile data is NULL, not "".
fn empty_to_none(val: Option<&str>) -> Option<&str> {
val.filter(|s| !s.is_empty())
}
let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", "));
let mut query = sqlx::query(&sql);
if let Some(name) = display_name {
query = query.bind(name);
if display_name.is_some() {
query = query.bind(empty_to_none(display_name));
}
if let Some(url) = avatar_url {
query = query.bind(url);
if avatar_url.is_some() {
query = query.bind(empty_to_none(avatar_url));
}
if let Some(bio) = about {
query = query.bind(bio);
if about.is_some() {
query = query.bind(empty_to_none(about));
}
if nip05_handle.is_some() {
query = query.bind(empty_to_none(nip05_handle));
}
query = query.bind(pubkey);
query.execute(pool).await?;
Ok(())
}
/// Look up a user by their full NIP-05 handle (exact match, case-insensitive).
/// Both `local_part` and `domain` must already be lowercased by the caller.
pub async fn get_user_by_nip05(
pool: &MySqlPool,
local_part: &str,
domain: &str,
) -> Result<Option<UserProfile>> {
let handle = format!("{}@{}", local_part, domain);
let row = sqlx::query_as::<
_,
(
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
r#"
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE LOWER(nip05_handle) = LOWER(?)
LIMIT 1
"#,
)
.bind(&handle)
.fetch_optional(pool)
.await?;
Ok(row.map(
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
pubkey,
display_name,
avatar_url,
about,
nip05_handle,
},
))
}

View File

@@ -380,6 +380,41 @@ pub struct SetProfileParams {
/// Short bio or description.
#[serde(default)]
pub about: Option<String>,
/// NIP-05 identifier (e.g. "alice@example.com"), or None to leave unchanged.
#[serde(default)]
pub nip05_handle: Option<String>,
}
/// Parameters for the `get_user_profile` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetUserProfileParams {
/// Hex-encoded pubkey to look up. Omit to get your own profile.
#[serde(default)]
pub pubkey: Option<String>,
}
/// Parameters for the `get_users_batch` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetUsersBatchParams {
/// List of hex-encoded pubkeys to look up (max 200).
pub pubkeys: Vec<String>,
}
/// Parameters for the `search` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
/// Full-text search query string.
pub q: String,
/// Maximum results to return (default 20, max 100).
#[serde(default)]
pub limit: Option<u32>,
}
/// Parameters for the `get_presence` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetPresenceParams {
/// Comma-separated hex-encoded public keys to look up presence for (max 200).
pub pubkeys: String,
}
/// Parameters for the `get_feed` tool.
@@ -1289,19 +1324,79 @@ impl SproutMcpServer {
/// Update the agent's user profile.
#[tool(
name = "set_profile",
description = "Update the agent's user profile (display name and/or avatar URL)."
description = "Update the agent's user profile (display name, about, avatar URL, and/or NIP-05 handle)."
)]
pub async fn set_profile(&self, Parameters(p): Parameters<SetProfileParams>) -> String {
let body = serde_json::json!({
"display_name": p.display_name,
"avatar_url": p.avatar_url,
"about": p.about,
"nip05_handle": p.nip05_handle,
});
match self.client.put("/api/users/me/profile", &body).await {
Ok(b) => b,
Err(e) => format!("Error: {e}"),
}
}
/// Read a user's profile by pubkey.
#[tool(
name = "get_user_profile",
description = "Get a user's profile by pubkey. Omit pubkey to get your own profile. Returns display name, avatar URL, about text, and NIP-05 handle."
)]
pub async fn get_user_profile(
&self,
Parameters(p): Parameters<GetUserProfileParams>,
) -> String {
let path = match p.pubkey {
None => "/api/users/me/profile".to_string(),
Some(pk) => format!("/api/users/{}/profile", pk),
};
match self.client.get(&path).await {
Ok(body) => body,
Err(e) => format!("Error fetching profile: {e}"),
}
}
/// Resolve display names for multiple pubkeys.
#[tool(
name = "get_users_batch",
description = "Resolve display names and NIP-05 handles for multiple pubkeys at once. Returns a map of pubkey to profile info, plus a list of unknown pubkeys. Useful for identifying message senders in bulk."
)]
pub async fn get_users_batch(&self, Parameters(p): Parameters<GetUsersBatchParams>) -> String {
let body = serde_json::json!({ "pubkeys": p.pubkeys });
match self.client.post("/api/users/batch", &body).await {
Ok(resp) => resp,
Err(e) => format!("Error fetching profiles: {e}"),
}
}
/// Full-text search across messages.
#[tool(
name = "search",
description = "Full-text search across messages in accessible channels. Returns matching messages with channel context. Powered by Typesense."
)]
pub async fn search(&self, Parameters(p): Parameters<SearchParams>) -> String {
let limit = p.limit.unwrap_or(20).min(100);
let path = format!("/api/search?q={}&limit={}", percent_encode(&p.q), limit);
match self.client.get(&path).await {
Ok(body) => body,
Err(e) => format!("Error searching: {e}"),
}
}
/// Get presence status for one or more users.
#[tool(
name = "get_presence",
description = "Get presence status (online/away/offline) for one or more users by pubkey. Pass comma-separated hex pubkeys."
)]
pub async fn get_presence(&self, Parameters(p): Parameters<GetPresenceParams>) -> String {
let path = format!("/api/presence?pubkeys={}", percent_encode(&p.pubkeys));
match self.client.get(&path).await {
Ok(body) => body,
Err(e) => format!("Error fetching presence: {e}"),
}
}
}
#[tool_handler]

View File

@@ -30,6 +30,8 @@ pub mod feed;
pub mod members;
/// Message and thread endpoints.
pub mod messages;
/// NIP-05 identity verification endpoint.
pub mod nip05;
/// Presence status endpoints.
pub mod presence;
/// Reaction endpoints.
@@ -60,7 +62,7 @@ pub use messages::{delete_message, get_thread, list_messages, send_message};
pub use presence::presence_handler;
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
pub use search::search_handler;
pub use users::{get_profile, update_profile};
pub use users::{get_profile, get_user_profile, get_users_batch, update_profile};
pub use workflows::{
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
trigger_workflow, update_workflow, workflow_webhook,

View File

@@ -0,0 +1,94 @@
//! NIP-05 identity verification endpoint.
use std::sync::Arc;
use axum::{
extract::{Query, State},
http::HeaderValue,
response::{IntoResponse, Json, Response},
};
use nostr::util::hex as nostr_hex;
use serde::Deserialize;
use crate::state::AppState;
/// Query parameters for the NIP-05 identity verification endpoint.
#[derive(Deserialize)]
pub struct Nip05Query {
/// The local part of the NIP-05 identifier to look up (e.g. `alice` from `alice@relay.example`).
pub name: Option<String>,
}
/// `GET /.well-known/nostr.json` — NIP-05 identity verification.
/// No authentication required — public discovery endpoint.
pub async fn nostr_nip05(
State(state): State<Arc<AppState>>,
Query(params): Query<Nip05Query>,
) -> Response {
let json = match params.name {
None => serde_json::json!({ "names": {}, "relays": {} }),
Some(n) => {
let name = n.to_lowercase();
// Extract domain from relay_url (e.g. "ws://sprout.block.xyz" → "sprout.block.xyz")
let domain = extract_domain(&state.config.relay_url);
match state.db.get_user_by_nip05(&name, &domain).await {
Ok(Some(user)) => {
let hex_pubkey = nostr_hex::encode(&user.pubkey);
let relay_url = state.config.relay_url.clone();
serde_json::json!({
"names": { (name): hex_pubkey.clone() },
"relays": { (hex_pubkey): [relay_url] }
})
}
_ => serde_json::json!({ "names": {}, "relays": {} }),
}
}
};
let mut response = Json(json).into_response();
response.headers_mut().insert(
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
response
}
/// Validate and canonicalize a NIP-05 handle: must be `local@domain` where domain
/// matches the relay. Returns the lowercased canonical form, or an error message.
pub(crate) fn canonicalize_nip05(raw: &str, relay_url: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("empty".into());
}
let (local, domain) = trimmed
.split_once('@')
.ok_or_else(|| "nip05_handle must be in user@domain format".to_string())?;
if local.is_empty() || domain.is_empty() {
return Err("nip05_handle must be in user@domain format".to_string());
}
let relay_domain = extract_domain(relay_url);
let canonical_domain = domain.to_lowercase();
if canonical_domain != relay_domain {
return Err(format!(
"nip05_handle domain must match this relay ({})",
relay_domain
));
}
Ok(format!("{}@{}", local.to_lowercase(), canonical_domain))
}
/// Extract the domain (host) from a URL string.
/// e.g. "ws://localhost:3000" → "localhost", "wss://sprout.block.xyz" → "sprout.block.xyz"
pub(crate) fn extract_domain(url: &str) -> String {
url.trim_start_matches("wss://")
.trim_start_matches("ws://")
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(':')
.next()
.unwrap_or("localhost")
.split('/')
.next()
.unwrap_or("localhost")
.to_lowercase()
}

View File

@@ -2,12 +2,14 @@
//!
//! Endpoints:
//! GET /api/users/me/profile — get own profile
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about)
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about, nip05_handle)
//! GET /api/users/{pubkey}/profile — get any user's profile by pubkey hex
//! POST /api/users/batch — resolve display names for multiple pubkeys
use std::sync::Arc;
use axum::{
extract::{Json as ExtractJson, State},
extract::{Json as ExtractJson, Path, State},
http::{HeaderMap, StatusCode},
response::Json,
};
@@ -28,11 +30,13 @@ pub struct UpdateProfileBody {
pub avatar_url: Option<String>,
/// Short bio or description, or `None` to leave unchanged.
pub about: Option<String>,
/// NIP-05 identifier (e.g. "alice@example.com"), or `None` to leave unchanged.
pub nip05_handle: Option<String>,
}
/// `PUT /api/users/me/profile` — update the authenticated user's profile.
///
/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "..." }` (all optional, at least one required)
/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "...", "nip05_handle": "alice@relay.example" }` (all optional, at least one required)
/// Returns: `{ "updated": true }`
pub async fn update_profile(
State(state): State<Arc<AppState>>,
@@ -56,19 +60,43 @@ pub async fn update_profile(
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
// nip05_handle: empty string means "clear", None means "leave unchanged"
let nip05_handle_raw = body.nip05_handle.as_deref().map(str::trim);
// Don't filter empty — empty string means "clear to NULL" via empty_to_none in DB layer
if display_name.is_none() && avatar_url.is_none() && about.is_none() {
// Validate and canonicalize NIP-05 handle (if non-empty)
let canonical_nip05: Option<String> = match nip05_handle_raw {
Some("") => Some(String::new()), // empty = clear to NULL
Some(h) => Some(
super::nip05::canonicalize_nip05(h, &state.config.relay_url)
.map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?,
),
None => None,
};
let nip05_handle = canonical_nip05.as_deref();
if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"at least one of display_name, avatar_url, or about is required",
"at least one of display_name, avatar_url, about, or nip05_handle is required",
));
}
state
.db
.update_user_profile(&pubkey_bytes, display_name, avatar_url, about)
.update_user_profile(&pubkey_bytes, display_name, avatar_url, about, nip05_handle)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
.map_err(|e| {
let msg = format!("{e}");
if msg.contains("Duplicate entry") || msg.contains("1062") {
api_error(
StatusCode::CONFLICT,
"nip05_handle is already claimed by another user",
)
} else {
internal_error(&format!("db error: {e}"))
}
})?;
Ok(Json(serde_json::json!({ "updated": true })))
}
@@ -99,3 +127,124 @@ pub async fn get_profile(
None => Err(api_error(StatusCode::NOT_FOUND, "user not found")),
}
}
/// `GET /api/users/{pubkey}/profile` — get any user's profile by pubkey hex.
pub async fn get_user_profile(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(pubkey_hex): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let _ = extract_auth_pubkey(&headers, &state).await?;
let pubkey_bytes = nostr_hex::decode(&pubkey_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?;
if pubkey_bytes.len() != 32 {
return Err(api_error(
StatusCode::BAD_REQUEST,
"pubkey must be 32 bytes",
));
}
let profile = state
.db
.get_user(&pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "user not found"))?;
Ok(Json(serde_json::json!({
"pubkey": nostr_hex::encode(&profile.pubkey),
"display_name": profile.display_name,
"avatar_url": profile.avatar_url,
"about": profile.about,
"nip05_handle": profile.nip05_handle,
})))
}
/// Request body for the batch profile resolution endpoint.
#[derive(Debug, Deserialize)]
pub struct BatchProfilesRequest {
/// List of pubkey hex strings to resolve (max 200).
pub pubkeys: Vec<String>,
}
/// `POST /api/users/batch` — resolve display names for multiple pubkeys.
pub async fn get_users_batch(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
ExtractJson(body): ExtractJson<BatchProfilesRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let _ = extract_auth_pubkey(&headers, &state).await?;
if body.pubkeys.len() > 200 {
return Err(api_error(
StatusCode::BAD_REQUEST,
"max 200 pubkeys per request",
));
}
// Partition inputs: valid hex (64 chars, valid hex) vs invalid (wrong length or bad hex).
// Both wrong-length and 64-char-non-hex inputs go to the missing list.
let mut invalid_inputs: Vec<String> = Vec::new();
let mut valid_hex_set: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &body.pubkeys {
if p.len() != 64 {
invalid_inputs.push(p.clone());
} else {
let lower = p.to_lowercase();
if nostr_hex::decode(&lower)
.map(|b| b.len() == 32)
.unwrap_or(false)
{
valid_hex_set.insert(lower);
} else {
invalid_inputs.push(p.clone());
}
}
}
let mut normalized: Vec<String> = valid_hex_set.into_iter().collect();
normalized.sort();
let pubkey_bytes: Vec<Vec<u8>> = normalized
.iter()
.filter_map(|h| nostr_hex::decode(h).ok())
.filter(|b| b.len() == 32)
.collect();
let records = state
.db
.get_users_bulk(&pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
let found_pubkeys: std::collections::HashSet<String> = records
.iter()
.map(|r| nostr_hex::encode(&r.pubkey))
.collect();
let mut profiles = serde_json::Map::new();
for r in records {
let hex = nostr_hex::encode(&r.pubkey);
profiles.insert(
hex,
serde_json::json!({
"display_name": r.display_name,
"nip05_handle": r.nip05_handle,
}),
);
}
let mut missing: Vec<String> = normalized
.iter()
.filter(|p| !found_pubkeys.contains(p.as_str()))
.cloned()
.collect();
missing.extend(invalid_inputs);
Ok(Json(serde_json::json!({
"profiles": profiles,
"missing": missing,
})))
}

View File

@@ -17,7 +17,7 @@ pub fn is_admin_kind(kind: u32) -> bool {
/// Check if a kind triggers side effects after storage.
pub fn is_side_effect_kind(kind: u32) -> bool {
matches!(kind, 7 | 9000..=9022 | 41001..=41003 | 40099)
matches!(kind, 0 | 7 | 9000..=9022 | 41001..=41003 | 40099)
}
/// Dispatch side effects for a stored event.
@@ -27,6 +27,7 @@ pub async fn handle_side_effects(
state: &Arc<AppState>,
) -> anyhow::Result<()> {
match kind {
0 => handle_kind0_profile(event, state).await,
9000 => handle_put_user(event, state).await,
9001 => handle_remove_user(event, state).await,
9002 => handle_edit_metadata(event, state).await,
@@ -249,6 +250,82 @@ pub async fn emit_system_message(
Ok(())
}
// ── NIP-01 Kind:0 Handler ────────────────────────────────────────────────────
/// Kind:0 (NIP-01 profile metadata) side effect — sync profile fields to users table.
async fn handle_kind0_profile(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
let content: serde_json::Value = serde_json::from_str(&event.content)
.map_err(|e| anyhow::anyhow!("kind:0 content parse error: {e}"))?;
// Kind:0 is absolute state (NIP-01 replaceable event). Fields present in the
// event are set; fields absent are cleared. We use Some("") to clear absent
// fields, since update_user_profile only writes Some values.
let display_name = content
.get("display_name")
.or_else(|| content.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
let avatar_url = content
.get("picture")
.or_else(|| content.get("image"))
.and_then(|v| v.as_str())
.unwrap_or("");
let about = content.get("about").and_then(|v| v.as_str()).unwrap_or("");
// Validate NIP-05 handle: must be user@domain where domain matches this relay.
// Invalid or off-domain handles are silently cleared (treated as absent) rather
// than stored, since the event is already persisted and can't be rejected.
let nip05_owned = content
.get("nip05")
.and_then(|v| v.as_str())
.and_then(|raw| crate::api::nip05::canonicalize_nip05(raw, &state.config.relay_url).ok());
let nip05_handle = nip05_owned.as_deref().unwrap_or("");
let pubkey_bytes = event.pubkey.serialize().to_vec();
state.db.ensure_user(&pubkey_bytes).await?;
// Pass all fields as Some — empty string clears the field in the DB.
// This ensures kind:0 is treated as absolute state, not a partial update.
// If the NIP-05 handle collides with another user's UNIQUE constraint, retry
// without it so display_name/about/avatar_url are still written.
let result = state
.db
.update_user_profile(
&pubkey_bytes,
Some(display_name),
Some(avatar_url),
Some(about),
Some(nip05_handle),
)
.await;
if let Err(ref e) = result {
let msg = format!("{e}");
if msg.contains("Duplicate entry") || msg.contains("1062") {
warn!(pubkey = %nostr::util::hex::encode(&pubkey_bytes),
"kind:0 NIP-05 handle contested, syncing profile without it");
state
.db
.update_user_profile(
&pubkey_bytes,
Some(display_name),
Some(avatar_url),
Some(about),
None, // skip contested NIP-05
)
.await?;
} else {
result?;
}
}
info!(pubkey = %nostr::util::hex::encode(&pubkey_bytes), "kind:0 profile synced to users table");
Ok(())
}
// ── NIP-29 Handlers ──────────────────────────────────────────────────────────
async fn handle_put_user(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {

View File

@@ -23,7 +23,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(nip11_or_ws_handler))
.route("/info", get(relay_info_handler))
.route("/.well-known/nostr.json", get(nip05_handler))
.route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
.route("/health", get(health_handler))
.route(
"/api/channels",
@@ -122,6 +122,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/api/users/me/profile",
get(api::get_profile).put(api::update_profile),
)
.route("/api/users/{pubkey}/profile", get(api::get_user_profile))
.route("/api/users/batch", post(api::get_users_batch))
// Feed route
.route("/api/feed", get(api::feed_handler))
.layer(TraceLayer::new_for_http())
@@ -163,14 +165,6 @@ async fn nip11_or_ws_handler(
}
}
// NIP-05 stub: returns empty names/relays. Full NIP-05 verification is planned.
async fn nip05_handler() -> impl IntoResponse {
Json(serde_json::json!({
"names": {},
"relays": {}
}))
}
async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, "ok")
}

View File

@@ -246,7 +246,7 @@ impl McpSession {
// ── Tests ─────────────────────────────────────────────────────────────────────
/// Spawn the MCP server, complete the initialize handshake, and verify that
/// all 36 expected tools are listed by `tools/list`.
/// all 40 expected tools are listed by `tools/list`.
#[tokio::test]
#[ignore]
async fn test_mcp_initialize_and_list_tools() {
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
assert_eq!(
tools.len(),
36,
"expected exactly 36 tools, got {}. Tools: {:?}",
40,
"expected exactly 40 tools, got {}. Tools: {:?}",
tools.len(),
tools
.iter()
@@ -344,6 +344,10 @@ async fn test_mcp_initialize_and_list_tools() {
"remove_reaction",
"get_reactions",
"set_profile",
"get_user_profile",
"get_users_batch",
"search",
"get_presence",
];
for expected in &expected_tools {
@@ -937,3 +941,126 @@ async fn test_mcp_canvas_set_and_get() {
session.stop();
}
// ── Public profile MCP tests ──────────────────────────────────────────────────
/// Call `get_user_profile` with no arguments to retrieve the authenticated user's own profile.
#[tokio::test]
#[ignore]
async fn test_mcp_get_user_profile_self() {
let keys = generate_test_keys();
let pubkey_hex = keys.public_key().to_hex();
// Set profile via REST first
let client = reqwest::Client::new();
client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&json!({"display_name": "MCP Self Test", "about": "Testing MCP profile"}))
.send()
.await
.expect("PUT profile");
let mut session = McpSession::start(&keys).await;
session.initialize();
// Get own profile (no pubkey arg)
let resp = session.send_request(
"tools/call",
json!({
"name": "get_user_profile",
"arguments": {}
}),
);
let content = &resp["result"]["content"];
let text = content[0]["text"].as_str().expect("text");
let profile: serde_json::Value = serde_json::from_str(text).expect("parse profile json");
assert_eq!(profile["display_name"].as_str(), Some("MCP Self Test"));
assert_eq!(profile["about"].as_str(), Some("Testing MCP profile"));
session.stop();
}
/// Call `get_user_profile` with a pubkey argument to retrieve another user's profile.
#[tokio::test]
#[ignore]
async fn test_mcp_get_user_profile_other() {
let keys = generate_test_keys();
// Create another user with a profile
let other_keys = Keys::generate();
let other_hex = other_keys.public_key().to_hex();
let client = reqwest::Client::new();
client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &other_hex)
.json(&json!({"display_name": "Other User MCP"}))
.send()
.await
.expect("PUT other profile");
let mut session = McpSession::start(&keys).await;
session.initialize();
let resp = session.send_request(
"tools/call",
json!({
"name": "get_user_profile",
"arguments": {"pubkey": other_hex}
}),
);
let content = &resp["result"]["content"];
let text = content[0]["text"].as_str().expect("text");
let profile: serde_json::Value = serde_json::from_str(text).expect("parse profile json");
assert_eq!(profile["display_name"].as_str(), Some("Other User MCP"));
session.stop();
}
/// Call `get_users_batch` with a mix of known and unknown pubkeys.
#[tokio::test]
#[ignore]
async fn test_mcp_get_users_batch() {
let keys = generate_test_keys();
let pubkey_hex = keys.public_key().to_hex();
// Set own profile
let client = reqwest::Client::new();
client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&json!({"display_name": "Batch MCP User"}))
.send()
.await
.expect("PUT profile");
let unknown_hex = Keys::generate().public_key().to_hex();
let mut session = McpSession::start(&keys).await;
session.initialize();
let resp = session.send_request(
"tools/call",
json!({
"name": "get_users_batch",
"arguments": {"pubkeys": [pubkey_hex, unknown_hex]}
}),
);
let content = &resp["result"]["content"];
let text = content[0]["text"].as_str().expect("text");
let batch: serde_json::Value = serde_json::from_str(text).expect("parse batch json");
assert!(
batch["profiles"].as_object().is_some(),
"profiles map present"
);
assert!(
batch["missing"].as_array().is_some(),
"missing array present"
);
session.stop();
}

View File

@@ -649,3 +649,151 @@ async fn test_eose_sent_for_empty_subscription() {
client.disconnect().await.expect("disconnect");
}
/// Kind:0 NIP-05 sync regression test.
///
/// Verifies:
/// 1. A valid `nip05` in kind:0 content is synced to the profile and resolvable via NIP-05 endpoint.
/// 2. An off-domain `nip05` in kind:0 content is NOT synced (handle is cleared).
#[tokio::test]
#[ignore]
async fn test_kind0_nip05_sync() {
let url = relay_url();
let http = relay_http_url();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Extract the relay domain from the relay URL for building a valid NIP-05 handle.
// e.g. "ws://localhost:3000" → "localhost"
let relay_domain = url
.trim_start_matches("wss://")
.trim_start_matches("ws://")
.split(':')
.next()
.unwrap_or("localhost")
.split('/')
.next()
.unwrap_or("localhost")
.to_lowercase();
let unique_name = format!("kind0test{}", &pubkey_hex[..8]);
let valid_handle = format!("{}@{}", unique_name, relay_domain);
// Step 1: Connect and publish kind:0 with a valid nip05 handle.
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let kind0_content = serde_json::json!({
"display_name": "Kind0 Test User",
"nip05": valid_handle,
})
.to_string();
let event = nostr::EventBuilder::new(Kind::Custom(0), kind0_content, [])
.sign_with_keys(&keys)
.expect("sign kind:0");
let ok = client.send_event(event).await.expect("send kind:0");
assert!(
ok.accepted,
"kind:0 event should be accepted: {:?}",
ok.message
);
// Give the relay a moment to process the side effect.
tokio::time::sleep(Duration::from_millis(300)).await;
// Step 2: Verify the profile has the NIP-05 handle via REST GET.
let http_client = reqwest::Client::new();
let profile_resp = http_client
.get(format!("{}/api/users/{}/profile", http, pubkey_hex))
.header("X-Pubkey", &pubkey_hex)
.send()
.await
.expect("GET profile");
assert_eq!(
profile_resp.status(),
200,
"profile should exist after kind:0"
);
let profile: serde_json::Value = profile_resp.json().await.expect("profile json");
assert_eq!(
profile["nip05_handle"].as_str(),
Some(valid_handle.as_str()),
"nip05_handle should be synced from kind:0"
);
// Step 3: Verify NIP-05 resolves via /.well-known/nostr.json.
let nip05_resp = http_client
.get(format!(
"{}/.well-known/nostr.json?name={}",
http, unique_name
))
.send()
.await
.expect("GET nostr.json");
assert_eq!(nip05_resp.status(), 200);
let nip05_body: serde_json::Value = nip05_resp.json().await.expect("nip05 json");
let resolved_pubkey = nip05_body["names"][&unique_name].as_str();
assert_eq!(
resolved_pubkey,
Some(pubkey_hex.as_str()),
"NIP-05 should resolve the pubkey after kind:0 sync"
);
// Step 4: Publish another kind:0 with an off-domain nip05 (should be cleared).
let off_domain_content = serde_json::json!({
"display_name": "Kind0 Test User",
"nip05": format!("{}@evil.com", unique_name),
})
.to_string();
let event2 = nostr::EventBuilder::new(Kind::Custom(0), off_domain_content, [])
.sign_with_keys(&keys)
.expect("sign kind:0 off-domain");
let ok2 = client
.send_event(event2)
.await
.expect("send kind:0 off-domain");
assert!(
ok2.accepted,
"off-domain kind:0 should still be accepted (stored but handle cleared)"
);
tokio::time::sleep(Duration::from_millis(300)).await;
// Step 5: Verify the handle was CLEARED (not set to the off-domain value).
let profile_resp2 = http_client
.get(format!("{}/api/users/{}/profile", http, pubkey_hex))
.header("X-Pubkey", &pubkey_hex)
.send()
.await
.expect("GET profile after off-domain kind:0");
assert_eq!(profile_resp2.status(), 200);
let profile2: serde_json::Value = profile_resp2.json().await.expect("profile json");
let handle_after = profile2["nip05_handle"].as_str().unwrap_or("");
assert!(
handle_after.is_empty() || handle_after == "null",
"nip05_handle should be cleared after off-domain kind:0, got: {:?}",
profile2["nip05_handle"]
);
// Step 6: Confirm NIP-05 no longer resolves.
let nip05_resp2 = http_client
.get(format!(
"{}/.well-known/nostr.json?name={}",
http, unique_name
))
.send()
.await
.expect("GET nostr.json after clear");
let nip05_body2: serde_json::Value = nip05_resp2.json().await.expect("nip05 json");
assert!(
nip05_body2["names"][&unique_name].is_null(),
"NIP-05 should not resolve after handle was cleared"
);
client.disconnect().await.expect("disconnect");
}

View File

@@ -79,6 +79,22 @@ async fn authed_post_json(
.unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}"))
}
/// Make an authenticated PUT request using the `X-Pubkey` dev-mode header.
async fn authed_put(
client: &Client,
url: &str,
pubkey_hex: &str,
body: serde_json::Value,
) -> reqwest::Response {
client
.put(url)
.header("X-Pubkey", pubkey_hex)
.json(&body)
.send()
.await
.unwrap_or_else(|e| panic!("HTTP PUT {url} failed: {e}"))
}
// ── Channel tests ─────────────────────────────────────────────────────────────
/// GET /api/channels returns a non-empty list with the expected fields.
@@ -926,3 +942,480 @@ async fn test_valid_pubkey_header_accepted() {
assert_eq!(resp.status(), 200, "expected 200 for valid X-Pubkey header");
}
// ── Public profile tests ──────────────────────────────────────────────────────
/// GET /api/users/:pubkey/profile returns the profile for a known user.
#[tokio::test]
#[ignore]
async fn test_get_user_profile_returns_known_user() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Set a profile first
let put_resp = client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&serde_json::json!({
"display_name": "Profile Test User",
"about": "Testing public profile endpoint"
}))
.send()
.await
.expect("PUT profile");
assert_eq!(put_resp.status(), 200);
// Read it back via the new public endpoint (using a different reader)
let reader_keys = Keys::generate();
let reader_hex = reader_keys.public_key().to_hex();
let resp = authed_get(
&client,
&format!("{}/api/users/{}/profile", relay_http_url(), pubkey_hex),
&reader_hex,
)
.await;
assert_eq!(resp.status(), 200, "expected 200 for known user profile");
let body: serde_json::Value = resp.json().await.expect("json");
assert_eq!(body["pubkey"].as_str(), Some(pubkey_hex.as_str()));
assert_eq!(body["display_name"].as_str(), Some("Profile Test User"));
assert_eq!(
body["about"].as_str(),
Some("Testing public profile endpoint")
);
}
/// GET /api/users/:pubkey/profile returns 404 for an unknown user.
#[tokio::test]
#[ignore]
async fn test_get_user_profile_returns_404_for_unknown() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Use a pubkey that has never been registered
let unknown_hex = Keys::generate().public_key().to_hex();
let resp = authed_get(
&client,
&format!("{}/api/users/{}/profile", relay_http_url(), unknown_hex),
&pubkey_hex,
)
.await;
assert_eq!(resp.status(), 404, "expected 404 for unknown user");
}
/// GET /api/users/:pubkey/profile returns 401 without authentication.
#[tokio::test]
#[ignore]
async fn test_get_user_profile_requires_auth() {
let client = http_client();
let some_pubkey = Keys::generate().public_key().to_hex();
let resp = client
.get(format!(
"{}/api/users/{}/profile",
relay_http_url(),
some_pubkey
))
.send()
.await
.expect("GET profile");
assert_eq!(resp.status(), 401, "expected 401 without auth");
}
/// GET /api/users/:pubkey/profile returns 400 for an invalid pubkey.
#[tokio::test]
#[ignore]
async fn test_get_user_profile_rejects_invalid_pubkey() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let resp = authed_get(
&client,
&format!(
"{}/api/users/{}/profile",
relay_http_url(),
"not-a-valid-hex"
),
&pubkey_hex,
)
.await;
assert_eq!(resp.status(), 400, "expected 400 for invalid pubkey hex");
}
/// POST /api/users/batch returns found profiles and a missing list.
#[tokio::test]
#[ignore]
async fn test_batch_profiles_known_and_unknown() {
let client = http_client();
// Create two users with profiles
let keys_a = Keys::generate();
let hex_a = keys_a.public_key().to_hex();
let keys_b = Keys::generate();
let hex_b = keys_b.public_key().to_hex();
let unknown_hex = Keys::generate().public_key().to_hex();
client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &hex_a)
.json(&serde_json::json!({"display_name": "Alice Batch"}))
.send()
.await
.expect("PUT alice");
client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &hex_b)
.json(&serde_json::json!({"display_name": "Bob Batch"}))
.send()
.await
.expect("PUT bob");
// Batch lookup
let resp = authed_post_json(
&client,
&format!("{}/api/users/batch", relay_http_url()),
&hex_a,
serde_json::json!({
"pubkeys": [hex_a, hex_b, unknown_hex]
}),
)
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
let profiles = body["profiles"].as_object().expect("profiles map");
assert_eq!(profiles.len(), 2, "expected 2 found profiles");
assert_eq!(
profiles[&hex_a.to_lowercase()]["display_name"].as_str(),
Some("Alice Batch")
);
assert_eq!(
profiles[&hex_b.to_lowercase()]["display_name"].as_str(),
Some("Bob Batch")
);
let missing = body["missing"].as_array().expect("missing array");
assert!(
missing
.iter()
.any(|v| v.as_str() == Some(&unknown_hex.to_lowercase())),
"unknown pubkey should be in missing"
);
}
/// POST /api/users/batch returns 400 when more than 200 pubkeys are submitted.
#[tokio::test]
#[ignore]
async fn test_batch_profiles_rejects_over_200() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let pubkeys: Vec<String> = (0..201).map(|i| format!("{:064x}", i)).collect();
let resp = authed_post_json(
&client,
&format!("{}/api/users/batch", relay_http_url()),
&pubkey_hex,
serde_json::json!({"pubkeys": pubkeys}),
)
.await;
assert_eq!(resp.status(), 400, "expected 400 for >200 pubkeys");
}
/// POST /api/users/batch returns 401 without authentication.
#[tokio::test]
#[ignore]
async fn test_batch_profiles_requires_auth() {
let client = http_client();
let resp = client
.post(format!("{}/api/users/batch", relay_http_url()))
.json(&serde_json::json!({"pubkeys": ["abc"]}))
.send()
.await
.expect("POST batch");
assert_eq!(resp.status(), 401, "expected 401 without auth");
}
/// POST /api/users/batch places invalid-length inputs in the missing list.
/// Also verifies that 64-char non-hex strings (e.g. "g" * 64) go to missing.
#[tokio::test]
#[ignore]
async fn test_batch_profiles_invalid_length_in_missing() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let non_hex_64 = "g".repeat(64);
let resp = authed_post_json(
&client,
&format!("{}/api/users/batch", relay_http_url()),
&pubkey_hex,
serde_json::json!({"pubkeys": ["tooshort", "x".repeat(100), non_hex_64]}),
)
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
let missing = body["missing"].as_array().expect("missing");
assert_eq!(
missing.len(),
3,
"wrong-length and 64-char non-hex inputs should all be in missing"
);
let missing_strs: Vec<&str> = missing.iter().filter_map(|v| v.as_str()).collect();
assert!(
missing_strs.contains(&"tooshort"),
"short input should be in missing"
);
assert!(
missing_strs.iter().any(|s| s.len() == 100),
"too-long input should be in missing"
);
assert!(
missing_strs.contains(&"g".repeat(64).as_str()),
"64-char non-hex should be in missing"
);
}
/// POST /api/users/batch normalizes pubkey case before lookup.
#[tokio::test]
#[ignore]
async fn test_batch_profiles_case_normalized() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Set profile
client
.put(format!("{}/api/users/me/profile", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&serde_json::json!({"display_name": "Case Test"}))
.send()
.await
.expect("PUT");
// Query with uppercase version
let upper_hex = pubkey_hex.to_uppercase();
let resp = authed_post_json(
&client,
&format!("{}/api/users/batch", relay_http_url()),
&pubkey_hex,
serde_json::json!({"pubkeys": [upper_hex]}),
)
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
let profiles = body["profiles"].as_object().expect("profiles");
assert_eq!(profiles.len(), 1, "uppercase pubkey should match");
}
// ── NIP-05 tests ──────────────────────────────────────────────────────────────
/// GET /.well-known/nostr.json?name=nonexistent returns empty names and relays.
#[tokio::test]
#[ignore]
async fn test_nip05_returns_empty_for_unknown_name() {
let client = http_client();
let resp = client
.get(format!(
"{}/.well-known/nostr.json?name=nonexistent",
relay_http_url()
))
.send()
.await
.expect("GET nip05");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
assert_eq!(body["names"].as_object().unwrap().len(), 0);
assert_eq!(body["relays"].as_object().unwrap().len(), 0);
}
/// GET /.well-known/nostr.json with no name param returns empty names.
#[tokio::test]
#[ignore]
async fn test_nip05_no_name_returns_empty() {
let client = http_client();
let resp = client
.get(format!("{}/.well-known/nostr.json", relay_http_url()))
.send()
.await
.expect("GET nip05");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
assert_eq!(body["names"].as_object().unwrap().len(), 0);
}
/// GET /.well-known/nostr.json includes the required CORS header.
#[tokio::test]
#[ignore]
async fn test_nip05_has_cors_header() {
let client = http_client();
let resp = client
.get(format!("{}/.well-known/nostr.json", relay_http_url()))
.send()
.await
.expect("GET nip05");
assert_eq!(resp.status(), 200);
let cors = resp.headers().get("access-control-allow-origin");
assert!(
cors.is_some(),
"NIP-05 must have Access-Control-Allow-Origin header"
);
assert_eq!(cors.unwrap().to_str().unwrap(), "*");
}
/// Full round-trip: set nip05_handle via PUT, then verify via /.well-known/nostr.json.
#[tokio::test]
#[ignore]
async fn test_nip05_round_trip_set_and_lookup() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Set NIP-05 handle — use "testuser@localhost" since relay_url is ws://localhost:3000
let unique_name = format!("nip05test{}", &pubkey_hex[..8]);
let handle = format!("{}@localhost", unique_name);
let resp = authed_put(
&client,
&format!("{}/api/users/me/profile", relay_http_url()),
&pubkey_hex,
serde_json::json!({"nip05_handle": handle}),
)
.await;
assert_eq!(resp.status(), 200, "set nip05_handle should succeed");
// Query NIP-05 endpoint
let resp = client
.get(format!(
"{}/.well-known/nostr.json?name={}",
relay_http_url(),
unique_name
))
.send()
.await
.expect("nip05 request");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
let names = body["names"].as_object().expect("names map");
assert!(
names.contains_key(&unique_name),
"NIP-05 should resolve the name. Got: {:?}",
names
);
let resolved_pubkey = names[&unique_name].as_str().expect("pubkey string");
assert_eq!(
resolved_pubkey, pubkey_hex,
"NIP-05 resolved pubkey should match"
);
}
/// Setting nip05_handle to empty string clears it.
#[tokio::test]
#[ignore]
async fn test_nip05_clear_handle() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let unique_name = format!("cleartest{}", &pubkey_hex[..8]);
let handle = format!("{}@localhost", unique_name);
// Set handle
let resp = authed_put(
&client,
&format!("{}/api/users/me/profile", relay_http_url()),
&pubkey_hex,
serde_json::json!({"nip05_handle": handle}),
)
.await;
assert_eq!(resp.status(), 200);
// Clear handle
let resp = authed_put(
&client,
&format!("{}/api/users/me/profile", relay_http_url()),
&pubkey_hex,
serde_json::json!({"nip05_handle": ""}),
)
.await;
assert_eq!(resp.status(), 200);
// Verify cleared — NIP-05 should no longer resolve
let resp = client
.get(format!(
"{}/.well-known/nostr.json?name={}",
relay_http_url(),
unique_name
))
.send()
.await
.expect("nip05 request");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("json");
let names = body["names"].as_object().expect("names map");
assert!(
!names.contains_key(&unique_name),
"NIP-05 should NOT resolve after clearing. Got: {:?}",
names
);
}
/// Duplicate nip05_handle returns 409 Conflict.
#[tokio::test]
#[ignore]
async fn test_nip05_duplicate_handle_conflict() {
let client = http_client();
let keys_a = Keys::generate();
let pubkey_a = keys_a.public_key().to_hex();
let keys_b = Keys::generate();
let pubkey_b = keys_b.public_key().to_hex();
let unique_name = format!("duptest{}", &pubkey_a[..8]);
let handle = format!("{}@localhost", unique_name);
// User A sets handle
let resp = authed_put(
&client,
&format!("{}/api/users/me/profile", relay_http_url()),
&pubkey_a,
serde_json::json!({"nip05_handle": handle}),
)
.await;
assert_eq!(resp.status(), 200);
// User B tries same handle → 409
let resp = authed_put(
&client,
&format!("{}/api/users/me/profile", relay_http_url()),
&pubkey_b,
serde_json::json!({"nip05_handle": handle}),
)
.await;
assert_eq!(
resp.status(),
409,
"duplicate nip05_handle should return 409 Conflict"
);
}