mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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
|
# 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
|
## Two Test Modes
|
||||||
|
|
||||||
| Mode | What It Does | When to Use |
|
| Mode | What It Does | When to Use |
|
||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions |
|
| **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.
|
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)
|
- [3.7 Expected Results](#37-expected-results)
|
||||||
4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios)
|
4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios)
|
||||||
5. [Workflow YAML Reference](#5-workflow-yaml-reference)
|
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)
|
7. [Cleanup](#7-cleanup)
|
||||||
8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting)
|
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:
|
Then run the integration suites:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# REST API integration tests (20 tests)
|
# REST API integration tests (35 tests)
|
||||||
RELAY_URL=ws://localhost:3000 \
|
RELAY_URL=ws://localhost:3000 \
|
||||||
cargo test -p sprout-test-client --test e2e_rest_api -- --ignored
|
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 \
|
RELAY_URL=ws://localhost:3000 \
|
||||||
cargo test -p sprout-test-client --test e2e_relay -- --ignored
|
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 \
|
RELAY_URL=ws://localhost:3000 \
|
||||||
cargo test -p sprout-test-client --test e2e_mcp -- --ignored
|
cargo test -p sprout-test-client --test e2e_mcp -- --ignored
|
||||||
```
|
```
|
||||||
@@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \
|
|||||||
### Expected Results
|
### Expected Results
|
||||||
|
|
||||||
```
|
```
|
||||||
test result: ok. 20 passed; 0 failed; 0 ignored ← REST API
|
test result: ok. 35 passed; 0 failed; 0 ignored ← REST API
|
||||||
test result: ok. 13 passed; 0 failed; 0 ignored ← relay
|
test result: ok. 14 passed; 0 failed; 0 ignored ← relay
|
||||||
test result: ok. 7 passed; 0 failed; 0 ignored ← MCP
|
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 Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose
|
||||||
│
|
│
|
||||||
sprout-mcp-server
|
sprout-mcp-server
|
||||||
(36 MCP tools)
|
(40 MCP tools)
|
||||||
│
|
│
|
||||||
Sprout Relay
|
Sprout Relay
|
||||||
(send_message, etc.)
|
(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).
|
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
|
### 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."
|
"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
|
```bash
|
||||||
mention "$GENERAL" "$ALICE_PUBKEY" \
|
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**
|
**A-6: Feed, search, and membership**
|
||||||
@@ -487,7 +495,7 @@ mention "$GENERAL" "$BOB_PUBKEY" \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mention "$GENERAL" "$BOB_PUBKEY" \
|
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**
|
**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."
|
"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
|
#### 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
|
### 3.6 Monitoring & Verification
|
||||||
|
|
||||||
#### Watch harness logs live
|
#### 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) |
|
| Bob in private-ops | Yes (Alice invited him in A-6) |
|
||||||
| Workflow | alice-notify created with message_posted trigger |
|
| Workflow | alice-notify created with message_posted trigger |
|
||||||
| Display names | Alice and Bob have display names set |
|
| 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 |
|
| Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors |
|
||||||
| charlie-lifecycle | Unarchived and final message sent successfully |
|
| 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)
|
### Channels (8)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `list_channels` | List all channels accessible to the agent |
|
| `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) |
|
| `create_channel` | Create a new channel (`channel_type`: stream\|forum, `visibility`: open\|private) |
|
||||||
| `update_channel` | Update channel name or metadata |
|
| `update_channel` | Update channel name or metadata |
|
||||||
| `archive_channel` | Archive a channel (creator only) |
|
| `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 |
|
| `join_channel` | Join an open channel |
|
||||||
| `leave_channel` | Leave a channel |
|
| `leave_channel` | Leave a channel |
|
||||||
|
|
||||||
### Messages (4)
|
### Messages (2)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `send_message` | Post a message to a channel |
|
| `send_message` | Post a message to a channel |
|
||||||
| `get_channel_history` | Get recent messages from 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)
|
### 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 |
|
| `add_reaction` | Add an emoji reaction to a message |
|
||||||
| `remove_reaction` | Remove a reaction |
|
| `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)
|
### Direct Messages (3)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `send_dm` | Send a direct message to a user |
|
| `open_dm` | Create or retrieve a DM channel with a user (optionally send an initial message) |
|
||||||
| `get_dm_history` | Get DM conversation history |
|
| `add_dm_member` | Add a member to an existing DM conversation |
|
||||||
| `list_dm_conversations` | List all DM conversations |
|
| `list_dms` | List all DM conversations |
|
||||||
|
|
||||||
### Canvas (2)
|
### 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 |
|
| `get_canvas` | Read the canvas document for a channel |
|
||||||
| `set_canvas` | Write/overwrite the canvas document (last writer wins) |
|
| `set_canvas` | Write/overwrite the canvas document (last writer wins) |
|
||||||
|
|
||||||
### Workflows (3)
|
### Workflows (7)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `create_workflow` | Create a new workflow with trigger and steps |
|
|
||||||
| `list_workflows` | List all workflows |
|
| `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 |
|
| `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 |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `get_feed` | Get the agent's personal activity feed |
|
| `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)
|
### Search (1)
|
||||||
|
|
||||||
@@ -1041,31 +1087,30 @@ The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surfac
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `get_profile` | Get the agent's profile |
|
| `set_profile` | Set display name, about/bio, avatar URL, and NIP-05 handle |
|
||||||
| `set_display_name` | Set the agent's display name |
|
| `get_user_profile` | Get any user's profile by pubkey (omit pubkey for own profile) |
|
||||||
| `set_about` | Set the agent's bio/about text |
|
| `get_users_batch` | Bulk resolve display names and NIP-05 handles for multiple pubkeys |
|
||||||
|
|
||||||
### Presence (2)
|
### Presence (1)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `set_presence` | Set the agent's presence status (online/away/etc.) |
|
| `get_presence` | Bulk presence lookup by pubkey |
|
||||||
| `get_presence` | Get presence status for a user |
|
|
||||||
|
|
||||||
### Members (2)
|
### Members (3)
|
||||||
|
|
||||||
| Tool | Description |
|
| 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 |
|
| `list_channel_members` | List members of a channel |
|
||||||
| `get_user_channels` | Get channels a user belongs to |
|
|
||||||
|
|
||||||
### Admin (3)
|
### Admin (2)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `set_channel_topic` | Set the topic for a channel |
|
| `set_channel_topic` | Set the topic for a channel |
|
||||||
| `set_channel_purpose` | Set the purpose 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
|
### 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
|
- ✅ 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
|
- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -559,15 +559,33 @@ impl Db {
|
|||||||
user::get_user(&self.pool, pubkey).await
|
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(
|
pub async fn update_user_profile(
|
||||||
&self,
|
&self,
|
||||||
pubkey: &[u8],
|
pubkey: &[u8],
|
||||||
display_name: Option<&str>,
|
display_name: Option<&str>,
|
||||||
avatar_url: Option<&str>,
|
avatar_url: Option<&str>,
|
||||||
about: Option<&str>,
|
about: Option<&str>,
|
||||||
|
nip05_handle: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> 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 ───────────────────────────────────────────────────────────
|
// ── 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.
|
/// 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.
|
/// 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(
|
pub async fn update_user_profile(
|
||||||
pool: &MySqlPool,
|
pool: &MySqlPool,
|
||||||
pubkey: &[u8],
|
pubkey: &[u8],
|
||||||
display_name: Option<&str>,
|
display_name: Option<&str>,
|
||||||
avatar_url: Option<&str>,
|
avatar_url: Option<&str>,
|
||||||
about: Option<&str>,
|
about: Option<&str>,
|
||||||
|
nip05_handle: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Build SET clause dynamically to avoid 2^3 match arms.
|
|
||||||
let mut set_parts: Vec<&str> = Vec::new();
|
let mut set_parts: Vec<&str> = Vec::new();
|
||||||
if display_name.is_some() {
|
if display_name.is_some() {
|
||||||
set_parts.push("display_name = ?");
|
set_parts.push("display_name = ?");
|
||||||
@@ -87,24 +92,76 @@ pub async fn update_user_profile(
|
|||||||
if about.is_some() {
|
if about.is_some() {
|
||||||
set_parts.push("about = ?");
|
set_parts.push("about = ?");
|
||||||
}
|
}
|
||||||
|
if nip05_handle.is_some() {
|
||||||
|
set_parts.push("nip05_handle = ?");
|
||||||
|
}
|
||||||
|
|
||||||
if set_parts.is_empty() {
|
if set_parts.is_empty() {
|
||||||
// Nothing to update — caller should have validated at least one field.
|
|
||||||
return Ok(());
|
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 sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", "));
|
||||||
let mut query = sqlx::query(&sql);
|
let mut query = sqlx::query(&sql);
|
||||||
if let Some(name) = display_name {
|
if display_name.is_some() {
|
||||||
query = query.bind(name);
|
query = query.bind(empty_to_none(display_name));
|
||||||
}
|
}
|
||||||
if let Some(url) = avatar_url {
|
if avatar_url.is_some() {
|
||||||
query = query.bind(url);
|
query = query.bind(empty_to_none(avatar_url));
|
||||||
}
|
}
|
||||||
if let Some(bio) = about {
|
if about.is_some() {
|
||||||
query = query.bind(bio);
|
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 = query.bind(pubkey);
|
||||||
query.execute(pool).await?;
|
query.execute(pool).await?;
|
||||||
Ok(())
|
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.
|
/// Short bio or description.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub about: Option<String>,
|
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.
|
/// Parameters for the `get_feed` tool.
|
||||||
@@ -1289,19 +1324,79 @@ impl SproutMcpServer {
|
|||||||
/// Update the agent's user profile.
|
/// Update the agent's user profile.
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "set_profile",
|
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 {
|
pub async fn set_profile(&self, Parameters(p): Parameters<SetProfileParams>) -> String {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"display_name": p.display_name,
|
"display_name": p.display_name,
|
||||||
"avatar_url": p.avatar_url,
|
"avatar_url": p.avatar_url,
|
||||||
"about": p.about,
|
"about": p.about,
|
||||||
|
"nip05_handle": p.nip05_handle,
|
||||||
});
|
});
|
||||||
match self.client.put("/api/users/me/profile", &body).await {
|
match self.client.put("/api/users/me/profile", &body).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => format!("Error: {e}"),
|
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]
|
#[tool_handler]
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub mod feed;
|
|||||||
pub mod members;
|
pub mod members;
|
||||||
/// Message and thread endpoints.
|
/// Message and thread endpoints.
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
/// NIP-05 identity verification endpoint.
|
||||||
|
pub mod nip05;
|
||||||
/// Presence status endpoints.
|
/// Presence status endpoints.
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
/// Reaction endpoints.
|
/// Reaction endpoints.
|
||||||
@@ -60,7 +62,7 @@ pub use messages::{delete_message, get_thread, list_messages, send_message};
|
|||||||
pub use presence::presence_handler;
|
pub use presence::presence_handler;
|
||||||
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
|
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
|
||||||
pub use search::search_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::{
|
pub use workflows::{
|
||||||
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
|
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
|
||||||
trigger_workflow, update_workflow, workflow_webhook,
|
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()
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
//!
|
//!
|
||||||
//! Endpoints:
|
//! Endpoints:
|
||||||
//! GET /api/users/me/profile — get own profile
|
//! 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 std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Json as ExtractJson, State},
|
extract::{Json as ExtractJson, Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
@@ -28,11 +30,13 @@ pub struct UpdateProfileBody {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
/// Short bio or description, or `None` to leave unchanged.
|
/// Short bio or description, or `None` to leave unchanged.
|
||||||
pub about: Option<String>,
|
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.
|
/// `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 }`
|
/// Returns: `{ "updated": true }`
|
||||||
pub async fn update_profile(
|
pub async fn update_profile(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -56,19 +60,43 @@ pub async fn update_profile(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|s| !s.is_empty());
|
.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(
|
return Err(api_error(
|
||||||
StatusCode::BAD_REQUEST,
|
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
|
state
|
||||||
.db
|
.db
|
||||||
.update_user_profile(&pubkey_bytes, display_name, avatar_url, about)
|
.update_user_profile(&pubkey_bytes, display_name, avatar_url, about, nip05_handle)
|
||||||
.await
|
.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 })))
|
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")),
|
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.
|
/// Check if a kind triggers side effects after storage.
|
||||||
pub fn is_side_effect_kind(kind: u32) -> bool {
|
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.
|
/// Dispatch side effects for a stored event.
|
||||||
@@ -27,6 +27,7 @@ pub async fn handle_side_effects(
|
|||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
match kind {
|
match kind {
|
||||||
|
0 => handle_kind0_profile(event, state).await,
|
||||||
9000 => handle_put_user(event, state).await,
|
9000 => handle_put_user(event, state).await,
|
||||||
9001 => handle_remove_user(event, state).await,
|
9001 => handle_remove_user(event, state).await,
|
||||||
9002 => handle_edit_metadata(event, state).await,
|
9002 => handle_edit_metadata(event, state).await,
|
||||||
@@ -249,6 +250,82 @@ pub async fn emit_system_message(
|
|||||||
Ok(())
|
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 ──────────────────────────────────────────────────────────
|
// ── NIP-29 Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn handle_put_user(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
|
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()
|
Router::new()
|
||||||
.route("/", get(nip11_or_ws_handler))
|
.route("/", get(nip11_or_ws_handler))
|
||||||
.route("/info", get(relay_info_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("/health", get(health_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/channels",
|
"/api/channels",
|
||||||
@@ -122,6 +122,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
"/api/users/me/profile",
|
"/api/users/me/profile",
|
||||||
get(api::get_profile).put(api::update_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
|
// Feed route
|
||||||
.route("/api/feed", get(api::feed_handler))
|
.route("/api/feed", get(api::feed_handler))
|
||||||
.layer(TraceLayer::new_for_http())
|
.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 {
|
async fn health_handler() -> impl IntoResponse {
|
||||||
(StatusCode::OK, "ok")
|
(StatusCode::OK, "ok")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ impl McpSession {
|
|||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Spawn the MCP server, complete the initialize handshake, and verify that
|
/// 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]
|
#[tokio::test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
async fn test_mcp_initialize_and_list_tools() {
|
async fn test_mcp_initialize_and_list_tools() {
|
||||||
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
36,
|
40,
|
||||||
"expected exactly 36 tools, got {}. Tools: {:?}",
|
"expected exactly 40 tools, got {}. Tools: {:?}",
|
||||||
tools.len(),
|
tools.len(),
|
||||||
tools
|
tools
|
||||||
.iter()
|
.iter()
|
||||||
@@ -344,6 +344,10 @@ async fn test_mcp_initialize_and_list_tools() {
|
|||||||
"remove_reaction",
|
"remove_reaction",
|
||||||
"get_reactions",
|
"get_reactions",
|
||||||
"set_profile",
|
"set_profile",
|
||||||
|
"get_user_profile",
|
||||||
|
"get_users_batch",
|
||||||
|
"search",
|
||||||
|
"get_presence",
|
||||||
];
|
];
|
||||||
|
|
||||||
for expected in &expected_tools {
|
for expected in &expected_tools {
|
||||||
@@ -937,3 +941,126 @@ async fn test_mcp_canvas_set_and_get() {
|
|||||||
|
|
||||||
session.stop();
|
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");
|
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}"))
|
.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 ─────────────────────────────────────────────────────────────
|
// ── Channel tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/channels returns a non-empty list with the expected fields.
|
/// 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");
|
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