mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: agent channel protection — policy-based control over who can add agents to channels (#34)
This commit is contained in:
Generated
+1
@@ -2635,6 +2635,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"hex",
|
||||
"nostr",
|
||||
"serde_json",
|
||||
"sprout-auth",
|
||||
|
||||
+817
@@ -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 |
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user