feat: agent channel protection — policy-based control over who can add agents to channels (#34)

This commit is contained in:
tlongwell-block
2026-03-11 19:20:44 -04:00
committed by GitHub
parent d94e3fbf77
commit 154abf3da9
15 changed files with 1966 additions and 10 deletions
Generated
+1
View File
@@ -2635,6 +2635,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"hex",
"nostr",
"serde_json",
"sprout-auth",
+817
View File
@@ -1323,3 +1323,820 @@ docker compose down -v && docker compose up -d
# Wait 30s then re-run migrations:
sqlx migrate run --database-url "$DATABASE_URL"
```
---
# Agent Channel Protection — Live Testing Guide
Manual testing guide for the Sprout Agent Channel Protection feature. Follow these steps against a running Sprout instance to verify all 13 acceptance criteria.
> **Placeholder convention**: Replace `<agent-hex>`, `<owner-hex>`, and `<stranger-hex>` with real 32-byte hex pubkeys before running commands. See §1.4 for how to generate them.
---
## 1. Prerequisites
### 1.1 Sprout Relay
- Running Sprout relay in dev mode with `require_auth_token=false` disabled (auth tokens required for all tests)
- MySQL database with the `agent_channel_protection` migration applied (see §2.2)
- Default relay URL: `http://localhost:3001` — adjust if different
### 1.2 Tools Required
| Tool | Purpose | Install |
|------|---------|---------|
| `curl` | REST API testing | Pre-installed on macOS/Linux |
| `websocat` | NIP-29 WebSocket testing | `brew install websocat` or `cargo install websocat` |
| `mysql` / `mysql-client` | DB verification queries | `brew install mysql-client` |
| `sprout-admin` | Minting agent tokens | Built from `crates/sprout-admin/` |
| `jq` | Pretty-print JSON responses | `brew install jq` |
### 1.3 Build sprout-admin
```bash
cd /path/to/sprout
cargo build -p sprout-admin
# Binary at: target/debug/sprout-admin
alias sprout-admin="./target/debug/sprout-admin"
```
### 1.4 Generate Test Keypairs
You need three distinct keypairs: **agent**, **owner**, and **stranger**.
```bash
# Generate three 32-byte hex private keys (use as pubkeys for testing)
export AGENT_HEX=$(openssl rand -hex 32)
export OWNER_HEX=$(openssl rand -hex 32)
export STRANGER_HEX=$(openssl rand -hex 32)
echo "AGENT: $AGENT_HEX"
echo "OWNER: $OWNER_HEX"
echo "STRANGER: $STRANGER_HEX"
```
> **Note**: In production Nostr, pubkeys are derived from private keys via secp256k1. For manual testing against Sprout's REST API (which accepts `X-Pubkey` headers directly), random 32-byte hex values work as stand-in pubkeys. For NIP-29 WebSocket tests you need real signed events — see §4.
### 1.5 API Token Setup
Sprout REST endpoints require a Bearer token. Mint tokens for each test identity:
```bash
# Mint agent token (with owner set)
AGENT_TOKEN=$(sprout-admin mint-token \
--name "test-agent" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$AGENT_HEX" \
--owner-pubkey "$OWNER_HEX")
# Mint owner token
OWNER_TOKEN=$(sprout-admin mint-token \
--name "test-owner" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$OWNER_HEX")
# Mint stranger token (no relationship to agent)
STRANGER_TOKEN=$(sprout-admin mint-token \
--name "test-stranger" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$STRANGER_HEX")
echo "AGENT_TOKEN: $AGENT_TOKEN"
echo "OWNER_TOKEN: $OWNER_TOKEN"
echo "STRANGER_TOKEN: $STRANGER_TOKEN"
```
---
## 2. Setup
### 2.1 Start the Relay
```bash
cd /path/to/sprout
# Copy and configure .env if not already done
cp .env.example .env
# Edit .env: set DATABASE_URL, PORT=3001, etc.
cargo run -p sprout-relay
# Or with justfile:
just dev
```
Verify relay is up:
```bash
curl -s http://localhost:3001/health | jq .
# Expected: {"status":"ok"} or similar
```
### 2.2 Run Migrations
The `agent_channel_protection` migration adds `agent_owner_pubkey` and `channel_add_policy` to the `users` table.
```bash
# Using sqlx-cli
cargo install sqlx-cli --no-default-features --features mysql
sqlx migrate run --database-url "$DATABASE_URL"
# Or via justfile if configured
just migrate
```
Verify migration applied:
```bash
mysql -u root -p sprout -e "DESCRIBE users;" | grep -E "agent_owner|channel_add"
# Expected output:
# agent_owner_pubkey | varbinary(32) | YES | MUL | NULL |
# channel_add_policy | enum(...) | NO | | anyone |
```
### 2.3 Create a Test Channel
All REST member tests require a channel ID. Create one with the owner token:
```bash
CHANNEL_ID=$(curl -s -X POST http://localhost:3001/api/channels \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "test-channel", "visibility": "open"}' \
| jq -r '.id')
echo "CHANNEL_ID: $CHANNEL_ID"
```
### 2.4 Verify Token/Pubkey Mapping
Confirm each token authenticates as the expected pubkey:
```bash
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $AGENT_TOKEN" | jq .pubkey
# Expected: "<agent-hex>"
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $OWNER_TOKEN" | jq .pubkey
# Expected: "<owner-hex>"
```
---
## 3. REST API Tests
All tests use `http://localhost:3001`. Adjust port as needed.
> **Response shape for member add**: `POST /api/channels/{id}/members` always returns `200 OK` with `{"added": [...], "errors": [...]}`. Policy violations appear in `errors`, not as HTTP error codes.
### 3.1 Set Channel Add Policy
**Test: Set policy to `owner_only`**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .
```
Expected response (`200 OK`):
```json
{
"channel_add_policy": "owner_only",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Test: Set policy to `nobody`**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .
```
Expected response (`200 OK`):
```json
{
"channel_add_policy": "nobody",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Test: Reset policy to `anyone`**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}' | jq .
```
Expected response (`200 OK`):
```json
{
"channel_add_policy": "anyone",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Test: Invalid policy value → 400**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "invite_only"}' | jq .
```
Expected response (`400 Bad Request`):
```json
{
"error": "channel_add_policy must be 'anyone', 'owner_only', or 'nobody'"
}
```
---
### 3.2 Default Policy Allows Anyone (AC-1, AC-9)
Verify that a fresh agent (policy = `anyone`) can be added by any authenticated user.
```bash
# Reset agent policy to anyone first
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}' | jq .channel_add_policy
# Expected: "anyone"
# Stranger adds agent to channel — should succeed
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<agent-hex>"],
"errors": []
}
```
---
### 3.3 `owner_only` Blocks Non-Owner (AC-3)
```bash
# Set agent policy to owner_only
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .channel_add_policy
# Expected: "owner_only"
# Stranger tries to add agent — should be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": [],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:owner_only — only the agent owner can add this agent"
}
]
}
```
---
### 3.4 `owner_only` Allows Owner (AC-2)
```bash
# Agent policy is still owner_only from 3.3
# Owner adds agent — should succeed
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<agent-hex>"],
"errors": []
}
```
---
### 3.5 `nobody` Blocks All (AC-4)
```bash
# Set agent policy to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .channel_add_policy
# Expected: "nobody"
# Owner tries to add agent — should be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
# Stranger tries to add agent — should also be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response for both (`200 OK`):
```json
{
"added": [],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:nobody — this agent has disabled external channel additions"
}
]
}
```
---
### 3.6 Self-Add Bypasses Policy (AC-12)
```bash
# Agent policy is still nobody from 3.5
# Agent adds ITSELF — should succeed regardless of policy
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<agent-hex>"],
"errors": []
}
```
---
### 3.7 Batch Mixed Results (AC-13)
Test that a batch add with both allowed and blocked pubkeys returns partial success.
```bash
# Set agent policy to nobody (blocked), stranger has default anyone (allowed)
# We'll add stranger to the channel and try to add agent in same batch
# Reset agent to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .channel_add_policy
# Batch: owner adds both stranger (allowed) and agent (blocked by nobody)
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$STRANGER_HEX\", \"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<stranger-hex>"],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:nobody — this agent has disabled external channel additions"
}
]
}
```
---
### 3.8 `owner_only` with NULL Owner (AC-11)
Test the edge case where an agent has `owner_only` policy but no `agent_owner_pubkey` set.
```bash
# Mint a new agent WITHOUT --owner-pubkey
NO_OWNER_HEX=$(openssl rand -hex 32)
NO_OWNER_TOKEN=$(sprout-admin mint-token \
--name "test-agent-no-owner" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$NO_OWNER_HEX")
# Set policy to owner_only (no owner set — misconfiguration)
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $NO_OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .
# Anyone tries to add — should be blocked with "no owner set" message
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$NO_OWNER_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": [],
"errors": [
{
"pubkey": "<no-owner-hex>",
"error": "policy:owner_only — agent has no owner set"
}
]
}
```
---
## 4. NIP-29 WebSocket Tests
NIP-29 uses signed Nostr events over WebSocket. Kind 9000 (`PUT_USER`) is the add-member event.
> **Requirement**: You need a Nostr keypair tool to sign events. Options:
> - [`nak`](https://github.com/fiatjaf/nak): `cargo install nak`
> - [`nostril`](https://github.com/jb55/nostril): C tool for signing events
> - A custom script using the `nostr` Rust/Python/JS library
### 4.1 Event Structure for Kind 9000
A `PUT_USER` event adds a member to a channel:
```json
{
"kind": 9000,
"pubkey": "<actor-hex>",
"created_at": <unix-timestamp>,
"tags": [
["e", "<channel-id-as-nostr-event-id>"],
["p", "<target-pubkey-hex>"],
["role", "member"]
],
"content": "",
"id": "<event-id>",
"sig": "<signature>"
}
```
The relay message format (NIP-01):
```json
["EVENT", "<subscription-id>", <event-object>]
```
### 4.2 Connect to Relay WebSocket
```bash
# Connect to relay WebSocket
websocat ws://localhost:3001
# Or with authentication header (if relay requires it)
websocat -H "Authorization: Bearer $ACTOR_TOKEN" ws://localhost:3001
```
### 4.3 Default Policy Allows (AC-1, AC-9)
```bash
# Reset agent to anyone policy first (via REST)
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}'
```
Send a signed kind:9000 event from stranger targeting agent. Expected relay response:
```json
["OK", "<event-id>", true, ""]
```
Event is stored and agent is added to channel.
### 4.4 `nobody` Policy Blocks (AC-4, AC-5)
```bash
# Set agent to nobody policy
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}'
```
Send a signed kind:9000 event from owner targeting agent. Expected relay response:
```json
["OK", "<event-id>", false, "invalid: policy:nobody — this agent has disabled external channel additions"]
```
**Verify event NOT stored** (see §6.3 for DB query).
### 4.5 `owner_only` Blocks Non-Owner (AC-3, AC-5)
```bash
# Set agent to owner_only policy
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}'
```
Send a signed kind:9000 event from **stranger** targeting agent. Expected relay response:
```json
["OK", "<event-id>", false, "invalid: policy:owner_only — only the agent owner can add this agent"]
```
Send same event from **owner** targeting agent. Expected relay response:
```json
["OK", "<event-id>", true, ""]
```
### 4.6 Self-Add Bypasses Policy via NIP-29 (AC-12)
```bash
# Agent policy is owner_only from 4.5
```
Send a signed kind:9000 event where **actor == target** (agent adds itself). Expected relay response:
```json
["OK", "<event-id>", true, ""]
```
Event stored, agent added to channel regardless of policy.
### 4.7 Using `nak` to Sign and Send Events
If you have `nak` installed:
```bash
# Generate event and pipe to websocat
nak event \
--kind 9000 \
--tag e="<channel-event-id>" \
--tag p="<agent-hex>" \
--tag role="member" \
--sec "$ACTOR_PRIVKEY_HEX" \
| nak encode \
| websocat ws://localhost:3001
```
---
## 5. MCP Tool Tests
The `set_channel_add_policy` MCP tool is available when the agent is connected via `sprout-mcp`.
### 5.1 Prerequisites
- `sprout-mcp` server running and connected to an MCP client (e.g., goose)
- Agent authenticated with a valid API token
### 5.2 Set Policy via MCP Tool (AC-7, AC-8)
**Set to `owner_only`:**
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "owner_only"
}
}
```
Expected tool response:
```json
{
"channel_add_policy": "owner_only",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Set to `nobody`:**
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "nobody"
}
}
```
Expected tool response:
```json
{
"channel_add_policy": "nobody",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Set to `anyone` (reset):**
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "anyone"
}
}
```
Expected tool response:
```json
{
"channel_add_policy": "anyone",
"agent_owner_pubkey": "<owner-hex>"
}
```
### 5.3 Invalid Policy via MCP Tool
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "invite_only"
}
}
```
Expected tool response (error string, not JSON):
```
Error: invalid policy "invite_only" — must be 'anyone', 'owner_only', or 'nobody'
```
### 5.4 Verify via REST After MCP Call
After calling the MCP tool, confirm the DB was updated:
```bash
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $AGENT_TOKEN" | jq '{channel_add_policy, agent_owner_pubkey}'
```
---
## 6. Database Verification
Direct SQL queries to verify schema and data state.
```bash
# Connect to MySQL
mysql -u root -p sprout
# Or with DATABASE_URL
mysql "$DATABASE_URL"
```
### 6.1 Verify Migration Applied (AC-6 prerequisite)
```sql
DESCRIBE users;
-- Look for:
-- agent_owner_pubkey | varbinary(32) | YES | MUL | NULL |
-- channel_add_policy | enum('anyone','owner_only','nobody') | NO | | anyone |
```
### 6.2 Verify Agent Owner Set at Mint Time (AC-6)
```sql
-- Replace X'...' with binary representation of hex pubkeys
-- Use UNHEX() for convenience:
SELECT
HEX(pubkey) AS pubkey,
HEX(agent_owner_pubkey) AS agent_owner_pubkey,
channel_add_policy
FROM users
WHERE pubkey = UNHEX('<agent-hex>');
```
Expected result after `mint-token --owner-pubkey <owner-hex>`:
```
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
| pubkey | agent_owner_pubkey | channel_add_policy |
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
| <agent-hex> | <owner-hex> | anyone |
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
```
### 6.3 Verify Policy Update
```sql
SELECT
HEX(pubkey) AS pubkey,
channel_add_policy,
HEX(agent_owner_pubkey) AS agent_owner_pubkey
FROM users
WHERE pubkey IN (UNHEX('<agent-hex>'), UNHEX('<owner-hex>'), UNHEX('<stranger-hex>'));
```
### 6.4 Verify Event NOT Stored After NIP-29 Rejection (AC-5)
After a kind:9000 rejection (§4.4, §4.5), confirm the event is absent from the events table:
```sql
SELECT id, kind, HEX(pubkey) AS pubkey, created_at
FROM events
WHERE kind = 9000
AND pubkey = UNHEX('<actor-hex>')
ORDER BY created_at DESC
LIMIT 5;
-- Should NOT contain the rejected event ID
```
### 6.5 Verify Default Policy for New Users (AC-9)
```sql
-- All users should have channel_add_policy = 'anyone' unless explicitly changed
SELECT COUNT(*) AS total_users,
SUM(channel_add_policy = 'anyone') AS anyone_count,
SUM(channel_add_policy = 'owner_only') AS owner_only_count,
SUM(channel_add_policy = 'nobody') AS nobody_count
FROM users;
```
### 6.6 Verify FK ON DELETE SET NULL (AC-10)
```sql
-- Before: agent has owner set
SELECT HEX(pubkey), HEX(agent_owner_pubkey), channel_add_policy
FROM users WHERE pubkey = UNHEX('<agent-hex>');
-- Delete the owner account (simulate owner deletion)
DELETE FROM users WHERE pubkey = UNHEX('<owner-hex>');
-- After: agent_owner_pubkey should be NULL (FK ON DELETE SET NULL)
SELECT HEX(pubkey), HEX(agent_owner_pubkey), channel_add_policy
FROM users WHERE pubkey = UNHEX('<agent-hex>');
-- Expected: agent_owner_pubkey = NULL, channel_add_policy unchanged
```
> ⚠️ **Warning**: This test deletes the owner user row. Use a throwaway keypair for this test and recreate the owner afterwards if needed.
---
## 7. Acceptance Criteria Checklist
| # | Criterion | Test Section | Pass Condition |
|---|-----------|-------------|----------------|
| AC-1 | Agent with `anyone` policy can be added by any authenticated user | §3.2 | `added` array contains agent pubkey |
| AC-2 | Agent with `owner_only` policy can be added by its owner | §3.4 | `added` array contains agent pubkey |
| AC-3 | Agent with `owner_only` policy cannot be added by non-owner | §3.3, §4.5 | `errors` array contains agent pubkey with `policy:owner_only` message |
| AC-4 | Agent with `nobody` policy cannot be added by anyone (self-add still allowed) | §3.5, §4.4 | `errors` array contains agent pubkey with `policy:nobody` message |
| AC-5 | NIP-29 kind:9000 enforces same policy as REST, BEFORE event storage | §4.4, §4.5 | `OK false "invalid: policy:..."` returned; event absent from DB (§6.4) |
| AC-6 | `sprout-admin mint-token --owner-pubkey <hex>` sets `agent_owner_pubkey` in `users` | §2.3, §6.2 | DB row has correct `agent_owner_pubkey` after mint |
| AC-7 | Agent can set own policy via MCP `set_channel_add_policy` tool | §5.2 | Tool returns updated policy; DB reflects change (§5.4) |
| AC-8 | Any user can set own policy via `PUT /api/users/me/channel-add-policy` | §3.1 | 200 response with updated `channel_add_policy` |
| AC-9 | Default policy is `anyone` — no behavior change for existing agents | §3.2, §6.5 | Existing add flows unaffected; new users default to `anyone` |
| AC-10 | Owner account deletion sets `agent_owner_pubkey = NULL` | §6.6 | After `DELETE FROM users WHERE pubkey = owner`, agent row has `agent_owner_pubkey = NULL` |
| AC-11 | `owner_only` with NULL owner returns policy error | §3.8 | `errors` contains `"policy:owner_only — agent has no owner set"` |
| AC-12 | Self-add bypasses policy for any user type, via both REST and NIP-29 | §3.6, §4.6 | `added` array contains actor pubkey regardless of policy |
| AC-13 | Batch add with mixed allowed/blocked pubkeys: partial success | §3.7 | `added` contains allowed pubkeys; `errors` contains blocked pubkeys with policy messages |
---
## Quick Reference
### Policy Values
| Value | Who Can Add Agent | Notes |
|-------|------------------|-------|
| `anyone` | Any authenticated user | Default. Backward compatible. |
| `owner_only` | Only `agent_owner_pubkey` holder | NULL owner → effectively `nobody` |
| `nobody` | No one (self-add still works) | Private channels inaccessible |
### Key Endpoints
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `PUT` | `/api/users/me/channel-add-policy` | Bearer token | Set caller's channel add policy |
| `POST` | `/api/channels/{id}/members` | Bearer token | Add members (policy enforced per-item) |
| `POST` | `/api/channels/{id}/join` | Bearer token | Self-join open channel (no policy check) |
### Error Messages
| Policy | REST error string | NIP-29 rejection string |
|--------|------------------|------------------------|
| `owner_only` (non-owner) | `policy:owner_only — only the agent owner can add this agent` | `invalid: policy:owner_only — only the agent owner can add this agent` |
| `owner_only` (no owner set) | `policy:owner_only — agent has no owner set` | `invalid: policy:owner_only — agent has no owner set` |
| `nobody` | `policy:nobody — this agent has disabled external channel additions` | `invalid: policy:nobody — this agent has disabled external channel additions` |
| DB error | `policy lookup failed: <error>` | propagated as `?` error |
+1
View File
@@ -18,4 +18,5 @@ nostr = { workspace = true }
tokio = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
hex = { workspace = true }
clap = { version = "4", features = ["derive"] }
+26 -2
View File
@@ -30,6 +30,11 @@ enum Command {
/// Nostr public key (hex). If omitted, generates a new keypair.
#[arg(long)]
pubkey: Option<String>,
/// Hex pubkey of the human operator who owns this agent.
/// If provided, sets agent_owner_pubkey in the users table.
#[arg(long)]
owner_pubkey: Option<String>,
},
/// List all active API tokens.
ListTokens,
@@ -53,14 +58,21 @@ async fn main() -> Result<()> {
name,
scopes,
pubkey,
} => mint_token(&db, &name, &scopes, pubkey.as_deref()).await?,
owner_pubkey,
} => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?,
Command::ListTokens => list_tokens(&db).await?,
}
Ok(())
}
async fn mint_token(db: &Db, name: &str, scopes_str: &str, pubkey_hex: Option<&str>) -> Result<()> {
async fn mint_token(
db: &Db,
name: &str,
scopes_str: &str,
pubkey_hex: Option<&str>,
owner_pubkey: Option<String>,
) -> Result<()> {
let scopes: Vec<String> = scopes_str
.split(',')
.map(|s| s.trim().to_string())
@@ -78,6 +90,18 @@ async fn mint_token(db: &Db, name: &str, scopes_str: &str, pubkey_hex: Option<&s
db.ensure_user(&pubkey_bytes).await?;
// Set agent owner if --owner-pubkey was provided
if let Some(ref owner_hex) = owner_pubkey {
let owner_bytes =
hex::decode(owner_hex).map_err(|e| anyhow::anyhow!("invalid owner pubkey hex: {e}"))?;
if owner_bytes.len() != 32 {
anyhow::bail!("owner pubkey must be 32 bytes (64 hex chars)");
}
// Ensure owner's user row exists (FK constraint requires it)
db.ensure_user(&owner_bytes).await?;
db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?;
}
let raw_token = generate_token();
let token_hash = hash_token(&raw_token);
+18
View File
@@ -588,6 +588,24 @@ impl Db {
user::get_user_by_nip05(&self.pool, local_part, domain).await
}
/// Set the owner pubkey for an agent user.
pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result<()> {
user::set_agent_owner(&self.pool, agent_pubkey, owner_pubkey).await
}
/// Get the channel_add_policy and agent_owner_pubkey for a user.
pub async fn get_agent_channel_policy(
&self,
pubkey: &[u8],
) -> Result<Option<(String, Option<Vec<u8>>)>> {
user::get_agent_channel_policy(&self.pool, pubkey).await
}
/// Set the channel_add_policy for a user.
pub async fn set_channel_add_policy(&self, pubkey: &[u8], policy: &str) -> Result<()> {
user::set_channel_add_policy(&self.pool, pubkey, policy).await
}
// ── API Tokens ───────────────────────────────────────────────────────────
/// Looks up a non-revoked API token by its SHA-256 hash.
+247
View File
@@ -2,6 +2,7 @@
use crate::error::Result;
use sqlx::MySqlPool;
use sqlx::Row;
/// A user's profile fields.
#[derive(Debug, Clone)]
@@ -165,3 +166,249 @@ pub async fn get_user_by_nip05(
},
))
}
/// Set the owner pubkey for an agent user.
/// The owner pubkey must already exist in the users table (FK constraint).
/// Returns an error if the agent pubkey is not found (rows_affected == 0).
pub async fn set_agent_owner(
pool: &MySqlPool,
agent_pubkey: &[u8],
owner_pubkey: &[u8],
) -> Result<()> {
let result = sqlx::query(r#"UPDATE users SET agent_owner_pubkey = ? WHERE pubkey = ?"#)
.bind(owner_pubkey)
.bind(agent_pubkey)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::DbError::NotFound(
"agent pubkey not found in users table".into(),
));
}
Ok(())
}
/// Get the channel_add_policy and agent_owner_pubkey for a user.
/// Returns None if the pubkey is not in the users table.
/// Returns Some((policy_str, owner_bytes_or_none)) if found.
pub async fn get_agent_channel_policy(
pool: &MySqlPool,
pubkey: &[u8],
) -> Result<Option<(String, Option<Vec<u8>>)>> {
let row =
sqlx::query(r#"SELECT channel_add_policy, agent_owner_pubkey FROM users WHERE pubkey = ?"#)
.bind(pubkey)
.fetch_optional(pool)
.await?;
row.map(|r| -> Result<(String, Option<Vec<u8>>)> {
let policy: String = r.try_get("channel_add_policy")?;
let owner: Option<Vec<u8>> = r.try_get("agent_owner_pubkey").unwrap_or(None);
Ok((policy, owner))
})
.transpose()
}
/// Set the channel_add_policy for a user.
/// Returns an error if the pubkey is not found (rows_affected == 0).
/// Returns an error if `policy` is not one of the valid ENUM values.
pub async fn set_channel_add_policy(pool: &MySqlPool, pubkey: &[u8], policy: &str) -> Result<()> {
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
return Err(crate::error::DbError::InvalidData(format!(
"invalid channel_add_policy: {policy}"
)));
}
let result = sqlx::query(r#"UPDATE users SET channel_add_policy = ? WHERE pubkey = ?"#)
.bind(policy)
.bind(pubkey)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::DbError::NotFound(
"pubkey not found in users table".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Db;
use nostr::Keys;
const TEST_DB_URL: &str = "mysql://sprout:sprout_dev@localhost:3306/sprout";
async fn setup_db() -> Db {
let pool = MySqlPool::connect(TEST_DB_URL)
.await
.expect("connect to test DB");
sqlx::migrate!("../../migrations")
.run(&pool)
.await
.expect("migrate");
Db::from_pool(pool)
}
fn random_pubkey() -> Vec<u8> {
Keys::generate().public_key().serialize().to_vec()
}
/// Setting an agent owner then reading back the policy should return
/// the default "anyone" policy and the owner pubkey.
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_set_agent_owner_and_get_policy() {
let db = setup_db().await;
let agent_pk = random_pubkey();
let owner_pk = random_pubkey();
ensure_user(&db.pool, &agent_pk)
.await
.expect("ensure agent");
ensure_user(&db.pool, &owner_pk)
.await
.expect("ensure owner");
set_agent_owner(&db.pool, &agent_pk, &owner_pk)
.await
.expect("set_agent_owner");
let result = get_agent_channel_policy(&db.pool, &agent_pk)
.await
.expect("get_agent_channel_policy");
let (policy, owner) = result.expect("should return Some for known pubkey");
assert_eq!(policy, "anyone", "default policy should be 'anyone'");
assert_eq!(
owner,
Some(owner_pk),
"owner pubkey should match what was set"
);
}
/// set_channel_add_policy should persist each of the three valid policies.
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_set_channel_add_policy() {
let db = setup_db().await;
let pk = random_pubkey();
ensure_user(&db.pool, &pk).await.expect("ensure user");
// owner_only
set_channel_add_policy(&db.pool, &pk, "owner_only")
.await
.expect("set owner_only");
let (policy, owner) = get_agent_channel_policy(&db.pool, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(policy, "owner_only");
assert!(owner.is_none(), "no owner was set");
// nobody
set_channel_add_policy(&db.pool, &pk, "nobody")
.await
.expect("set nobody");
let (policy, owner) = get_agent_channel_policy(&db.pool, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(policy, "nobody");
assert!(owner.is_none());
// anyone (reset to default)
set_channel_add_policy(&db.pool, &pk, "anyone")
.await
.expect("set anyone");
let (policy, owner) = get_agent_channel_policy(&db.pool, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(policy, "anyone");
assert!(owner.is_none());
}
/// get_agent_channel_policy should return None for a pubkey that has
/// never been inserted into the users table.
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_get_policy_unknown_pubkey() {
let db = setup_db().await;
let pk = random_pubkey();
let result = get_agent_channel_policy(&db.pool, &pk)
.await
.expect("query should not error");
assert!(result.is_none(), "unknown pubkey should return None");
}
/// set_agent_owner should return Err when the agent pubkey does not exist
/// in the users table (0 rows affected → NotFound).
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_set_agent_owner_nonexistent_agent() {
let db = setup_db().await;
let agent_pk = random_pubkey();
let owner_pk = random_pubkey();
// Only ensure the owner exists — agent is intentionally absent.
ensure_user(&db.pool, &owner_pk)
.await
.expect("ensure owner");
let result = set_agent_owner(&db.pool, &agent_pk, &owner_pk).await;
assert!(
result.is_err(),
"should error when agent pubkey is not in users table"
);
}
/// set_channel_add_policy should return Err when the pubkey does not exist
/// in the users table (0 rows affected → NotFound).
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_set_channel_add_policy_nonexistent_user() {
let db = setup_db().await;
let pk = random_pubkey();
let result = set_channel_add_policy(&db.pool, &pk, "nobody").await;
assert!(
result.is_err(),
"should error when pubkey is not in users table"
);
}
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_set_channel_add_policy_rejects_invalid() {
let db = setup_db().await;
let pubkey = nostr::Keys::generate().public_key().serialize().to_vec();
ensure_user(&db.pool, &pubkey).await.unwrap();
let result = set_channel_add_policy(&db.pool, &pubkey, "invalid_policy").await;
assert!(result.is_err(), "should reject invalid policy value");
}
/// A user with "owner_only" policy but no agent_owner_pubkey set should
/// return Some(("owner_only", None)).
#[tokio::test]
#[ignore = "requires MySQL"]
async fn test_owner_only_with_no_owner() {
let db = setup_db().await;
let pk = random_pubkey();
ensure_user(&db.pool, &pk).await.expect("ensure user");
set_channel_add_policy(&db.pool, &pk, "owner_only")
.await
.expect("set owner_only");
let result = get_agent_channel_policy(&db.pool, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(result.0, "owner_only");
assert!(result.1.is_none(), "owner should be None when never set");
}
}
+36
View File
@@ -425,6 +425,16 @@ pub struct SetPresenceParams {
pub status: PresenceStatus,
}
/// Parameters for the `set_channel_add_policy` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetChannelAddPolicyParams {
/// Channel add policy: "anyone" (default), "owner_only", or "nobody".
/// - "anyone": any authenticated user can add you to open channels.
/// - "owner_only": only your provisioned owner can add you.
/// - "nobody": no one can add you; you must self-join channels.
pub policy: String,
}
/// Parameters for the `get_feed` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetFeedParams {
@@ -1418,6 +1428,32 @@ impl SproutMcpServer {
Err(e) => format!("Error: {e}"),
}
}
/// Set this agent's channel addition policy.
#[tool(
name = "set_channel_add_policy",
description = "Set your channel addition policy. 'anyone' = any authenticated user can add you to open channels (default). 'owner_only' = only your provisioned owner can add you. 'nobody' = no one can add you; you may self-join open channels via join_channel, but private channels are inaccessible until a consent flow is implemented."
)]
pub async fn set_channel_add_policy(
&self,
Parameters(p): Parameters<SetChannelAddPolicyParams>,
) -> String {
if !matches!(p.policy.as_str(), "anyone" | "owner_only" | "nobody") {
return format!(
"Error: invalid policy {:?} — must be 'anyone', 'owner_only', or 'nobody'",
p.policy
);
}
let body = serde_json::json!({ "channel_add_policy": p.policy });
match self
.client
.put("/api/users/me/channel-add-policy", &body)
.await
{
Ok(b) => b,
Err(e) => format!("Error: {e}"),
}
}
}
#[tool_handler]
+84
View File
@@ -113,6 +113,90 @@ pub async fn add_members(
}
};
// --- Agent Channel Protection: self-add bypass + policy check ---
// Self-add: always allowed, skip policy check.
if pubkey_bytes == actor_bytes {
match state
.db
.add_member(channel_id, &pubkey_bytes, role.clone(), Some(&actor_bytes))
.await
{
Ok(_) => {
let target_hex = nostr_hex::encode(&pubkey_bytes);
if let Err(e) = emit_system_message(
&state,
channel_id,
serde_json::json!({
"type": "member_joined",
"actor": &actor_hex,
"target": target_hex,
}),
)
.await
{
tracing::warn!("Failed to emit system message: {e}");
}
added.push(hex_pk.clone());
}
Err(e) => {
errors.push(serde_json::json!({
"pubkey": hex_pk,
"error": e.to_string()
}));
}
}
continue;
}
// Third-party add: check channel_add_policy.
// FAIL CLOSED: DB errors block the add (never bypass protection).
match state.db.get_agent_channel_policy(&pubkey_bytes).await {
Err(e) => {
errors.push(serde_json::json!({
"pubkey": hex_pk,
"error": format!("policy lookup failed: {e}"),
}));
continue;
}
Ok(Some((ref policy, ref owner))) => {
let blocked = match policy.as_str() {
"owner_only" => match owner {
Some(owner_bytes) => actor_bytes.as_slice() != owner_bytes.as_slice(),
None => true,
},
"nobody" => true,
// "anyone" or any unknown value → allow.
// NOTE: DB ENUM constraint prevents unknown values from being stored.
// If a new policy value is added to the ENUM, update this match.
_ => false,
};
if blocked {
let reason = match policy.as_str() {
"owner_only" if owner.is_none() => {
"policy:owner_only — agent has no owner set"
}
"owner_only" => {
"policy:owner_only — only the agent owner can add this agent"
}
"nobody" => {
"policy:nobody — this agent has disabled external channel additions"
}
_ => "policy:blocked",
};
errors.push(serde_json::json!({
"pubkey": hex_pk,
"error": reason,
}));
continue;
}
}
Ok(None) => {
// Pubkey not in users table — no policy row, treat as "anyone" (default).
}
}
// --- End Agent Channel Protection ---
match state
.db
.add_member(channel_id, &pubkey_bytes, role.clone(), Some(&actor_bytes))
+3 -1
View File
@@ -62,7 +62,9 @@ pub use messages::{delete_message, get_thread, list_messages, send_message};
pub use presence::{presence_handler, set_presence_handler};
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
pub use search::search_handler;
pub use users::{get_profile, get_user_profile, get_users_batch, update_profile};
pub use users::{
get_profile, get_user_profile, get_users_batch, put_channel_add_policy, update_profile,
};
pub use workflows::{
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
trigger_workflow, update_workflow, workflow_webhook,
+43
View File
@@ -248,3 +248,46 @@ pub async fn get_users_batch(
"missing": missing,
})))
}
/// Request body for updating channel add policy.
#[derive(Debug, Deserialize)]
pub struct UpdateChannelAddPolicyBody {
/// Policy value: `"anyone"`, `"owner_only"`, or `"nobody"`.
pub channel_add_policy: String,
}
/// `PUT /api/users/me/channel-add-policy` — set the caller's channel add policy.
pub async fn put_channel_add_policy(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
ExtractJson(body): ExtractJson<UpdateChannelAddPolicyBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey_hex, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let policy = body.channel_add_policy.as_str();
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
return Err(api_error(
StatusCode::BAD_REQUEST,
"channel_add_policy must be 'anyone', 'owner_only', or 'nobody'",
));
}
state
.db
.set_channel_add_policy(&pubkey_bytes, policy)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
// Return updated state
let (current_policy, owner_pk) = state
.db
.get_agent_channel_policy(&pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.unwrap_or_else(|| ("anyone".to_string(), None));
Ok(Json(serde_json::json!({
"channel_add_policy": current_policy,
"agent_owner_pubkey": owner_pk.map(|b| nostr_hex::encode(&b)),
})))
}
@@ -76,19 +76,53 @@ pub async fn validate_admin_event(
match kind {
9000 => {
// PUT_USER: open channels allow any member; private requires owner/admin
// PUT_USER: open channels allow any authenticated user; private requires owner/admin.
// Policy check applies to both open and private channels.
if channel.visibility == "private" {
// Check actor is owner/admin
let members = state.db.get_members(channel_id).await?;
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
_ => Err(anyhow::anyhow!("actor not authorized")),
Some(m) if m.role == "owner" || m.role == "admin" => {}
_ => return Err(anyhow::anyhow!("actor not authorized")),
}
} else {
// Open channel: any authenticated user can add
Ok(())
}
// Extract target pubkey from p tag
let target_pubkey =
extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?;
// Self-add: always allowed regardless of policy.
if target_pubkey == actor_bytes {
return Ok(());
}
// Third-party add: check channel_add_policy on the target.
if let Some((policy, owner)) = state.db.get_agent_channel_policy(&target_pubkey).await?
{
match policy.as_str() {
"owner_only" => {
let owner_bytes = owner.ok_or_else(|| {
anyhow::anyhow!("policy:owner_only — agent has no owner set")
})?;
if actor_bytes != owner_bytes {
return Err(anyhow::anyhow!(
"policy:owner_only — only the agent owner can add this agent"
));
}
}
"nobody" => {
return Err(anyhow::anyhow!(
"policy:nobody — this agent has disabled external channel additions"
));
}
// "anyone" or any unknown value → allow.
// NOTE: DB ENUM constraint prevents unknown values from being stored.
// If a new policy value is added to the ENUM, update this match.
_ => {}
}
}
Ok(())
}
9001 => {
// REMOVE_USER: self-remove allowed unless actor is the last owner; removing others requires owner/admin
+4
View File
@@ -125,6 +125,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/api/users/me/profile",
get(api::get_profile).put(api::update_profile),
)
.route(
"/api/users/me/channel-add-policy",
put(api::put_channel_add_policy),
)
.route("/api/users/{pubkey}/profile", get(api::get_user_profile))
.route("/api/users/batch", post(api::get_users_batch))
// Feed route
@@ -797,3 +797,196 @@ async fn test_kind0_nip05_sync() {
client.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): default policy ("anyone") allows a third party to add an agent.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_default_policy_allows() {
let url = relay_url();
let channel_owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Create a channel owned by channel_owner.
let channel_id = create_test_channel(&channel_owner_keys).await;
// Connect as channel_owner.
let mut ws = SproutTestClient::connect(&url, &channel_owner_keys)
.await
.expect("connect as channel_owner");
// Build kind 9000 PUT_USER event: h = channel_id, p = agent pubkey.
let h_tag = nostr::Tag::parse(&["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(&["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "", [h_tag, p_tag])
.sign_with_keys(&channel_owner_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
ok.accepted,
"default policy should allow PUT_USER, got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): "nobody" policy blocks a third party from adding the agent.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_nobody_blocks() {
let url = relay_url();
let channel_owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Set agent's channel_add_policy to "nobody" via REST.
let http_client = reqwest::Client::new();
let resp = http_client
.put(format!(
"{}/api/users/me/channel-add-policy",
relay_http_url()
))
.header("X-Pubkey", &agent_pubkey_hex)
.json(&serde_json::json!({ "channel_add_policy": "nobody" }))
.send()
.await
.expect("set policy request");
assert_eq!(resp.status(), 200, "set policy failed");
// Create a channel owned by channel_owner (not the agent).
let channel_id = create_test_channel(&channel_owner_keys).await;
// Connect as channel_owner.
let mut ws = SproutTestClient::connect(&url, &channel_owner_keys)
.await
.expect("connect as channel_owner");
// Build kind 9000 PUT_USER event targeting the agent.
let h_tag = nostr::Tag::parse(&["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(&["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "", [h_tag, p_tag])
.sign_with_keys(&channel_owner_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
!ok.accepted,
"nobody policy should block PUT_USER, but relay accepted it"
);
assert!(
ok.message.contains("policy:nobody"),
"rejection message should contain 'policy:nobody', got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): self-add bypasses "nobody" policy — an agent can always add itself.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_self_add_bypasses_policy() {
let url = relay_url();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Set agent's channel_add_policy to "nobody" via REST.
let http_client = reqwest::Client::new();
let resp = http_client
.put(format!(
"{}/api/users/me/channel-add-policy",
relay_http_url()
))
.header("X-Pubkey", &agent_pubkey_hex)
.json(&serde_json::json!({ "channel_add_policy": "nobody" }))
.send()
.await
.expect("set policy request");
assert_eq!(resp.status(), 200, "set policy failed");
// Create a channel where the agent is the owner.
let channel_id = create_test_channel(&agent_keys).await;
// Connect as agent.
let mut ws = SproutTestClient::connect(&url, &agent_keys)
.await
.expect("connect as agent");
// Build kind 9000 PUT_USER event where agent targets ITSELF.
let h_tag = nostr::Tag::parse(&["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(&["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "", [h_tag, p_tag])
.sign_with_keys(&agent_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
ok.accepted,
"self-add should bypass nobody policy, got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000: `owner_only` policy blocks third-party PUT_USER.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_owner_only_blocks() {
let url = relay_url();
let channel_owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Set agent's channel_add_policy to "owner_only" via REST.
let http_client = reqwest::Client::new();
let resp = http_client
.put(format!(
"{}/api/users/me/channel-add-policy",
relay_http_url()
))
.header("X-Pubkey", &agent_pubkey_hex)
.json(&serde_json::json!({ "channel_add_policy": "owner_only" }))
.send()
.await
.expect("set policy request");
assert_eq!(resp.status(), 200, "set policy failed");
// Create a channel owned by channel_owner (not the agent).
let channel_id = create_test_channel(&channel_owner_keys).await;
// Connect as channel_owner.
let mut ws = SproutTestClient::connect(&url, &channel_owner_keys)
.await
.expect("connect as channel_owner");
// Build kind 9000 PUT_USER event targeting the agent.
let h_tag = nostr::Tag::parse(&["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(&["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "", [h_tag, p_tag])
.sign_with_keys(&channel_owner_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
!ok.accepted,
"owner_only policy should block third-party PUT_USER, but relay accepted it"
);
assert!(
ok.message.contains("policy:owner_only"),
"rejection message should contain 'policy:owner_only', got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
@@ -1595,3 +1595,447 @@ async fn test_nip05_duplicate_handle_conflict() {
"duplicate nip05_handle should return 409 Conflict"
);
}
// ── Agent Channel Protection tests ───────────────────────────────────────────
/// PUT /api/users/me/channel-add-policy updates the policy and returns the new value.
/// Cycles through owner_only → nobody → anyone to verify each round-trip.
#[tokio::test]
#[ignore]
async fn test_set_channel_add_policy_returns_updated_policy() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let url = format!("{}/api/users/me/channel-add-policy", relay_http_url());
// Set to owner_only
let resp = authed_put(
&client,
&url,
&pubkey_hex,
serde_json::json!({ "channel_add_policy": "owner_only" }),
)
.await;
assert_eq!(resp.status(), 200, "expected 200 for owner_only");
let body: serde_json::Value = resp.json().await.expect("json");
assert_eq!(
body["channel_add_policy"].as_str(),
Some("owner_only"),
"body should reflect owner_only"
);
// Set to nobody
let resp = authed_put(
&client,
&url,
&pubkey_hex,
serde_json::json!({ "channel_add_policy": "nobody" }),
)
.await;
assert_eq!(resp.status(), 200, "expected 200 for nobody");
let body: serde_json::Value = resp.json().await.expect("json");
assert_eq!(
body["channel_add_policy"].as_str(),
Some("nobody"),
"body should reflect nobody"
);
// Set to anyone
let resp = authed_put(
&client,
&url,
&pubkey_hex,
serde_json::json!({ "channel_add_policy": "anyone" }),
)
.await;
assert_eq!(resp.status(), 200, "expected 200 for anyone");
let body: serde_json::Value = resp.json().await.expect("json");
assert_eq!(
body["channel_add_policy"].as_str(),
Some("anyone"),
"body should reflect anyone"
);
}
/// PUT /api/users/me/channel-add-policy rejects unknown policy values with 400.
#[tokio::test]
#[ignore]
async fn test_set_channel_add_policy_rejects_invalid() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let resp = authed_put(
&client,
&format!("{}/api/users/me/channel-add-policy", relay_http_url()),
&pubkey_hex,
serde_json::json!({ "channel_add_policy": "invalid_value" }),
)
.await;
assert_eq!(resp.status(), 400, "invalid policy value should return 400");
}
/// Default policy (no policy set) allows anyone to add the agent to a channel.
#[tokio::test]
#[ignore]
async fn test_add_member_default_policy_allows_anyone() {
let client = http_client();
let owner_keys = Keys::generate();
let owner_hex = owner_keys.public_key().to_hex();
let agent_keys = Keys::generate();
let agent_hex = agent_keys.public_key().to_hex();
// Owner creates a channel (agent has no policy set — default "anyone")
let channel_name = format!("e2e-policy-default-{}", uuid::Uuid::new_v4().simple());
let create_resp = authed_post_json(
&client,
&format!("{}/api/channels", relay_http_url()),
&owner_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "open"
}),
)
.await;
assert_eq!(create_resp.status(), 201, "channel creation should succeed");
let channel: serde_json::Value = create_resp.json().await.expect("json");
let channel_id = channel["id"].as_str().expect("channel id");
// Owner adds agent — default policy should allow it
let resp = authed_post_json(
&client,
&format!("{}/api/channels/{channel_id}/members", relay_http_url()),
&owner_hex,
serde_json::json!({ "pubkeys": [agent_hex], "role": "member" }),
)
.await;
assert_eq!(resp.status(), 200, "add member should return 200");
let body: serde_json::Value = resp.json().await.expect("json");
let added = body["added"].as_array().expect("added array");
assert!(
added.iter().any(|v| v.as_str() == Some(&agent_hex)),
"agent should be in added list; got body: {body}"
);
}
/// owner_only policy blocks a non-owner stranger from adding the agent.
#[tokio::test]
#[ignore]
async fn test_add_member_owner_only_blocks_non_owner() {
let client = http_client();
let agent_keys = Keys::generate();
let agent_hex = agent_keys.public_key().to_hex();
let stranger_keys = Keys::generate();
let stranger_hex = stranger_keys.public_key().to_hex();
// Agent sets policy to owner_only
let resp = authed_put(
&client,
&format!("{}/api/users/me/channel-add-policy", relay_http_url()),
&agent_hex,
serde_json::json!({ "channel_add_policy": "owner_only" }),
)
.await;
assert_eq!(resp.status(), 200, "policy set should succeed");
// Stranger creates a channel
let channel_name = format!("e2e-owner-only-block-{}", uuid::Uuid::new_v4().simple());
let create_resp = authed_post_json(
&client,
&format!("{}/api/channels", relay_http_url()),
&stranger_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "open"
}),
)
.await;
assert_eq!(create_resp.status(), 201, "channel creation should succeed");
let channel: serde_json::Value = create_resp.json().await.expect("json");
let channel_id = channel["id"].as_str().expect("channel id");
// Stranger tries to add agent — should be blocked by owner_only policy
let resp = authed_post_json(
&client,
&format!("{}/api/channels/{channel_id}/members", relay_http_url()),
&stranger_hex,
serde_json::json!({ "pubkeys": [agent_hex], "role": "member" }),
)
.await;
assert_eq!(
resp.status(),
200,
"add member returns 200 even on policy block"
);
let body: serde_json::Value = resp.json().await.expect("json");
let errors = body["errors"].as_array().expect("errors array");
let agent_error = errors
.iter()
.find(|e| e["pubkey"].as_str() == Some(&agent_hex))
.unwrap_or_else(|| panic!("agent should be in errors; got body: {body}"));
let error_msg = agent_error["error"].as_str().unwrap_or("");
assert!(
error_msg.contains("policy:owner_only"),
"error should mention policy:owner_only; got: {error_msg}"
);
}
/// owner_only policy: when no owner is set, even the channel creator is blocked.
///
/// Full AC-2 coverage (owner IS set and can add) requires setting `agent_owner_pubkey`
/// via `sprout-admin set-agent-owner`, which is not available in e2e REST tests.
/// The owner bypass path is covered by DB-level unit tests in sprout-db.
///
/// What we CAN verify here: with `owner_only` set and no owner configured, the policy
/// blocks everyone — including the channel creator — because NULL owner matches nobody.
#[tokio::test]
#[ignore]
async fn test_add_member_owner_only_null_owner_blocks_all() {
let client = http_client();
let agent_keys = Keys::generate();
let agent_hex = agent_keys.public_key().to_hex();
let channel_creator_keys = Keys::generate();
let channel_creator_hex = channel_creator_keys.public_key().to_hex();
// Agent sets policy to owner_only (no agent_owner_pubkey is set — NULL by default).
let resp = authed_put(
&client,
&format!("{}/api/users/me/channel-add-policy", relay_http_url()),
&agent_hex,
serde_json::json!({ "channel_add_policy": "owner_only" }),
)
.await;
assert_eq!(resp.status(), 200, "policy set should succeed");
// Channel creator creates a channel.
let channel_name = format!("e2e-owner-only-noowner-{}", uuid::Uuid::new_v4().simple());
let create_resp = authed_post_json(
&client,
&format!("{}/api/channels", relay_http_url()),
&channel_creator_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "open"
}),
)
.await;
assert_eq!(create_resp.status(), 201, "channel creation should succeed");
let channel: serde_json::Value = create_resp.json().await.expect("json");
let channel_id = channel["id"].as_str().expect("channel id");
// Channel creator tries to add agent — owner_only with NULL owner blocks everyone.
let resp = authed_post_json(
&client,
&format!("{}/api/channels/{channel_id}/members", relay_http_url()),
&channel_creator_hex,
serde_json::json!({ "pubkeys": [agent_hex], "role": "member" }),
)
.await;
assert_eq!(
resp.status(),
200,
"add member returns 200 even on policy block"
);
let body: serde_json::Value = resp.json().await.expect("json");
let errors = body["errors"].as_array().expect("errors array");
let agent_error = errors
.iter()
.find(|e| e["pubkey"].as_str() == Some(&agent_hex))
.unwrap_or_else(|| panic!("agent should be in errors; got body: {body}"));
let error_msg = agent_error["error"].as_str().unwrap_or("");
assert!(
error_msg.contains("policy:owner_only"),
"error should mention policy:owner_only when no owner is set; got: {error_msg}"
);
}
/// nobody policy blocks all external callers from adding the agent.
#[tokio::test]
#[ignore]
async fn test_add_member_nobody_blocks_all() {
let client = http_client();
let agent_keys = Keys::generate();
let agent_hex = agent_keys.public_key().to_hex();
let stranger_keys = Keys::generate();
let stranger_hex = stranger_keys.public_key().to_hex();
// Agent sets policy to nobody
let resp = authed_put(
&client,
&format!("{}/api/users/me/channel-add-policy", relay_http_url()),
&agent_hex,
serde_json::json!({ "channel_add_policy": "nobody" }),
)
.await;
assert_eq!(resp.status(), 200, "policy set should succeed");
// Stranger creates a channel
let channel_name = format!("e2e-nobody-block-{}", uuid::Uuid::new_v4().simple());
let create_resp = authed_post_json(
&client,
&format!("{}/api/channels", relay_http_url()),
&stranger_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "open"
}),
)
.await;
assert_eq!(create_resp.status(), 201, "channel creation should succeed");
let channel: serde_json::Value = create_resp.json().await.expect("json");
let channel_id = channel["id"].as_str().expect("channel id");
// Stranger tries to add agent — nobody policy blocks all
let resp = authed_post_json(
&client,
&format!("{}/api/channels/{channel_id}/members", relay_http_url()),
&stranger_hex,
serde_json::json!({ "pubkeys": [agent_hex], "role": "member" }),
)
.await;
assert_eq!(
resp.status(),
200,
"add member returns 200 even on policy block"
);
let body: serde_json::Value = resp.json().await.expect("json");
let errors = body["errors"].as_array().expect("errors array");
let agent_error = errors
.iter()
.find(|e| e["pubkey"].as_str() == Some(&agent_hex))
.unwrap_or_else(|| panic!("agent should be in errors; got body: {body}"));
let error_msg = agent_error["error"].as_str().unwrap_or("");
assert!(
error_msg.contains("policy:nobody"),
"error should mention policy:nobody; got: {error_msg}"
);
}
/// Self-add bypasses the nobody policy — an agent can always add itself.
#[tokio::test]
#[ignore]
async fn test_self_add_bypasses_policy() {
let client = http_client();
let agent_keys = Keys::generate();
let agent_hex = agent_keys.public_key().to_hex();
// Agent sets policy to nobody
let resp = authed_put(
&client,
&format!("{}/api/users/me/channel-add-policy", relay_http_url()),
&agent_hex,
serde_json::json!({ "channel_add_policy": "nobody" }),
)
.await;
assert_eq!(resp.status(), 200, "policy set should succeed");
// Agent creates a channel (making agent the owner)
let channel_name = format!("e2e-self-add-{}", uuid::Uuid::new_v4().simple());
let create_resp = authed_post_json(
&client,
&format!("{}/api/channels", relay_http_url()),
&agent_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "open"
}),
)
.await;
assert_eq!(create_resp.status(), 201, "channel creation should succeed");
let channel: serde_json::Value = create_resp.json().await.expect("json");
let channel_id = channel["id"].as_str().expect("channel id");
// Agent adds itself — self-add should bypass nobody policy
let resp = authed_post_json(
&client,
&format!("{}/api/channels/{channel_id}/members", relay_http_url()),
&agent_hex,
serde_json::json!({ "pubkeys": [agent_hex], "role": "member" }),
)
.await;
assert_eq!(resp.status(), 200, "self-add should return 200");
let body: serde_json::Value = resp.json().await.expect("json");
let added = body["added"].as_array().expect("added array");
assert!(
added.iter().any(|v| v.as_str() == Some(&agent_hex)),
"agent should be in added list (self-add bypasses nobody); got body: {body}"
);
}
/// Batch add with mixed policies: allowed agent goes to added, blocked agent goes to errors.
#[tokio::test]
#[ignore]
async fn test_batch_add_mixed_policies() {
let client = http_client();
let owner_keys = Keys::generate();
let owner_hex = owner_keys.public_key().to_hex();
let allowed_agent_keys = Keys::generate();
let allowed_agent_hex = allowed_agent_keys.public_key().to_hex();
let blocked_agent_keys = Keys::generate();
let blocked_agent_hex = blocked_agent_keys.public_key().to_hex();
// Blocked agent sets policy to nobody
let resp = authed_put(
&client,
&format!("{}/api/users/me/channel-add-policy", relay_http_url()),
&blocked_agent_hex,
serde_json::json!({ "channel_add_policy": "nobody" }),
)
.await;
assert_eq!(resp.status(), 200, "policy set should succeed");
// Owner creates a channel (allowed_agent has no policy — default "anyone")
let channel_name = format!("e2e-batch-mixed-{}", uuid::Uuid::new_v4().simple());
let create_resp = authed_post_json(
&client,
&format!("{}/api/channels", relay_http_url()),
&owner_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "open"
}),
)
.await;
assert_eq!(create_resp.status(), 201, "channel creation should succeed");
let channel: serde_json::Value = create_resp.json().await.expect("json");
let channel_id = channel["id"].as_str().expect("channel id");
// Owner adds both agents in one batch request
let resp = authed_post_json(
&client,
&format!("{}/api/channels/{channel_id}/members", relay_http_url()),
&owner_hex,
serde_json::json!({
"pubkeys": [allowed_agent_hex, blocked_agent_hex],
"role": "member"
}),
)
.await;
assert_eq!(resp.status(), 200, "batch add should return 200");
let body: serde_json::Value = resp.json().await.expect("json");
// Allowed agent should be in added
let added = body["added"].as_array().expect("added array");
assert!(
added.iter().any(|v| v.as_str() == Some(&allowed_agent_hex)),
"allowed_agent should be in added; got body: {body}"
);
// Blocked agent should be in errors
let errors = body["errors"].as_array().expect("errors array");
let blocked_error = errors
.iter()
.find(|e| e["pubkey"].as_str() == Some(&blocked_agent_hex))
.unwrap_or_else(|| panic!("blocked_agent should be in errors; got body: {body}"));
let error_msg = blocked_error["error"].as_str().unwrap_or("");
assert!(
error_msg.contains("policy:nobody"),
"error should mention policy:nobody; got: {error_msg}"
);
}
@@ -0,0 +1,8 @@
-- Migration: add agent channel protection fields to users table
ALTER TABLE users
ADD COLUMN agent_owner_pubkey VARBINARY(32) NULL,
ADD COLUMN channel_add_policy ENUM('anyone', 'owner_only', 'nobody')
NOT NULL DEFAULT 'anyone',
ADD CONSTRAINT fk_users_agent_owner
FOREIGN KEY (agent_owner_pubkey) REFERENCES users(pubkey)
ON DELETE SET NULL;