mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: public profiles, NIP-05 identity, search & presence tools (#28)
This commit is contained in:
127
TESTING.md
127
TESTING.md
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
crates/sprout-relay/src/api/nip05.rs
Normal file
94
crates/sprout-relay/src/api/nip05.rs
Normal 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()
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
//! User profile REST API.
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! GET /api/users/me/profile — get own profile
|
||||
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about)
|
||||
//! GET /api/users/me/profile — get own profile
|
||||
//! 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,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user