From 154abf3da9b3fb51c0de88c24eb80d86bfadca5c Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:20:44 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20channel=20protection=20?= =?UTF-8?q?=E2=80=94=20policy-based=20control=20over=20who=20can=20add=20a?= =?UTF-8?q?gents=20to=20channels=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + TESTING.md | 817 ++++++++++++++++++ crates/sprout-admin/Cargo.toml | 1 + crates/sprout-admin/src/main.rs | 28 +- crates/sprout-db/src/lib.rs | 18 + crates/sprout-db/src/user.rs | 247 ++++++ crates/sprout-mcp/src/server.rs | 36 + crates/sprout-relay/src/api/members.rs | 84 ++ crates/sprout-relay/src/api/mod.rs | 4 +- crates/sprout-relay/src/api/users.rs | 43 + .../sprout-relay/src/handlers/side_effects.rs | 48 +- crates/sprout-relay/src/router.rs | 4 + crates/sprout-test-client/tests/e2e_relay.rs | 193 +++++ .../sprout-test-client/tests/e2e_rest_api.rs | 444 ++++++++++ ...0260316000001_agent_channel_protection.sql | 8 + 15 files changed, 1966 insertions(+), 10 deletions(-) create mode 100644 migrations/20260316000001_agent_channel_protection.sql diff --git a/Cargo.lock b/Cargo.lock index 4fb2ce65c..1318cc977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2635,6 +2635,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "hex", "nostr", "serde_json", "sprout-auth", diff --git a/TESTING.md b/TESTING.md index 6954aeea4..991277a05 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 ``, ``, and `` 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: "" + +curl -s http://localhost:3001/api/users/me \ + -H "Authorization: Bearer $OWNER_TOKEN" | jq .pubkey +# Expected: "" +``` + +--- + +## 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": "" +} +``` + +**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": "" +} +``` + +**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": "" +} +``` + +**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": [""], + "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": "", + "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": [""], + "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": "", + "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": [""], + "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": [""], + "errors": [ + { + "pubkey": "", + "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": "", + "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": "", + "created_at": , + "tags": [ + ["e", ""], + ["p", ""], + ["role", "member"] + ], + "content": "", + "id": "", + "sig": "" +} +``` + +The relay message format (NIP-01): +```json +["EVENT", "", ] +``` + +### 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", "", 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", "", 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", "", 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", "", 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", "", 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="" \ + --tag p="" \ + --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": "" +} +``` + +**Set to `nobody`:** +```json +{ + "tool": "set_channel_add_policy", + "arguments": { + "policy": "nobody" + } +} +``` + +Expected tool response: +```json +{ + "channel_add_policy": "nobody", + "agent_owner_pubkey": "" +} +``` + +**Set to `anyone` (reset):** +```json +{ + "tool": "set_channel_add_policy", + "arguments": { + "policy": "anyone" + } +} +``` + +Expected tool response: +```json +{ + "channel_add_policy": "anyone", + "agent_owner_pubkey": "" +} +``` + +### 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(''); +``` + +Expected result after `mint-token --owner-pubkey `: +``` ++------------------------------------------------------------------+------------------------------------------------------------------+--------------------+ +| pubkey | agent_owner_pubkey | channel_add_policy | ++------------------------------------------------------------------+------------------------------------------------------------------+--------------------+ +| | | 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(''), UNHEX(''), UNHEX('')); +``` + +### 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('') +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(''); + +-- Delete the owner account (simulate owner deletion) +DELETE FROM users WHERE pubkey = UNHEX(''); + +-- 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(''); +-- 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 ` 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: ` | propagated as `?` error | diff --git a/crates/sprout-admin/Cargo.toml b/crates/sprout-admin/Cargo.toml index 52634b640..54bd99cde 100644 --- a/crates/sprout-admin/Cargo.toml +++ b/crates/sprout-admin/Cargo.toml @@ -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"] } diff --git a/crates/sprout-admin/src/main.rs b/crates/sprout-admin/src/main.rs index 6bd49e13e..e58d45d7d 100644 --- a/crates/sprout-admin/src/main.rs +++ b/crates/sprout-admin/src/main.rs @@ -30,6 +30,11 @@ enum Command { /// Nostr public key (hex). If omitted, generates a new keypair. #[arg(long)] pubkey: Option, + + /// 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, }, /// 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, +) -> Result<()> { let scopes: Vec = 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); diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 36203ee1d..8697bd7fd 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -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>)>> { + 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. diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index 4233b81be..d29498ef9 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -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>)>> { + 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>)> { + let policy: String = r.try_get("channel_add_policy")?; + let owner: Option> = 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 { + 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"); + } +} diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 375a76de7..98d3c4120 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -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, + ) -> 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] diff --git a/crates/sprout-relay/src/api/members.rs b/crates/sprout-relay/src/api/members.rs index 57bf9cdc6..2fd7dbd69 100644 --- a/crates/sprout-relay/src/api/members.rs +++ b/crates/sprout-relay/src/api/members.rs @@ -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)) diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 2c75addd6..8af69959b 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -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, diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index 8c2b42ae6..144540fa0 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -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>, + headers: HeaderMap, + ExtractJson(body): ExtractJson, +) -> Result, (StatusCode, Json)> { + 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)), + }))) +} diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 0063039f8..e4d8d5f44 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -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 diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index eb35d5c79..17f9140c8 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -125,6 +125,10 @@ pub fn build_router(state: Arc) -> 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 diff --git a/crates/sprout-test-client/tests/e2e_relay.rs b/crates/sprout-test-client/tests/e2e_relay.rs index 213e71646..3bdf92ef5 100644 --- a/crates/sprout-test-client/tests/e2e_relay.rs +++ b/crates/sprout-test-client/tests/e2e_relay.rs @@ -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"); +} diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 056089467..dacc04e99 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -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}" + ); +} diff --git a/migrations/20260316000001_agent_channel_protection.sql b/migrations/20260316000001_agent_channel_protection.sql new file mode 100644 index 000000000..873de6684 --- /dev/null +++ b/migrations/20260316000001_agent_channel_protection.sql @@ -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;